edsl 0.1.36.dev6__py3-none-any.whl → 0.1.37.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 (257) hide show
  1. edsl/Base.py +303 -303
  2. edsl/BaseDiff.py +260 -260
  3. edsl/TemplateLoader.py +24 -24
  4. edsl/__init__.py +48 -47
  5. edsl/__version__.py +1 -1
  6. edsl/agents/Agent.py +804 -804
  7. edsl/agents/AgentList.py +345 -337
  8. edsl/agents/Invigilator.py +222 -222
  9. edsl/agents/InvigilatorBase.py +305 -294
  10. edsl/agents/PromptConstructor.py +312 -312
  11. edsl/agents/__init__.py +3 -3
  12. edsl/agents/descriptors.py +86 -86
  13. edsl/agents/prompt_helpers.py +129 -129
  14. edsl/auto/AutoStudy.py +117 -117
  15. edsl/auto/StageBase.py +230 -230
  16. edsl/auto/StageGenerateSurvey.py +178 -178
  17. edsl/auto/StageLabelQuestions.py +125 -125
  18. edsl/auto/StagePersona.py +61 -61
  19. edsl/auto/StagePersonaDimensionValueRanges.py +88 -88
  20. edsl/auto/StagePersonaDimensionValues.py +74 -74
  21. edsl/auto/StagePersonaDimensions.py +69 -69
  22. edsl/auto/StageQuestions.py +73 -73
  23. edsl/auto/SurveyCreatorPipeline.py +21 -21
  24. edsl/auto/utilities.py +224 -224
  25. edsl/base/Base.py +289 -289
  26. edsl/config.py +149 -149
  27. edsl/conjure/AgentConstructionMixin.py +152 -152
  28. edsl/conjure/Conjure.py +62 -62
  29. edsl/conjure/InputData.py +659 -659
  30. edsl/conjure/InputDataCSV.py +48 -48
  31. edsl/conjure/InputDataMixinQuestionStats.py +182 -182
  32. edsl/conjure/InputDataPyRead.py +91 -91
  33. edsl/conjure/InputDataSPSS.py +8 -8
  34. edsl/conjure/InputDataStata.py +8 -8
  35. edsl/conjure/QuestionOptionMixin.py +76 -76
  36. edsl/conjure/QuestionTypeMixin.py +23 -23
  37. edsl/conjure/RawQuestion.py +65 -65
  38. edsl/conjure/SurveyResponses.py +7 -7
  39. edsl/conjure/__init__.py +9 -9
  40. edsl/conjure/naming_utilities.py +263 -263
  41. edsl/conjure/utilities.py +201 -201
  42. edsl/conversation/Conversation.py +238 -238
  43. edsl/conversation/car_buying.py +58 -58
  44. edsl/conversation/mug_negotiation.py +81 -81
  45. edsl/conversation/next_speaker_utilities.py +93 -93
  46. edsl/coop/PriceFetcher.py +54 -54
  47. edsl/coop/__init__.py +2 -2
  48. edsl/coop/coop.py +824 -849
  49. edsl/coop/utils.py +131 -131
  50. edsl/data/Cache.py +527 -527
  51. edsl/data/CacheEntry.py +228 -228
  52. edsl/data/CacheHandler.py +149 -149
  53. edsl/data/RemoteCacheSync.py +97 -84
  54. edsl/data/SQLiteDict.py +292 -292
  55. edsl/data/__init__.py +4 -4
  56. edsl/data/orm.py +10 -10
  57. edsl/data_transfer_models.py +73 -73
  58. edsl/enums.py +173 -173
  59. edsl/exceptions/__init__.py +50 -50
  60. edsl/exceptions/agents.py +40 -40
  61. edsl/exceptions/configuration.py +16 -16
  62. edsl/exceptions/coop.py +10 -10
  63. edsl/exceptions/data.py +14 -14
  64. edsl/exceptions/general.py +34 -34
  65. edsl/exceptions/jobs.py +33 -33
  66. edsl/exceptions/language_models.py +63 -63
  67. edsl/exceptions/prompts.py +15 -15
  68. edsl/exceptions/questions.py +91 -91
  69. edsl/exceptions/results.py +26 -26
  70. edsl/exceptions/surveys.py +34 -34
  71. edsl/inference_services/AnthropicService.py +87 -87
  72. edsl/inference_services/AwsBedrock.py +115 -115
  73. edsl/inference_services/AzureAI.py +217 -217
  74. edsl/inference_services/DeepInfraService.py +18 -18
  75. edsl/inference_services/GoogleService.py +156 -156
  76. edsl/inference_services/GroqService.py +20 -20
  77. edsl/inference_services/InferenceServiceABC.py +147 -147
  78. edsl/inference_services/InferenceServicesCollection.py +74 -72
  79. edsl/inference_services/MistralAIService.py +123 -123
  80. edsl/inference_services/OllamaService.py +18 -18
  81. edsl/inference_services/OpenAIService.py +224 -224
  82. edsl/inference_services/TestService.py +89 -89
  83. edsl/inference_services/TogetherAIService.py +170 -170
  84. edsl/inference_services/models_available_cache.py +118 -118
  85. edsl/inference_services/rate_limits_cache.py +25 -25
  86. edsl/inference_services/registry.py +39 -39
  87. edsl/inference_services/write_available.py +10 -10
  88. edsl/jobs/Answers.py +56 -56
  89. edsl/jobs/Jobs.py +1112 -1112
  90. edsl/jobs/__init__.py +1 -1
  91. edsl/jobs/buckets/BucketCollection.py +63 -63
  92. edsl/jobs/buckets/ModelBuckets.py +65 -65
  93. edsl/jobs/buckets/TokenBucket.py +248 -248
  94. edsl/jobs/interviews/Interview.py +661 -651
  95. edsl/jobs/interviews/InterviewExceptionCollection.py +99 -99
  96. edsl/jobs/interviews/InterviewExceptionEntry.py +182 -182
  97. edsl/jobs/interviews/InterviewStatistic.py +63 -63
  98. edsl/jobs/interviews/InterviewStatisticsCollection.py +25 -25
  99. edsl/jobs/interviews/InterviewStatusDictionary.py +78 -78
  100. edsl/jobs/interviews/InterviewStatusLog.py +92 -92
  101. edsl/jobs/interviews/ReportErrors.py +66 -66
  102. edsl/jobs/interviews/interview_status_enum.py +9 -9
  103. edsl/jobs/runners/JobsRunnerAsyncio.py +338 -337
  104. edsl/jobs/runners/JobsRunnerStatus.py +332 -332
  105. edsl/jobs/tasks/QuestionTaskCreator.py +242 -242
  106. edsl/jobs/tasks/TaskCreators.py +64 -64
  107. edsl/jobs/tasks/TaskHistory.py +441 -441
  108. edsl/jobs/tasks/TaskStatusLog.py +23 -23
  109. edsl/jobs/tasks/task_status_enum.py +163 -163
  110. edsl/jobs/tokens/InterviewTokenUsage.py +27 -27
  111. edsl/jobs/tokens/TokenUsage.py +34 -34
  112. edsl/language_models/LanguageModel.py +718 -718
  113. edsl/language_models/ModelList.py +102 -102
  114. edsl/language_models/RegisterLanguageModelsMeta.py +184 -184
  115. edsl/language_models/__init__.py +2 -2
  116. edsl/language_models/fake_openai_call.py +15 -15
  117. edsl/language_models/fake_openai_service.py +61 -61
  118. edsl/language_models/registry.py +137 -137
  119. edsl/language_models/repair.py +156 -156
  120. edsl/language_models/unused/ReplicateBase.py +83 -83
  121. edsl/language_models/utilities.py +64 -64
  122. edsl/notebooks/Notebook.py +259 -259
  123. edsl/notebooks/__init__.py +1 -1
  124. edsl/prompts/Prompt.py +353 -358
  125. edsl/prompts/__init__.py +2 -2
  126. edsl/questions/AnswerValidatorMixin.py +289 -289
  127. edsl/questions/QuestionBase.py +616 -616
  128. edsl/questions/QuestionBaseGenMixin.py +161 -161
  129. edsl/questions/QuestionBasePromptsMixin.py +266 -266
  130. edsl/questions/QuestionBudget.py +227 -227
  131. edsl/questions/QuestionCheckBox.py +359 -359
  132. edsl/questions/QuestionExtract.py +183 -183
  133. edsl/questions/QuestionFreeText.py +114 -113
  134. edsl/questions/QuestionFunctional.py +159 -159
  135. edsl/questions/QuestionList.py +231 -231
  136. edsl/questions/QuestionMultipleChoice.py +286 -286
  137. edsl/questions/QuestionNumerical.py +153 -153
  138. edsl/questions/QuestionRank.py +324 -324
  139. edsl/questions/Quick.py +41 -41
  140. edsl/questions/RegisterQuestionsMeta.py +71 -71
  141. edsl/questions/ResponseValidatorABC.py +174 -174
  142. edsl/questions/SimpleAskMixin.py +73 -73
  143. edsl/questions/__init__.py +26 -26
  144. edsl/questions/compose_questions.py +98 -98
  145. edsl/questions/decorators.py +21 -21
  146. edsl/questions/derived/QuestionLikertFive.py +76 -76
  147. edsl/questions/derived/QuestionLinearScale.py +87 -87
  148. edsl/questions/derived/QuestionTopK.py +91 -91
  149. edsl/questions/derived/QuestionYesNo.py +82 -82
  150. edsl/questions/descriptors.py +418 -418
  151. edsl/questions/prompt_templates/question_budget.jinja +13 -13
  152. edsl/questions/prompt_templates/question_checkbox.jinja +32 -32
  153. edsl/questions/prompt_templates/question_extract.jinja +11 -11
  154. edsl/questions/prompt_templates/question_free_text.jinja +3 -3
  155. edsl/questions/prompt_templates/question_linear_scale.jinja +11 -11
  156. edsl/questions/prompt_templates/question_list.jinja +17 -17
  157. edsl/questions/prompt_templates/question_multiple_choice.jinja +33 -33
  158. edsl/questions/prompt_templates/question_numerical.jinja +36 -36
  159. edsl/questions/question_registry.py +147 -147
  160. edsl/questions/settings.py +12 -12
  161. edsl/questions/templates/budget/answering_instructions.jinja +7 -7
  162. edsl/questions/templates/budget/question_presentation.jinja +7 -7
  163. edsl/questions/templates/checkbox/answering_instructions.jinja +10 -10
  164. edsl/questions/templates/checkbox/question_presentation.jinja +22 -22
  165. edsl/questions/templates/extract/answering_instructions.jinja +7 -7
  166. edsl/questions/templates/likert_five/answering_instructions.jinja +10 -10
  167. edsl/questions/templates/likert_five/question_presentation.jinja +11 -11
  168. edsl/questions/templates/linear_scale/answering_instructions.jinja +5 -5
  169. edsl/questions/templates/linear_scale/question_presentation.jinja +5 -5
  170. edsl/questions/templates/list/answering_instructions.jinja +3 -3
  171. edsl/questions/templates/list/question_presentation.jinja +5 -5
  172. edsl/questions/templates/multiple_choice/answering_instructions.jinja +9 -9
  173. edsl/questions/templates/multiple_choice/question_presentation.jinja +11 -11
  174. edsl/questions/templates/numerical/answering_instructions.jinja +6 -6
  175. edsl/questions/templates/numerical/question_presentation.jinja +6 -6
  176. edsl/questions/templates/rank/answering_instructions.jinja +11 -11
  177. edsl/questions/templates/rank/question_presentation.jinja +15 -15
  178. edsl/questions/templates/top_k/answering_instructions.jinja +8 -8
  179. edsl/questions/templates/top_k/question_presentation.jinja +22 -22
  180. edsl/questions/templates/yes_no/answering_instructions.jinja +6 -6
  181. edsl/questions/templates/yes_no/question_presentation.jinja +11 -11
  182. edsl/results/Dataset.py +293 -293
  183. edsl/results/DatasetExportMixin.py +693 -693
  184. edsl/results/DatasetTree.py +145 -145
  185. edsl/results/Result.py +435 -433
  186. edsl/results/Results.py +1160 -1158
  187. edsl/results/ResultsDBMixin.py +238 -238
  188. edsl/results/ResultsExportMixin.py +43 -43
  189. edsl/results/ResultsFetchMixin.py +33 -33
  190. edsl/results/ResultsGGMixin.py +121 -121
  191. edsl/results/ResultsToolsMixin.py +98 -98
  192. edsl/results/Selector.py +118 -118
  193. edsl/results/__init__.py +2 -2
  194. edsl/results/tree_explore.py +115 -115
  195. edsl/scenarios/FileStore.py +458 -443
  196. edsl/scenarios/Scenario.py +510 -507
  197. edsl/scenarios/ScenarioHtmlMixin.py +59 -59
  198. edsl/scenarios/ScenarioList.py +1101 -1101
  199. edsl/scenarios/ScenarioListExportMixin.py +52 -52
  200. edsl/scenarios/ScenarioListPdfMixin.py +261 -261
  201. edsl/scenarios/__init__.py +4 -2
  202. edsl/shared.py +1 -1
  203. edsl/study/ObjectEntry.py +173 -173
  204. edsl/study/ProofOfWork.py +113 -113
  205. edsl/study/SnapShot.py +80 -80
  206. edsl/study/Study.py +528 -528
  207. edsl/study/__init__.py +4 -4
  208. edsl/surveys/DAG.py +148 -148
  209. edsl/surveys/Memory.py +31 -31
  210. edsl/surveys/MemoryPlan.py +244 -244
  211. edsl/surveys/Rule.py +324 -324
  212. edsl/surveys/RuleCollection.py +387 -387
  213. edsl/surveys/Survey.py +1772 -1772
  214. edsl/surveys/SurveyCSS.py +261 -261
  215. edsl/surveys/SurveyExportMixin.py +259 -259
  216. edsl/surveys/SurveyFlowVisualizationMixin.py +121 -121
  217. edsl/surveys/SurveyQualtricsImport.py +284 -284
  218. edsl/surveys/__init__.py +3 -3
  219. edsl/surveys/base.py +53 -53
  220. edsl/surveys/descriptors.py +56 -56
  221. edsl/surveys/instructions/ChangeInstruction.py +47 -47
  222. edsl/surveys/instructions/Instruction.py +51 -51
  223. edsl/surveys/instructions/InstructionCollection.py +77 -77
  224. edsl/templates/error_reporting/base.html +23 -23
  225. edsl/templates/error_reporting/exceptions_by_model.html +34 -34
  226. edsl/templates/error_reporting/exceptions_by_question_name.html +16 -16
  227. edsl/templates/error_reporting/exceptions_by_type.html +16 -16
  228. edsl/templates/error_reporting/interview_details.html +115 -115
  229. edsl/templates/error_reporting/interviews.html +9 -9
  230. edsl/templates/error_reporting/overview.html +4 -4
  231. edsl/templates/error_reporting/performance_plot.html +1 -1
  232. edsl/templates/error_reporting/report.css +73 -73
  233. edsl/templates/error_reporting/report.html +117 -117
  234. edsl/templates/error_reporting/report.js +25 -25
  235. edsl/tools/__init__.py +1 -1
  236. edsl/tools/clusters.py +192 -192
  237. edsl/tools/embeddings.py +27 -27
  238. edsl/tools/embeddings_plotting.py +118 -118
  239. edsl/tools/plotting.py +112 -112
  240. edsl/tools/summarize.py +18 -18
  241. edsl/utilities/SystemInfo.py +28 -28
  242. edsl/utilities/__init__.py +22 -22
  243. edsl/utilities/ast_utilities.py +25 -25
  244. edsl/utilities/data/Registry.py +6 -6
  245. edsl/utilities/data/__init__.py +1 -1
  246. edsl/utilities/data/scooter_results.json +1 -1
  247. edsl/utilities/decorators.py +77 -77
  248. edsl/utilities/gcp_bucket/cloud_storage.py +96 -96
  249. edsl/utilities/interface.py +627 -627
  250. edsl/utilities/repair_functions.py +28 -28
  251. edsl/utilities/restricted_python.py +70 -70
  252. edsl/utilities/utilities.py +391 -391
  253. {edsl-0.1.36.dev6.dist-info → edsl-0.1.37.dev2.dist-info}/LICENSE +21 -21
  254. {edsl-0.1.36.dev6.dist-info → edsl-0.1.37.dev2.dist-info}/METADATA +1 -1
  255. edsl-0.1.37.dev2.dist-info/RECORD +279 -0
  256. edsl-0.1.36.dev6.dist-info/RECORD +0 -279
  257. {edsl-0.1.36.dev6.dist-info → edsl-0.1.37.dev2.dist-info}/WHEEL +0 -0
edsl/jobs/Jobs.py CHANGED
@@ -1,1112 +1,1112 @@
1
- # """The Jobs class is a collection of agents, scenarios and models and one survey."""
2
- from __future__ import annotations
3
- import warnings
4
- import requests
5
- from itertools import product
6
- from typing import Optional, Union, Sequence, Generator
7
-
8
- from edsl.Base import Base
9
- from edsl.exceptions import MissingAPIKeyError
10
- from edsl.jobs.buckets.BucketCollection import BucketCollection
11
- from edsl.jobs.interviews.Interview import Interview
12
- from edsl.jobs.runners.JobsRunnerAsyncio import JobsRunnerAsyncio
13
- from edsl.utilities.decorators import add_edsl_version, remove_edsl_version
14
-
15
- from edsl.data.RemoteCacheSync import RemoteCacheSync
16
- from edsl.exceptions.coop import CoopServerResponseError
17
-
18
-
19
- class Jobs(Base):
20
- """
21
- A collection of agents, scenarios and models and one survey.
22
- The actual running of a job is done by a `JobsRunner`, which is a subclass of `JobsRunner`.
23
- The `JobsRunner` is chosen by the user, and is stored in the `jobs_runner_name` attribute.
24
- """
25
-
26
- def __init__(
27
- self,
28
- survey: "Survey",
29
- agents: Optional[list["Agent"]] = None,
30
- models: Optional[list["LanguageModel"]] = None,
31
- scenarios: Optional[list["Scenario"]] = None,
32
- ):
33
- """Initialize a Jobs instance.
34
-
35
- :param survey: the survey to be used in the job
36
- :param agents: a list of agents
37
- :param models: a list of models
38
- :param scenarios: a list of scenarios
39
- """
40
- self.survey = survey
41
- self.agents: "AgentList" = agents
42
- self.scenarios: "ScenarioList" = scenarios
43
- self.models = models
44
-
45
- self.__bucket_collection = None
46
-
47
- # these setters and getters are used to ensure that the agents, models, and scenarios are stored as AgentList, ModelList, and ScenarioList objects
48
-
49
- @property
50
- def models(self):
51
- return self._models
52
-
53
- @models.setter
54
- def models(self, value):
55
- from edsl import ModelList
56
-
57
- if value:
58
- if not isinstance(value, ModelList):
59
- self._models = ModelList(value)
60
- else:
61
- self._models = value
62
- else:
63
- self._models = ModelList([])
64
-
65
- @property
66
- def agents(self):
67
- return self._agents
68
-
69
- @agents.setter
70
- def agents(self, value):
71
- from edsl import AgentList
72
-
73
- if value:
74
- if not isinstance(value, AgentList):
75
- self._agents = AgentList(value)
76
- else:
77
- self._agents = value
78
- else:
79
- self._agents = AgentList([])
80
-
81
- @property
82
- def scenarios(self):
83
- return self._scenarios
84
-
85
- @scenarios.setter
86
- def scenarios(self, value):
87
- from edsl import ScenarioList
88
-
89
- if value:
90
- if not isinstance(value, ScenarioList):
91
- self._scenarios = ScenarioList(value)
92
- else:
93
- self._scenarios = value
94
- else:
95
- self._scenarios = ScenarioList([])
96
-
97
- def by(
98
- self,
99
- *args: Union[
100
- "Agent",
101
- "Scenario",
102
- "LanguageModel",
103
- Sequence[Union["Agent", "Scenario", "LanguageModel"]],
104
- ],
105
- ) -> Jobs:
106
- """
107
- Add Agents, Scenarios and LanguageModels to a job. If no objects of this type exist in the Jobs instance, it stores the new objects as a list in the corresponding attribute. Otherwise, it combines the new objects with existing objects using the object's `__add__` method.
108
-
109
- This 'by' is intended to create a fluent interface.
110
-
111
- >>> from edsl import Survey
112
- >>> from edsl import QuestionFreeText
113
- >>> q = QuestionFreeText(question_name="name", question_text="What is your name?")
114
- >>> j = Jobs(survey = Survey(questions=[q]))
115
- >>> j
116
- Jobs(survey=Survey(...), agents=AgentList([]), models=ModelList([]), scenarios=ScenarioList([]))
117
- >>> from edsl import Agent; a = Agent(traits = {"status": "Sad"})
118
- >>> j.by(a).agents
119
- AgentList([Agent(traits = {'status': 'Sad'})])
120
-
121
- :param args: objects or a sequence (list, tuple, ...) of objects of the same type
122
-
123
- Notes:
124
- - all objects must implement the 'get_value', 'set_value', and `__add__` methods
125
- - agents: traits of new agents are combined with traits of existing agents. New and existing agents should not have overlapping traits, and do not increase the # agents in the instance
126
- - scenarios: traits of new scenarios are combined with traits of old existing. New scenarios will overwrite overlapping traits, and do not increase the number of scenarios in the instance
127
- - models: new models overwrite old models.
128
- """
129
- passed_objects = self._turn_args_to_list(
130
- args
131
- ) # objects can also be passed comma-separated
132
-
133
- current_objects, objects_key = self._get_current_objects_of_this_type(
134
- passed_objects[0]
135
- )
136
-
137
- if not current_objects:
138
- new_objects = passed_objects
139
- else:
140
- new_objects = self._merge_objects(passed_objects, current_objects)
141
-
142
- setattr(self, objects_key, new_objects) # update the job
143
- return self
144
-
145
- def prompts(self) -> "Dataset":
146
- """Return a Dataset of prompts that will be used.
147
-
148
-
149
- >>> from edsl.jobs import Jobs
150
- >>> Jobs.example().prompts()
151
- Dataset(...)
152
- """
153
- from edsl import Coop
154
-
155
- c = Coop()
156
- price_lookup = c.fetch_prices()
157
-
158
- interviews = self.interviews()
159
- # data = []
160
- interview_indices = []
161
- question_names = []
162
- user_prompts = []
163
- system_prompts = []
164
- scenario_indices = []
165
- agent_indices = []
166
- models = []
167
- costs = []
168
- from edsl.results.Dataset import Dataset
169
-
170
- for interview_index, interview in enumerate(interviews):
171
- invigilators = [
172
- interview._get_invigilator(question)
173
- for question in self.survey.questions
174
- ]
175
- for _, invigilator in enumerate(invigilators):
176
- prompts = invigilator.get_prompts()
177
- user_prompt = prompts["user_prompt"]
178
- system_prompt = prompts["system_prompt"]
179
- user_prompts.append(user_prompt)
180
- system_prompts.append(system_prompt)
181
- agent_index = self.agents.index(invigilator.agent)
182
- agent_indices.append(agent_index)
183
- interview_indices.append(interview_index)
184
- scenario_index = self.scenarios.index(invigilator.scenario)
185
- scenario_indices.append(scenario_index)
186
- models.append(invigilator.model.model)
187
- question_names.append(invigilator.question.question_name)
188
-
189
- prompt_cost = self.estimate_prompt_cost(
190
- system_prompt=system_prompt,
191
- user_prompt=user_prompt,
192
- price_lookup=price_lookup,
193
- inference_service=invigilator.model._inference_service_,
194
- model=invigilator.model.model,
195
- )
196
- costs.append(prompt_cost["cost"])
197
-
198
- d = Dataset(
199
- [
200
- {"user_prompt": user_prompts},
201
- {"system_prompt": system_prompts},
202
- {"interview_index": interview_indices},
203
- {"question_name": question_names},
204
- {"scenario_index": scenario_indices},
205
- {"agent_index": agent_indices},
206
- {"model": models},
207
- {"estimated_cost": costs},
208
- ]
209
- )
210
- return d
211
-
212
- def show_prompts(self, all=False) -> None:
213
- """Print the prompts."""
214
- if all:
215
- self.prompts().to_scenario_list().print(format="rich")
216
- else:
217
- self.prompts().select(
218
- "user_prompt", "system_prompt"
219
- ).to_scenario_list().print(format="rich")
220
-
221
- @staticmethod
222
- def estimate_prompt_cost(
223
- system_prompt: str,
224
- user_prompt: str,
225
- price_lookup: dict,
226
- inference_service: str,
227
- model: str,
228
- ) -> dict:
229
- """Estimates the cost of a prompt. Takes piping into account."""
230
-
231
- def get_piping_multiplier(prompt: str):
232
- """Returns 2 if a prompt includes Jinja braces, and 1 otherwise."""
233
-
234
- if "{{" in prompt and "}}" in prompt:
235
- return 2
236
- return 1
237
-
238
- # Look up prices per token
239
- key = (inference_service, model)
240
-
241
- try:
242
- relevant_prices = price_lookup[key]
243
- output_price_per_token = 1 / float(
244
- relevant_prices["output"]["one_usd_buys"]
245
- )
246
- input_price_per_token = 1 / float(relevant_prices["input"]["one_usd_buys"])
247
- except KeyError:
248
- # A KeyError is likely to occur if we cannot retrieve prices (the price_lookup dict is empty)
249
- # Use a sensible default
250
-
251
- import warnings
252
-
253
- warnings.warn(
254
- "Price data could not be retrieved. Using default estimates for input and output token prices. Input: $0.15 / 1M tokens; Output: $0.60 / 1M tokens"
255
- )
256
-
257
- output_price_per_token = 0.00000015 # $0.15 / 1M tokens
258
- input_price_per_token = 0.00000060 # $0.60 / 1M tokens
259
-
260
- # Compute the number of characters (double if the question involves piping)
261
- user_prompt_chars = len(str(user_prompt)) * get_piping_multiplier(
262
- str(user_prompt)
263
- )
264
- system_prompt_chars = len(str(system_prompt)) * get_piping_multiplier(
265
- str(system_prompt)
266
- )
267
-
268
- # Convert into tokens (1 token approx. equals 4 characters)
269
- input_tokens = (user_prompt_chars + system_prompt_chars) // 4
270
- output_tokens = input_tokens
271
-
272
- cost = (
273
- input_tokens * input_price_per_token
274
- + output_tokens * output_price_per_token
275
- )
276
-
277
- return {
278
- "input_tokens": input_tokens,
279
- "output_tokens": output_tokens,
280
- "cost": cost,
281
- }
282
-
283
- def estimate_job_cost_from_external_prices(self, price_lookup: dict) -> dict:
284
- """
285
- Estimates the cost of a job according to the following assumptions:
286
-
287
- - 1 token = 4 characters.
288
- - Input tokens = output tokens.
289
-
290
- price_lookup is an external pricing dictionary.
291
- """
292
-
293
- import pandas as pd
294
-
295
- interviews = self.interviews()
296
- data = []
297
- for interview in interviews:
298
- invigilators = [
299
- interview._get_invigilator(question)
300
- for question in self.survey.questions
301
- ]
302
- for invigilator in invigilators:
303
- prompts = invigilator.get_prompts()
304
-
305
- # By this point, agent and scenario data has already been added to the prompts
306
- user_prompt = prompts["user_prompt"]
307
- system_prompt = prompts["system_prompt"]
308
- inference_service = invigilator.model._inference_service_
309
- model = invigilator.model.model
310
-
311
- prompt_cost = self.estimate_prompt_cost(
312
- system_prompt=system_prompt,
313
- user_prompt=user_prompt,
314
- price_lookup=price_lookup,
315
- inference_service=inference_service,
316
- model=model,
317
- )
318
-
319
- data.append(
320
- {
321
- "user_prompt": user_prompt,
322
- "system_prompt": system_prompt,
323
- "estimated_input_tokens": prompt_cost["input_tokens"],
324
- "estimated_output_tokens": prompt_cost["output_tokens"],
325
- "estimated_cost": prompt_cost["cost"],
326
- "inference_service": inference_service,
327
- "model": model,
328
- }
329
- )
330
-
331
- df = pd.DataFrame.from_records(data)
332
-
333
- df = (
334
- df.groupby(["inference_service", "model"])
335
- .agg(
336
- {
337
- "estimated_cost": "sum",
338
- "estimated_input_tokens": "sum",
339
- "estimated_output_tokens": "sum",
340
- }
341
- )
342
- .reset_index()
343
- )
344
-
345
- estimated_costs_by_model = df.to_dict("records")
346
-
347
- estimated_total_cost = sum(
348
- model["estimated_cost"] for model in estimated_costs_by_model
349
- )
350
- estimated_total_input_tokens = sum(
351
- model["estimated_input_tokens"] for model in estimated_costs_by_model
352
- )
353
- estimated_total_output_tokens = sum(
354
- model["estimated_output_tokens"] for model in estimated_costs_by_model
355
- )
356
-
357
- output = {
358
- "estimated_total_cost": estimated_total_cost,
359
- "estimated_total_input_tokens": estimated_total_input_tokens,
360
- "estimated_total_output_tokens": estimated_total_output_tokens,
361
- "model_costs": estimated_costs_by_model,
362
- }
363
-
364
- return output
365
-
366
- def estimate_job_cost(self) -> dict:
367
- """
368
- Estimates the cost of a job according to the following assumptions:
369
-
370
- - 1 token = 4 characters.
371
- - Input tokens = output tokens.
372
-
373
- Fetches prices from Coop.
374
- """
375
- from edsl import Coop
376
-
377
- c = Coop()
378
- price_lookup = c.fetch_prices()
379
-
380
- return self.estimate_job_cost_from_external_prices(price_lookup=price_lookup)
381
-
382
- @staticmethod
383
- def compute_job_cost(job_results: "Results") -> float:
384
- """
385
- Computes the cost of a completed job in USD.
386
- """
387
- total_cost = 0
388
- for result in job_results:
389
- for key in result.raw_model_response:
390
- if key.endswith("_cost"):
391
- result_cost = result.raw_model_response[key]
392
-
393
- question_name = key.removesuffix("_cost")
394
- cache_used = result.cache_used_dict[question_name]
395
-
396
- if isinstance(result_cost, (int, float)) and not cache_used:
397
- total_cost += result_cost
398
-
399
- return total_cost
400
-
401
- @staticmethod
402
- def _get_container_class(object):
403
- from edsl.agents.AgentList import AgentList
404
- from edsl.agents.Agent import Agent
405
- from edsl.scenarios.Scenario import Scenario
406
- from edsl.scenarios.ScenarioList import ScenarioList
407
- from edsl.language_models.ModelList import ModelList
408
-
409
- if isinstance(object, Agent):
410
- return AgentList
411
- elif isinstance(object, Scenario):
412
- return ScenarioList
413
- elif isinstance(object, ModelList):
414
- return ModelList
415
- else:
416
- return list
417
-
418
- @staticmethod
419
- def _turn_args_to_list(args):
420
- """Return a list of the first argument if it is a sequence, otherwise returns a list of all the arguments.
421
-
422
- Example:
423
-
424
- >>> Jobs._turn_args_to_list([1,2,3])
425
- [1, 2, 3]
426
-
427
- """
428
-
429
- def did_user_pass_a_sequence(args):
430
- """Return True if the user passed a sequence, False otherwise.
431
-
432
- Example:
433
-
434
- >>> did_user_pass_a_sequence([1,2,3])
435
- True
436
-
437
- >>> did_user_pass_a_sequence(1)
438
- False
439
- """
440
- return len(args) == 1 and isinstance(args[0], Sequence)
441
-
442
- if did_user_pass_a_sequence(args):
443
- container_class = Jobs._get_container_class(args[0][0])
444
- return container_class(args[0])
445
- else:
446
- container_class = Jobs._get_container_class(args[0])
447
- return container_class(args)
448
-
449
- def _get_current_objects_of_this_type(
450
- self, object: Union["Agent", "Scenario", "LanguageModel"]
451
- ) -> tuple[list, str]:
452
- from edsl.agents.Agent import Agent
453
- from edsl.scenarios.Scenario import Scenario
454
- from edsl.language_models.LanguageModel import LanguageModel
455
-
456
- """Return the current objects of the same type as the first argument.
457
-
458
- >>> from edsl.jobs import Jobs
459
- >>> j = Jobs.example()
460
- >>> j._get_current_objects_of_this_type(j.agents[0])
461
- (AgentList([Agent(traits = {'status': 'Joyful'}), Agent(traits = {'status': 'Sad'})]), 'agents')
462
- """
463
- class_to_key = {
464
- Agent: "agents",
465
- Scenario: "scenarios",
466
- LanguageModel: "models",
467
- }
468
- for class_type in class_to_key:
469
- if isinstance(object, class_type) or issubclass(
470
- object.__class__, class_type
471
- ):
472
- key = class_to_key[class_type]
473
- break
474
- else:
475
- raise ValueError(
476
- f"First argument must be an Agent, Scenario, or LanguageModel, not {object}"
477
- )
478
- current_objects = getattr(self, key, None)
479
- return current_objects, key
480
-
481
- @staticmethod
482
- def _get_empty_container_object(object):
483
- from edsl import AgentList
484
- from edsl import Agent
485
- from edsl import Scenario
486
- from edsl import ScenarioList
487
-
488
- if isinstance(object, Agent):
489
- return AgentList([])
490
- elif isinstance(object, Scenario):
491
- return ScenarioList([])
492
- else:
493
- return []
494
-
495
- @staticmethod
496
- def _merge_objects(passed_objects, current_objects) -> list:
497
- """
498
- Combine all the existing objects with the new objects.
499
-
500
- For example, if the user passes in 3 agents,
501
- and there are 2 existing agents, this will create 6 new agents
502
-
503
- >>> Jobs(survey = [])._merge_objects([1,2,3], [4,5,6])
504
- [5, 6, 7, 6, 7, 8, 7, 8, 9]
505
- """
506
- new_objects = Jobs._get_empty_container_object(passed_objects[0])
507
- for current_object in current_objects:
508
- for new_object in passed_objects:
509
- new_objects.append(current_object + new_object)
510
- return new_objects
511
-
512
- def interviews(self) -> list[Interview]:
513
- """
514
- Return a list of :class:`edsl.jobs.interviews.Interview` objects.
515
-
516
- It returns one Interview for each combination of Agent, Scenario, and LanguageModel.
517
- If any of Agents, Scenarios, or LanguageModels are missing, it fills in with defaults.
518
-
519
- >>> from edsl.jobs import Jobs
520
- >>> j = Jobs.example()
521
- >>> len(j.interviews())
522
- 4
523
- >>> j.interviews()[0]
524
- Interview(agent = Agent(traits = {'status': 'Joyful'}), survey = Survey(...), scenario = Scenario({'period': 'morning'}), model = Model(...))
525
- """
526
- if hasattr(self, "_interviews"):
527
- return self._interviews
528
- else:
529
- return list(self._create_interviews())
530
-
531
- @classmethod
532
- def from_interviews(cls, interview_list):
533
- """Return a Jobs instance from a list of interviews.
534
-
535
- This is useful when you have, say, a list of failed interviews and you want to create
536
- a new job with only those interviews.
537
- """
538
- survey = interview_list[0].survey
539
- # get all the models
540
- models = list(set([interview.model for interview in interview_list]))
541
- jobs = cls(survey)
542
- jobs.models = models
543
- jobs._interviews = interview_list
544
- return jobs
545
-
546
- def _create_interviews(self) -> Generator[Interview, None, None]:
547
- """
548
- Generate interviews.
549
-
550
- Note that this sets the agents, model and scenarios if they have not been set. This is a side effect of the method.
551
- This is useful because a user can create a job without setting the agents, models, or scenarios, and the job will still run,
552
- with us filling in defaults.
553
-
554
-
555
- """
556
- # if no agents, models, or scenarios are set, set them to defaults
557
- from edsl.agents.Agent import Agent
558
- from edsl.language_models.registry import Model
559
- from edsl.scenarios.Scenario import Scenario
560
-
561
- self.agents = self.agents or [Agent()]
562
- self.models = self.models or [Model()]
563
- self.scenarios = self.scenarios or [Scenario()]
564
- for agent, scenario, model in product(self.agents, self.scenarios, self.models):
565
- yield Interview(
566
- survey=self.survey,
567
- agent=agent,
568
- scenario=scenario,
569
- model=model,
570
- skip_retry=self.skip_retry,
571
- raise_validation_errors=self.raise_validation_errors,
572
- )
573
-
574
- def create_bucket_collection(self) -> BucketCollection:
575
- """
576
- Create a collection of buckets for each model.
577
-
578
- These buckets are used to track API calls and token usage.
579
-
580
- >>> from edsl.jobs import Jobs
581
- >>> from edsl import Model
582
- >>> j = Jobs.example().by(Model(temperature = 1), Model(temperature = 0.5))
583
- >>> bc = j.create_bucket_collection()
584
- >>> bc
585
- BucketCollection(...)
586
- """
587
- bucket_collection = BucketCollection()
588
- for model in self.models:
589
- bucket_collection.add_model(model)
590
- return bucket_collection
591
-
592
- @property
593
- def bucket_collection(self) -> BucketCollection:
594
- """Return the bucket collection. If it does not exist, create it."""
595
- if self.__bucket_collection is None:
596
- self.__bucket_collection = self.create_bucket_collection()
597
- return self.__bucket_collection
598
-
599
- def html(self):
600
- """Return the HTML representations for each scenario"""
601
- links = []
602
- for index, scenario in enumerate(self.scenarios):
603
- links.append(
604
- self.survey.html(
605
- scenario=scenario, return_link=True, cta=f"Scenario {index}"
606
- )
607
- )
608
- return links
609
-
610
- def __hash__(self):
611
- """Allow the model to be used as a key in a dictionary.
612
-
613
- >>> from edsl.jobs import Jobs
614
- >>> hash(Jobs.example())
615
- 846655441787442972
616
-
617
- """
618
- from edsl.utilities.utilities import dict_hash
619
-
620
- return dict_hash(self._to_dict())
621
-
622
- def _output(self, message) -> None:
623
- """Check if a Job is verbose. If so, print the message."""
624
- if hasattr(self, "verbose") and self.verbose:
625
- print(message)
626
-
627
- def _check_parameters(self, strict=False, warn=False) -> None:
628
- """Check if the parameters in the survey and scenarios are consistent.
629
-
630
- >>> from edsl import QuestionFreeText
631
- >>> from edsl import Survey
632
- >>> from edsl import Scenario
633
- >>> q = QuestionFreeText(question_text = "{{poo}}", question_name = "ugly_question")
634
- >>> j = Jobs(survey = Survey(questions=[q]))
635
- >>> with warnings.catch_warnings(record=True) as w:
636
- ... j._check_parameters(warn = True)
637
- ... assert len(w) == 1
638
- ... assert issubclass(w[-1].category, UserWarning)
639
- ... assert "The following parameters are in the survey but not in the scenarios" in str(w[-1].message)
640
-
641
- >>> q = QuestionFreeText(question_text = "{{poo}}", question_name = "ugly_question")
642
- >>> s = Scenario({'plop': "A", 'poo': "B"})
643
- >>> j = Jobs(survey = Survey(questions=[q])).by(s)
644
- >>> j._check_parameters(strict = True)
645
- Traceback (most recent call last):
646
- ...
647
- ValueError: The following parameters are in the scenarios but not in the survey: {'plop'}
648
-
649
- >>> q = QuestionFreeText(question_text = "Hello", question_name = "ugly_question")
650
- >>> s = Scenario({'ugly_question': "B"})
651
- >>> j = Jobs(survey = Survey(questions=[q])).by(s)
652
- >>> j._check_parameters()
653
- Traceback (most recent call last):
654
- ...
655
- ValueError: The following names are in both the survey question_names and the scenario keys: {'ugly_question'}. This will create issues.
656
- """
657
- survey_parameters: set = self.survey.parameters
658
- scenario_parameters: set = self.scenarios.parameters
659
-
660
- msg0, msg1, msg2 = None, None, None
661
-
662
- # look for key issues
663
- if intersection := set(self.scenarios.parameters) & set(
664
- self.survey.question_names
665
- ):
666
- msg0 = f"The following names are in both the survey question_names and the scenario keys: {intersection}. This will create issues."
667
-
668
- raise ValueError(msg0)
669
-
670
- if in_survey_but_not_in_scenarios := survey_parameters - scenario_parameters:
671
- msg1 = f"The following parameters are in the survey but not in the scenarios: {in_survey_but_not_in_scenarios}"
672
- if in_scenarios_but_not_in_survey := scenario_parameters - survey_parameters:
673
- msg2 = f"The following parameters are in the scenarios but not in the survey: {in_scenarios_but_not_in_survey}"
674
-
675
- if msg1 or msg2:
676
- message = "\n".join(filter(None, [msg1, msg2]))
677
- if strict:
678
- raise ValueError(message)
679
- else:
680
- if warn:
681
- warnings.warn(message)
682
-
683
- if self.scenarios.has_jinja_braces:
684
- warnings.warn(
685
- "The scenarios have Jinja braces ({{ and }}). Converting to '<<' and '>>'. If you want a different conversion, use the convert_jinja_braces method first to modify the scenario."
686
- )
687
- self.scenarios = self.scenarios.convert_jinja_braces()
688
-
689
- @property
690
- def skip_retry(self):
691
- if not hasattr(self, "_skip_retry"):
692
- return False
693
- return self._skip_retry
694
-
695
- @property
696
- def raise_validation_errors(self):
697
- if not hasattr(self, "_raise_validation_errors"):
698
- return False
699
- return self._raise_validation_errors
700
-
701
- def create_remote_inference_job(
702
- self, iterations: int = 1, remote_inference_description: Optional[str] = None
703
- ):
704
- """ """
705
- from edsl.coop.coop import Coop
706
-
707
- coop = Coop()
708
- self._output("Remote inference activated. Sending job to server...")
709
- remote_job_creation_data = coop.remote_inference_create(
710
- self,
711
- description=remote_inference_description,
712
- status="queued",
713
- iterations=iterations,
714
- )
715
- job_uuid = remote_job_creation_data.get("uuid")
716
- print(f"Job sent to server. (Job uuid={job_uuid}).")
717
- return remote_job_creation_data
718
-
719
- @staticmethod
720
- def check_status(job_uuid):
721
- from edsl.coop.coop import Coop
722
-
723
- coop = Coop()
724
- return coop.remote_inference_get(job_uuid)
725
-
726
- def poll_remote_inference_job(
727
- self, remote_job_creation_data: dict
728
- ) -> Union[Results, None]:
729
- from edsl.coop.coop import Coop
730
- import time
731
- from datetime import datetime
732
- from edsl.config import CONFIG
733
-
734
- expected_parrot_url = CONFIG.get("EXPECTED_PARROT_URL")
735
-
736
- job_uuid = remote_job_creation_data.get("uuid")
737
-
738
- coop = Coop()
739
- job_in_queue = True
740
- while job_in_queue:
741
- remote_job_data = coop.remote_inference_get(job_uuid)
742
- status = remote_job_data.get("status")
743
- if status == "cancelled":
744
- print("\r" + " " * 80 + "\r", end="")
745
- print("Job cancelled by the user.")
746
- print(
747
- f"See {expected_parrot_url}/home/remote-inference for more details."
748
- )
749
- return None
750
- elif status == "failed":
751
- print("\r" + " " * 80 + "\r", end="")
752
- print("Job failed.")
753
- print(
754
- f"See {expected_parrot_url}/home/remote-inference for more details."
755
- )
756
- return None
757
- elif status == "completed":
758
- results_uuid = remote_job_data.get("results_uuid")
759
- results = coop.get(results_uuid, expected_object_type="results")
760
- print("\r" + " " * 80 + "\r", end="")
761
- url = f"{expected_parrot_url}/content/{results_uuid}"
762
- print(f"Job completed and Results stored on Coop: {url}.")
763
- return results
764
- else:
765
- duration = 5
766
- time_checked = datetime.now().strftime("%Y-%m-%d %I:%M:%S %p")
767
- frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
768
- start_time = time.time()
769
- i = 0
770
- while time.time() - start_time < duration:
771
- print(
772
- f"\r{frames[i % len(frames)]} Job status: {status} - last update: {time_checked}",
773
- end="",
774
- flush=True,
775
- )
776
- time.sleep(0.1)
777
- i += 1
778
-
779
- def use_remote_inference(self, disable_remote_inference: bool):
780
- if disable_remote_inference:
781
- return False
782
- if not disable_remote_inference:
783
- try:
784
- from edsl import Coop
785
-
786
- user_edsl_settings = Coop().edsl_settings
787
- return user_edsl_settings.get("remote_inference", False)
788
- except requests.ConnectionError:
789
- pass
790
- except CoopServerResponseError as e:
791
- pass
792
-
793
- return False
794
-
795
- def use_remote_cache(self):
796
- try:
797
- from edsl import Coop
798
-
799
- user_edsl_settings = Coop().edsl_settings
800
- return user_edsl_settings.get("remote_caching", False)
801
- except requests.ConnectionError:
802
- pass
803
- except CoopServerResponseError as e:
804
- pass
805
-
806
- return False
807
-
808
- def check_api_keys(self):
809
- from edsl import Model
810
-
811
- for model in self.models + [Model()]:
812
- if not model.has_valid_api_key():
813
- raise MissingAPIKeyError(
814
- model_name=str(model.model),
815
- inference_service=model._inference_service_,
816
- )
817
-
818
- def run(
819
- self,
820
- n: int = 1,
821
- progress_bar: bool = False,
822
- stop_on_exception: bool = False,
823
- cache: Union[Cache, bool] = None,
824
- check_api_keys: bool = False,
825
- sidecar_model: Optional[LanguageModel] = None,
826
- verbose: bool = False,
827
- print_exceptions=True,
828
- remote_cache_description: Optional[str] = None,
829
- remote_inference_description: Optional[str] = None,
830
- skip_retry: bool = False,
831
- raise_validation_errors: bool = False,
832
- disable_remote_inference: bool = False,
833
- ) -> Results:
834
- """
835
- Runs the Job: conducts Interviews and returns their results.
836
-
837
- :param n: how many times to run each interview
838
- :param progress_bar: shows a progress bar
839
- :param stop_on_exception: stops the job if an exception is raised
840
- :param cache: a cache object to store results
841
- :param check_api_keys: check if the API keys are valid
842
- :param batch_mode: run the job in batch mode i.e., no expecation of interaction with the user
843
- :param verbose: prints messages
844
- :param remote_cache_description: specifies a description for this group of entries in the remote cache
845
- :param remote_inference_description: specifies a description for the remote inference job
846
- """
847
- from edsl.coop.coop import Coop
848
-
849
- self._check_parameters()
850
- self._skip_retry = skip_retry
851
- self._raise_validation_errors = raise_validation_errors
852
-
853
- self.verbose = verbose
854
-
855
- if remote_inference := self.use_remote_inference(disable_remote_inference):
856
- remote_job_creation_data = self.create_remote_inference_job(
857
- iterations=n, remote_inference_description=remote_inference_description
858
- )
859
- results = self.poll_remote_inference_job(remote_job_creation_data)
860
- if results is None:
861
- self._output("Job failed.")
862
- return results
863
-
864
- if check_api_keys:
865
- self.check_api_keys()
866
-
867
- # handle cache
868
- if cache is None or cache is True:
869
- from edsl.data.CacheHandler import CacheHandler
870
-
871
- cache = CacheHandler().get_cache()
872
- if cache is False:
873
- from edsl.data.Cache import Cache
874
-
875
- cache = Cache()
876
-
877
- remote_cache = self.use_remote_cache()
878
- with RemoteCacheSync(
879
- coop=Coop(),
880
- cache=cache,
881
- output_func=self._output,
882
- remote_cache=remote_cache,
883
- remote_cache_description=remote_cache_description,
884
- ) as r:
885
- results = self._run_local(
886
- n=n,
887
- progress_bar=progress_bar,
888
- cache=cache,
889
- stop_on_exception=stop_on_exception,
890
- sidecar_model=sidecar_model,
891
- print_exceptions=print_exceptions,
892
- raise_validation_errors=raise_validation_errors,
893
- )
894
-
895
- results.cache = cache.new_entries_cache()
896
- return results
897
-
898
- def _run_local(self, *args, **kwargs):
899
- """Run the job locally."""
900
-
901
- results = JobsRunnerAsyncio(self).run(*args, **kwargs)
902
- return results
903
-
904
- async def run_async(self, cache=None, n=1, **kwargs):
905
- """Run asynchronously."""
906
- results = await JobsRunnerAsyncio(self).run_async(cache=cache, n=n, **kwargs)
907
- return results
908
-
909
- def all_question_parameters(self):
910
- """Return all the fields in the questions in the survey.
911
- >>> from edsl.jobs import Jobs
912
- >>> Jobs.example().all_question_parameters()
913
- {'period'}
914
- """
915
- return set.union(*[question.parameters for question in self.survey.questions])
916
-
917
- #######################
918
- # Dunder methods
919
- #######################
920
- def print(self):
921
- from rich import print_json
922
- import json
923
-
924
- print_json(json.dumps(self.to_dict()))
925
-
926
- def __repr__(self) -> str:
927
- """Return an eval-able string representation of the Jobs instance."""
928
- return f"Jobs(survey={repr(self.survey)}, agents={repr(self.agents)}, models={repr(self.models)}, scenarios={repr(self.scenarios)})"
929
-
930
- def _repr_html_(self) -> str:
931
- from rich import print_json
932
- import json
933
-
934
- print_json(json.dumps(self.to_dict()))
935
-
936
- def __len__(self) -> int:
937
- """Return the maximum number of questions that will be asked while running this job.
938
- Note that this is the maximum number of questions, not the actual number of questions that will be asked, as some questions may be skipped.
939
-
940
- >>> from edsl.jobs import Jobs
941
- >>> len(Jobs.example())
942
- 8
943
- """
944
- number_of_questions = (
945
- len(self.agents or [1])
946
- * len(self.scenarios or [1])
947
- * len(self.models or [1])
948
- * len(self.survey)
949
- )
950
- return number_of_questions
951
-
952
- #######################
953
- # Serialization methods
954
- #######################
955
-
956
- def _to_dict(self):
957
- return {
958
- "survey": self.survey._to_dict(),
959
- "agents": [agent._to_dict() for agent in self.agents],
960
- "models": [model._to_dict() for model in self.models],
961
- "scenarios": [scenario._to_dict() for scenario in self.scenarios],
962
- }
963
-
964
- @add_edsl_version
965
- def to_dict(self) -> dict:
966
- """Convert the Jobs instance to a dictionary."""
967
- return self._to_dict()
968
-
969
- @classmethod
970
- @remove_edsl_version
971
- def from_dict(cls, data: dict) -> Jobs:
972
- """Creates a Jobs instance from a dictionary."""
973
- from edsl import Survey
974
- from edsl.agents.Agent import Agent
975
- from edsl.language_models.LanguageModel import LanguageModel
976
- from edsl.scenarios.Scenario import Scenario
977
-
978
- return cls(
979
- survey=Survey.from_dict(data["survey"]),
980
- agents=[Agent.from_dict(agent) for agent in data["agents"]],
981
- models=[LanguageModel.from_dict(model) for model in data["models"]],
982
- scenarios=[Scenario.from_dict(scenario) for scenario in data["scenarios"]],
983
- )
984
-
985
- def __eq__(self, other: Jobs) -> bool:
986
- """Return True if the Jobs instance is equal to another Jobs instance.
987
-
988
- >>> from edsl.jobs import Jobs
989
- >>> Jobs.example() == Jobs.example()
990
- True
991
-
992
- """
993
- return self.to_dict() == other.to_dict()
994
-
995
- #######################
996
- # Example methods
997
- #######################
998
- @classmethod
999
- def example(
1000
- cls,
1001
- throw_exception_probability: float = 0.0,
1002
- randomize: bool = False,
1003
- test_model=False,
1004
- ) -> Jobs:
1005
- """Return an example Jobs instance.
1006
-
1007
- :param throw_exception_probability: the probability that an exception will be thrown when answering a question. This is useful for testing error handling.
1008
- :param randomize: whether to randomize the job by adding a random string to the period
1009
- :param test_model: whether to use a test model
1010
-
1011
- >>> Jobs.example()
1012
- Jobs(...)
1013
-
1014
- """
1015
- import random
1016
- from uuid import uuid4
1017
- from edsl.questions import QuestionMultipleChoice
1018
- from edsl.agents.Agent import Agent
1019
- from edsl.scenarios.Scenario import Scenario
1020
-
1021
- addition = "" if not randomize else str(uuid4())
1022
-
1023
- if test_model:
1024
- from edsl.language_models import LanguageModel
1025
-
1026
- m = LanguageModel.example(test_model=True)
1027
-
1028
- # (status, question, period)
1029
- agent_answers = {
1030
- ("Joyful", "how_feeling", "morning"): "OK",
1031
- ("Joyful", "how_feeling", "afternoon"): "Great",
1032
- ("Joyful", "how_feeling_yesterday", "morning"): "Great",
1033
- ("Joyful", "how_feeling_yesterday", "afternoon"): "Good",
1034
- ("Sad", "how_feeling", "morning"): "Terrible",
1035
- ("Sad", "how_feeling", "afternoon"): "OK",
1036
- ("Sad", "how_feeling_yesterday", "morning"): "OK",
1037
- ("Sad", "how_feeling_yesterday", "afternoon"): "Terrible",
1038
- }
1039
-
1040
- def answer_question_directly(self, question, scenario):
1041
- """Return the answer to a question. This is a method that can be added to an agent."""
1042
-
1043
- if random.random() < throw_exception_probability:
1044
- raise Exception("Error!")
1045
- return agent_answers[
1046
- (self.traits["status"], question.question_name, scenario["period"])
1047
- ]
1048
-
1049
- sad_agent = Agent(traits={"status": "Sad"})
1050
- joy_agent = Agent(traits={"status": "Joyful"})
1051
-
1052
- sad_agent.add_direct_question_answering_method(answer_question_directly)
1053
- joy_agent.add_direct_question_answering_method(answer_question_directly)
1054
-
1055
- q1 = QuestionMultipleChoice(
1056
- question_text="How are you this {{ period }}?",
1057
- question_options=["Good", "Great", "OK", "Terrible"],
1058
- question_name="how_feeling",
1059
- )
1060
- q2 = QuestionMultipleChoice(
1061
- question_text="How were you feeling yesterday {{ period }}?",
1062
- question_options=["Good", "Great", "OK", "Terrible"],
1063
- question_name="how_feeling_yesterday",
1064
- )
1065
- from edsl import Survey, ScenarioList
1066
-
1067
- base_survey = Survey(questions=[q1, q2])
1068
-
1069
- scenario_list = ScenarioList(
1070
- [
1071
- Scenario({"period": f"morning{addition}"}),
1072
- Scenario({"period": "afternoon"}),
1073
- ]
1074
- )
1075
- if test_model:
1076
- job = base_survey.by(m).by(scenario_list).by(joy_agent, sad_agent)
1077
- else:
1078
- job = base_survey.by(scenario_list).by(joy_agent, sad_agent)
1079
-
1080
- return job
1081
-
1082
- def rich_print(self):
1083
- """Print a rich representation of the Jobs instance."""
1084
- from rich.table import Table
1085
-
1086
- table = Table(title="Jobs")
1087
- table.add_column("Jobs")
1088
- table.add_row(self.survey.rich_print())
1089
- return table
1090
-
1091
- def code(self):
1092
- """Return the code to create this instance."""
1093
- raise NotImplementedError
1094
-
1095
-
1096
- def main():
1097
- """Run the module's doctests."""
1098
- from edsl.jobs import Jobs
1099
- from edsl.data.Cache import Cache
1100
-
1101
- job = Jobs.example()
1102
- len(job) == 8
1103
- results = job.run(cache=Cache())
1104
- len(results) == 8
1105
- results
1106
-
1107
-
1108
- if __name__ == "__main__":
1109
- """Run the module's doctests."""
1110
- import doctest
1111
-
1112
- doctest.testmod(optionflags=doctest.ELLIPSIS)
1
+ # """The Jobs class is a collection of agents, scenarios and models and one survey."""
2
+ from __future__ import annotations
3
+ import warnings
4
+ import requests
5
+ from itertools import product
6
+ from typing import Optional, Union, Sequence, Generator
7
+
8
+ from edsl.Base import Base
9
+ from edsl.exceptions import MissingAPIKeyError
10
+ from edsl.jobs.buckets.BucketCollection import BucketCollection
11
+ from edsl.jobs.interviews.Interview import Interview
12
+ from edsl.jobs.runners.JobsRunnerAsyncio import JobsRunnerAsyncio
13
+ from edsl.utilities.decorators import add_edsl_version, remove_edsl_version
14
+
15
+ from edsl.data.RemoteCacheSync import RemoteCacheSync
16
+ from edsl.exceptions.coop import CoopServerResponseError
17
+
18
+
19
+ class Jobs(Base):
20
+ """
21
+ A collection of agents, scenarios and models and one survey.
22
+ The actual running of a job is done by a `JobsRunner`, which is a subclass of `JobsRunner`.
23
+ The `JobsRunner` is chosen by the user, and is stored in the `jobs_runner_name` attribute.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ survey: "Survey",
29
+ agents: Optional[list["Agent"]] = None,
30
+ models: Optional[list["LanguageModel"]] = None,
31
+ scenarios: Optional[list["Scenario"]] = None,
32
+ ):
33
+ """Initialize a Jobs instance.
34
+
35
+ :param survey: the survey to be used in the job
36
+ :param agents: a list of agents
37
+ :param models: a list of models
38
+ :param scenarios: a list of scenarios
39
+ """
40
+ self.survey = survey
41
+ self.agents: "AgentList" = agents
42
+ self.scenarios: "ScenarioList" = scenarios
43
+ self.models = models
44
+
45
+ self.__bucket_collection = None
46
+
47
+ # these setters and getters are used to ensure that the agents, models, and scenarios are stored as AgentList, ModelList, and ScenarioList objects
48
+
49
+ @property
50
+ def models(self):
51
+ return self._models
52
+
53
+ @models.setter
54
+ def models(self, value):
55
+ from edsl import ModelList
56
+
57
+ if value:
58
+ if not isinstance(value, ModelList):
59
+ self._models = ModelList(value)
60
+ else:
61
+ self._models = value
62
+ else:
63
+ self._models = ModelList([])
64
+
65
+ @property
66
+ def agents(self):
67
+ return self._agents
68
+
69
+ @agents.setter
70
+ def agents(self, value):
71
+ from edsl import AgentList
72
+
73
+ if value:
74
+ if not isinstance(value, AgentList):
75
+ self._agents = AgentList(value)
76
+ else:
77
+ self._agents = value
78
+ else:
79
+ self._agents = AgentList([])
80
+
81
+ @property
82
+ def scenarios(self):
83
+ return self._scenarios
84
+
85
+ @scenarios.setter
86
+ def scenarios(self, value):
87
+ from edsl import ScenarioList
88
+
89
+ if value:
90
+ if not isinstance(value, ScenarioList):
91
+ self._scenarios = ScenarioList(value)
92
+ else:
93
+ self._scenarios = value
94
+ else:
95
+ self._scenarios = ScenarioList([])
96
+
97
+ def by(
98
+ self,
99
+ *args: Union[
100
+ "Agent",
101
+ "Scenario",
102
+ "LanguageModel",
103
+ Sequence[Union["Agent", "Scenario", "LanguageModel"]],
104
+ ],
105
+ ) -> Jobs:
106
+ """
107
+ Add Agents, Scenarios and LanguageModels to a job. If no objects of this type exist in the Jobs instance, it stores the new objects as a list in the corresponding attribute. Otherwise, it combines the new objects with existing objects using the object's `__add__` method.
108
+
109
+ This 'by' is intended to create a fluent interface.
110
+
111
+ >>> from edsl import Survey
112
+ >>> from edsl import QuestionFreeText
113
+ >>> q = QuestionFreeText(question_name="name", question_text="What is your name?")
114
+ >>> j = Jobs(survey = Survey(questions=[q]))
115
+ >>> j
116
+ Jobs(survey=Survey(...), agents=AgentList([]), models=ModelList([]), scenarios=ScenarioList([]))
117
+ >>> from edsl import Agent; a = Agent(traits = {"status": "Sad"})
118
+ >>> j.by(a).agents
119
+ AgentList([Agent(traits = {'status': 'Sad'})])
120
+
121
+ :param args: objects or a sequence (list, tuple, ...) of objects of the same type
122
+
123
+ Notes:
124
+ - all objects must implement the 'get_value', 'set_value', and `__add__` methods
125
+ - agents: traits of new agents are combined with traits of existing agents. New and existing agents should not have overlapping traits, and do not increase the # agents in the instance
126
+ - scenarios: traits of new scenarios are combined with traits of old existing. New scenarios will overwrite overlapping traits, and do not increase the number of scenarios in the instance
127
+ - models: new models overwrite old models.
128
+ """
129
+ passed_objects = self._turn_args_to_list(
130
+ args
131
+ ) # objects can also be passed comma-separated
132
+
133
+ current_objects, objects_key = self._get_current_objects_of_this_type(
134
+ passed_objects[0]
135
+ )
136
+
137
+ if not current_objects:
138
+ new_objects = passed_objects
139
+ else:
140
+ new_objects = self._merge_objects(passed_objects, current_objects)
141
+
142
+ setattr(self, objects_key, new_objects) # update the job
143
+ return self
144
+
145
+ def prompts(self) -> "Dataset":
146
+ """Return a Dataset of prompts that will be used.
147
+
148
+
149
+ >>> from edsl.jobs import Jobs
150
+ >>> Jobs.example().prompts()
151
+ Dataset(...)
152
+ """
153
+ from edsl import Coop
154
+
155
+ c = Coop()
156
+ price_lookup = c.fetch_prices()
157
+
158
+ interviews = self.interviews()
159
+ # data = []
160
+ interview_indices = []
161
+ question_names = []
162
+ user_prompts = []
163
+ system_prompts = []
164
+ scenario_indices = []
165
+ agent_indices = []
166
+ models = []
167
+ costs = []
168
+ from edsl.results.Dataset import Dataset
169
+
170
+ for interview_index, interview in enumerate(interviews):
171
+ invigilators = [
172
+ interview._get_invigilator(question)
173
+ for question in self.survey.questions
174
+ ]
175
+ for _, invigilator in enumerate(invigilators):
176
+ prompts = invigilator.get_prompts()
177
+ user_prompt = prompts["user_prompt"]
178
+ system_prompt = prompts["system_prompt"]
179
+ user_prompts.append(user_prompt)
180
+ system_prompts.append(system_prompt)
181
+ agent_index = self.agents.index(invigilator.agent)
182
+ agent_indices.append(agent_index)
183
+ interview_indices.append(interview_index)
184
+ scenario_index = self.scenarios.index(invigilator.scenario)
185
+ scenario_indices.append(scenario_index)
186
+ models.append(invigilator.model.model)
187
+ question_names.append(invigilator.question.question_name)
188
+
189
+ prompt_cost = self.estimate_prompt_cost(
190
+ system_prompt=system_prompt,
191
+ user_prompt=user_prompt,
192
+ price_lookup=price_lookup,
193
+ inference_service=invigilator.model._inference_service_,
194
+ model=invigilator.model.model,
195
+ )
196
+ costs.append(prompt_cost["cost"])
197
+
198
+ d = Dataset(
199
+ [
200
+ {"user_prompt": user_prompts},
201
+ {"system_prompt": system_prompts},
202
+ {"interview_index": interview_indices},
203
+ {"question_name": question_names},
204
+ {"scenario_index": scenario_indices},
205
+ {"agent_index": agent_indices},
206
+ {"model": models},
207
+ {"estimated_cost": costs},
208
+ ]
209
+ )
210
+ return d
211
+
212
+ def show_prompts(self, all=False) -> None:
213
+ """Print the prompts."""
214
+ if all:
215
+ self.prompts().to_scenario_list().print(format="rich")
216
+ else:
217
+ self.prompts().select(
218
+ "user_prompt", "system_prompt"
219
+ ).to_scenario_list().print(format="rich")
220
+
221
+ @staticmethod
222
+ def estimate_prompt_cost(
223
+ system_prompt: str,
224
+ user_prompt: str,
225
+ price_lookup: dict,
226
+ inference_service: str,
227
+ model: str,
228
+ ) -> dict:
229
+ """Estimates the cost of a prompt. Takes piping into account."""
230
+
231
+ def get_piping_multiplier(prompt: str):
232
+ """Returns 2 if a prompt includes Jinja braces, and 1 otherwise."""
233
+
234
+ if "{{" in prompt and "}}" in prompt:
235
+ return 2
236
+ return 1
237
+
238
+ # Look up prices per token
239
+ key = (inference_service, model)
240
+
241
+ try:
242
+ relevant_prices = price_lookup[key]
243
+ output_price_per_token = 1 / float(
244
+ relevant_prices["output"]["one_usd_buys"]
245
+ )
246
+ input_price_per_token = 1 / float(relevant_prices["input"]["one_usd_buys"])
247
+ except KeyError:
248
+ # A KeyError is likely to occur if we cannot retrieve prices (the price_lookup dict is empty)
249
+ # Use a sensible default
250
+
251
+ import warnings
252
+
253
+ warnings.warn(
254
+ "Price data could not be retrieved. Using default estimates for input and output token prices. Input: $0.15 / 1M tokens; Output: $0.60 / 1M tokens"
255
+ )
256
+
257
+ output_price_per_token = 0.00000015 # $0.15 / 1M tokens
258
+ input_price_per_token = 0.00000060 # $0.60 / 1M tokens
259
+
260
+ # Compute the number of characters (double if the question involves piping)
261
+ user_prompt_chars = len(str(user_prompt)) * get_piping_multiplier(
262
+ str(user_prompt)
263
+ )
264
+ system_prompt_chars = len(str(system_prompt)) * get_piping_multiplier(
265
+ str(system_prompt)
266
+ )
267
+
268
+ # Convert into tokens (1 token approx. equals 4 characters)
269
+ input_tokens = (user_prompt_chars + system_prompt_chars) // 4
270
+ output_tokens = input_tokens
271
+
272
+ cost = (
273
+ input_tokens * input_price_per_token
274
+ + output_tokens * output_price_per_token
275
+ )
276
+
277
+ return {
278
+ "input_tokens": input_tokens,
279
+ "output_tokens": output_tokens,
280
+ "cost": cost,
281
+ }
282
+
283
+ def estimate_job_cost_from_external_prices(self, price_lookup: dict) -> dict:
284
+ """
285
+ Estimates the cost of a job according to the following assumptions:
286
+
287
+ - 1 token = 4 characters.
288
+ - Input tokens = output tokens.
289
+
290
+ price_lookup is an external pricing dictionary.
291
+ """
292
+
293
+ import pandas as pd
294
+
295
+ interviews = self.interviews()
296
+ data = []
297
+ for interview in interviews:
298
+ invigilators = [
299
+ interview._get_invigilator(question)
300
+ for question in self.survey.questions
301
+ ]
302
+ for invigilator in invigilators:
303
+ prompts = invigilator.get_prompts()
304
+
305
+ # By this point, agent and scenario data has already been added to the prompts
306
+ user_prompt = prompts["user_prompt"]
307
+ system_prompt = prompts["system_prompt"]
308
+ inference_service = invigilator.model._inference_service_
309
+ model = invigilator.model.model
310
+
311
+ prompt_cost = self.estimate_prompt_cost(
312
+ system_prompt=system_prompt,
313
+ user_prompt=user_prompt,
314
+ price_lookup=price_lookup,
315
+ inference_service=inference_service,
316
+ model=model,
317
+ )
318
+
319
+ data.append(
320
+ {
321
+ "user_prompt": user_prompt,
322
+ "system_prompt": system_prompt,
323
+ "estimated_input_tokens": prompt_cost["input_tokens"],
324
+ "estimated_output_tokens": prompt_cost["output_tokens"],
325
+ "estimated_cost": prompt_cost["cost"],
326
+ "inference_service": inference_service,
327
+ "model": model,
328
+ }
329
+ )
330
+
331
+ df = pd.DataFrame.from_records(data)
332
+
333
+ df = (
334
+ df.groupby(["inference_service", "model"])
335
+ .agg(
336
+ {
337
+ "estimated_cost": "sum",
338
+ "estimated_input_tokens": "sum",
339
+ "estimated_output_tokens": "sum",
340
+ }
341
+ )
342
+ .reset_index()
343
+ )
344
+
345
+ estimated_costs_by_model = df.to_dict("records")
346
+
347
+ estimated_total_cost = sum(
348
+ model["estimated_cost"] for model in estimated_costs_by_model
349
+ )
350
+ estimated_total_input_tokens = sum(
351
+ model["estimated_input_tokens"] for model in estimated_costs_by_model
352
+ )
353
+ estimated_total_output_tokens = sum(
354
+ model["estimated_output_tokens"] for model in estimated_costs_by_model
355
+ )
356
+
357
+ output = {
358
+ "estimated_total_cost": estimated_total_cost,
359
+ "estimated_total_input_tokens": estimated_total_input_tokens,
360
+ "estimated_total_output_tokens": estimated_total_output_tokens,
361
+ "model_costs": estimated_costs_by_model,
362
+ }
363
+
364
+ return output
365
+
366
+ def estimate_job_cost(self) -> dict:
367
+ """
368
+ Estimates the cost of a job according to the following assumptions:
369
+
370
+ - 1 token = 4 characters.
371
+ - Input tokens = output tokens.
372
+
373
+ Fetches prices from Coop.
374
+ """
375
+ from edsl import Coop
376
+
377
+ c = Coop()
378
+ price_lookup = c.fetch_prices()
379
+
380
+ return self.estimate_job_cost_from_external_prices(price_lookup=price_lookup)
381
+
382
+ @staticmethod
383
+ def compute_job_cost(job_results: "Results") -> float:
384
+ """
385
+ Computes the cost of a completed job in USD.
386
+ """
387
+ total_cost = 0
388
+ for result in job_results:
389
+ for key in result.raw_model_response:
390
+ if key.endswith("_cost"):
391
+ result_cost = result.raw_model_response[key]
392
+
393
+ question_name = key.removesuffix("_cost")
394
+ cache_used = result.cache_used_dict[question_name]
395
+
396
+ if isinstance(result_cost, (int, float)) and not cache_used:
397
+ total_cost += result_cost
398
+
399
+ return total_cost
400
+
401
+ @staticmethod
402
+ def _get_container_class(object):
403
+ from edsl.agents.AgentList import AgentList
404
+ from edsl.agents.Agent import Agent
405
+ from edsl.scenarios.Scenario import Scenario
406
+ from edsl.scenarios.ScenarioList import ScenarioList
407
+ from edsl.language_models.ModelList import ModelList
408
+
409
+ if isinstance(object, Agent):
410
+ return AgentList
411
+ elif isinstance(object, Scenario):
412
+ return ScenarioList
413
+ elif isinstance(object, ModelList):
414
+ return ModelList
415
+ else:
416
+ return list
417
+
418
+ @staticmethod
419
+ def _turn_args_to_list(args):
420
+ """Return a list of the first argument if it is a sequence, otherwise returns a list of all the arguments.
421
+
422
+ Example:
423
+
424
+ >>> Jobs._turn_args_to_list([1,2,3])
425
+ [1, 2, 3]
426
+
427
+ """
428
+
429
+ def did_user_pass_a_sequence(args):
430
+ """Return True if the user passed a sequence, False otherwise.
431
+
432
+ Example:
433
+
434
+ >>> did_user_pass_a_sequence([1,2,3])
435
+ True
436
+
437
+ >>> did_user_pass_a_sequence(1)
438
+ False
439
+ """
440
+ return len(args) == 1 and isinstance(args[0], Sequence)
441
+
442
+ if did_user_pass_a_sequence(args):
443
+ container_class = Jobs._get_container_class(args[0][0])
444
+ return container_class(args[0])
445
+ else:
446
+ container_class = Jobs._get_container_class(args[0])
447
+ return container_class(args)
448
+
449
+ def _get_current_objects_of_this_type(
450
+ self, object: Union["Agent", "Scenario", "LanguageModel"]
451
+ ) -> tuple[list, str]:
452
+ from edsl.agents.Agent import Agent
453
+ from edsl.scenarios.Scenario import Scenario
454
+ from edsl.language_models.LanguageModel import LanguageModel
455
+
456
+ """Return the current objects of the same type as the first argument.
457
+
458
+ >>> from edsl.jobs import Jobs
459
+ >>> j = Jobs.example()
460
+ >>> j._get_current_objects_of_this_type(j.agents[0])
461
+ (AgentList([Agent(traits = {'status': 'Joyful'}), Agent(traits = {'status': 'Sad'})]), 'agents')
462
+ """
463
+ class_to_key = {
464
+ Agent: "agents",
465
+ Scenario: "scenarios",
466
+ LanguageModel: "models",
467
+ }
468
+ for class_type in class_to_key:
469
+ if isinstance(object, class_type) or issubclass(
470
+ object.__class__, class_type
471
+ ):
472
+ key = class_to_key[class_type]
473
+ break
474
+ else:
475
+ raise ValueError(
476
+ f"First argument must be an Agent, Scenario, or LanguageModel, not {object}"
477
+ )
478
+ current_objects = getattr(self, key, None)
479
+ return current_objects, key
480
+
481
+ @staticmethod
482
+ def _get_empty_container_object(object):
483
+ from edsl import AgentList
484
+ from edsl import Agent
485
+ from edsl import Scenario
486
+ from edsl import ScenarioList
487
+
488
+ if isinstance(object, Agent):
489
+ return AgentList([])
490
+ elif isinstance(object, Scenario):
491
+ return ScenarioList([])
492
+ else:
493
+ return []
494
+
495
+ @staticmethod
496
+ def _merge_objects(passed_objects, current_objects) -> list:
497
+ """
498
+ Combine all the existing objects with the new objects.
499
+
500
+ For example, if the user passes in 3 agents,
501
+ and there are 2 existing agents, this will create 6 new agents
502
+
503
+ >>> Jobs(survey = [])._merge_objects([1,2,3], [4,5,6])
504
+ [5, 6, 7, 6, 7, 8, 7, 8, 9]
505
+ """
506
+ new_objects = Jobs._get_empty_container_object(passed_objects[0])
507
+ for current_object in current_objects:
508
+ for new_object in passed_objects:
509
+ new_objects.append(current_object + new_object)
510
+ return new_objects
511
+
512
+ def interviews(self) -> list[Interview]:
513
+ """
514
+ Return a list of :class:`edsl.jobs.interviews.Interview` objects.
515
+
516
+ It returns one Interview for each combination of Agent, Scenario, and LanguageModel.
517
+ If any of Agents, Scenarios, or LanguageModels are missing, it fills in with defaults.
518
+
519
+ >>> from edsl.jobs import Jobs
520
+ >>> j = Jobs.example()
521
+ >>> len(j.interviews())
522
+ 4
523
+ >>> j.interviews()[0]
524
+ Interview(agent = Agent(traits = {'status': 'Joyful'}), survey = Survey(...), scenario = Scenario({'period': 'morning'}), model = Model(...))
525
+ """
526
+ if hasattr(self, "_interviews"):
527
+ return self._interviews
528
+ else:
529
+ return list(self._create_interviews())
530
+
531
+ @classmethod
532
+ def from_interviews(cls, interview_list):
533
+ """Return a Jobs instance from a list of interviews.
534
+
535
+ This is useful when you have, say, a list of failed interviews and you want to create
536
+ a new job with only those interviews.
537
+ """
538
+ survey = interview_list[0].survey
539
+ # get all the models
540
+ models = list(set([interview.model for interview in interview_list]))
541
+ jobs = cls(survey)
542
+ jobs.models = models
543
+ jobs._interviews = interview_list
544
+ return jobs
545
+
546
+ def _create_interviews(self) -> Generator[Interview, None, None]:
547
+ """
548
+ Generate interviews.
549
+
550
+ Note that this sets the agents, model and scenarios if they have not been set. This is a side effect of the method.
551
+ This is useful because a user can create a job without setting the agents, models, or scenarios, and the job will still run,
552
+ with us filling in defaults.
553
+
554
+
555
+ """
556
+ # if no agents, models, or scenarios are set, set them to defaults
557
+ from edsl.agents.Agent import Agent
558
+ from edsl.language_models.registry import Model
559
+ from edsl.scenarios.Scenario import Scenario
560
+
561
+ self.agents = self.agents or [Agent()]
562
+ self.models = self.models or [Model()]
563
+ self.scenarios = self.scenarios or [Scenario()]
564
+ for agent, scenario, model in product(self.agents, self.scenarios, self.models):
565
+ yield Interview(
566
+ survey=self.survey,
567
+ agent=agent,
568
+ scenario=scenario,
569
+ model=model,
570
+ skip_retry=self.skip_retry,
571
+ raise_validation_errors=self.raise_validation_errors,
572
+ )
573
+
574
+ def create_bucket_collection(self) -> BucketCollection:
575
+ """
576
+ Create a collection of buckets for each model.
577
+
578
+ These buckets are used to track API calls and token usage.
579
+
580
+ >>> from edsl.jobs import Jobs
581
+ >>> from edsl import Model
582
+ >>> j = Jobs.example().by(Model(temperature = 1), Model(temperature = 0.5))
583
+ >>> bc = j.create_bucket_collection()
584
+ >>> bc
585
+ BucketCollection(...)
586
+ """
587
+ bucket_collection = BucketCollection()
588
+ for model in self.models:
589
+ bucket_collection.add_model(model)
590
+ return bucket_collection
591
+
592
+ @property
593
+ def bucket_collection(self) -> BucketCollection:
594
+ """Return the bucket collection. If it does not exist, create it."""
595
+ if self.__bucket_collection is None:
596
+ self.__bucket_collection = self.create_bucket_collection()
597
+ return self.__bucket_collection
598
+
599
+ def html(self):
600
+ """Return the HTML representations for each scenario"""
601
+ links = []
602
+ for index, scenario in enumerate(self.scenarios):
603
+ links.append(
604
+ self.survey.html(
605
+ scenario=scenario, return_link=True, cta=f"Scenario {index}"
606
+ )
607
+ )
608
+ return links
609
+
610
+ def __hash__(self):
611
+ """Allow the model to be used as a key in a dictionary.
612
+
613
+ >>> from edsl.jobs import Jobs
614
+ >>> hash(Jobs.example())
615
+ 846655441787442972
616
+
617
+ """
618
+ from edsl.utilities.utilities import dict_hash
619
+
620
+ return dict_hash(self._to_dict())
621
+
622
+ def _output(self, message) -> None:
623
+ """Check if a Job is verbose. If so, print the message."""
624
+ if hasattr(self, "verbose") and self.verbose:
625
+ print(message)
626
+
627
+ def _check_parameters(self, strict=False, warn=False) -> None:
628
+ """Check if the parameters in the survey and scenarios are consistent.
629
+
630
+ >>> from edsl import QuestionFreeText
631
+ >>> from edsl import Survey
632
+ >>> from edsl import Scenario
633
+ >>> q = QuestionFreeText(question_text = "{{poo}}", question_name = "ugly_question")
634
+ >>> j = Jobs(survey = Survey(questions=[q]))
635
+ >>> with warnings.catch_warnings(record=True) as w:
636
+ ... j._check_parameters(warn = True)
637
+ ... assert len(w) == 1
638
+ ... assert issubclass(w[-1].category, UserWarning)
639
+ ... assert "The following parameters are in the survey but not in the scenarios" in str(w[-1].message)
640
+
641
+ >>> q = QuestionFreeText(question_text = "{{poo}}", question_name = "ugly_question")
642
+ >>> s = Scenario({'plop': "A", 'poo': "B"})
643
+ >>> j = Jobs(survey = Survey(questions=[q])).by(s)
644
+ >>> j._check_parameters(strict = True)
645
+ Traceback (most recent call last):
646
+ ...
647
+ ValueError: The following parameters are in the scenarios but not in the survey: {'plop'}
648
+
649
+ >>> q = QuestionFreeText(question_text = "Hello", question_name = "ugly_question")
650
+ >>> s = Scenario({'ugly_question': "B"})
651
+ >>> j = Jobs(survey = Survey(questions=[q])).by(s)
652
+ >>> j._check_parameters()
653
+ Traceback (most recent call last):
654
+ ...
655
+ ValueError: The following names are in both the survey question_names and the scenario keys: {'ugly_question'}. This will create issues.
656
+ """
657
+ survey_parameters: set = self.survey.parameters
658
+ scenario_parameters: set = self.scenarios.parameters
659
+
660
+ msg0, msg1, msg2 = None, None, None
661
+
662
+ # look for key issues
663
+ if intersection := set(self.scenarios.parameters) & set(
664
+ self.survey.question_names
665
+ ):
666
+ msg0 = f"The following names are in both the survey question_names and the scenario keys: {intersection}. This will create issues."
667
+
668
+ raise ValueError(msg0)
669
+
670
+ if in_survey_but_not_in_scenarios := survey_parameters - scenario_parameters:
671
+ msg1 = f"The following parameters are in the survey but not in the scenarios: {in_survey_but_not_in_scenarios}"
672
+ if in_scenarios_but_not_in_survey := scenario_parameters - survey_parameters:
673
+ msg2 = f"The following parameters are in the scenarios but not in the survey: {in_scenarios_but_not_in_survey}"
674
+
675
+ if msg1 or msg2:
676
+ message = "\n".join(filter(None, [msg1, msg2]))
677
+ if strict:
678
+ raise ValueError(message)
679
+ else:
680
+ if warn:
681
+ warnings.warn(message)
682
+
683
+ if self.scenarios.has_jinja_braces:
684
+ warnings.warn(
685
+ "The scenarios have Jinja braces ({{ and }}). Converting to '<<' and '>>'. If you want a different conversion, use the convert_jinja_braces method first to modify the scenario."
686
+ )
687
+ self.scenarios = self.scenarios.convert_jinja_braces()
688
+
689
+ @property
690
+ def skip_retry(self):
691
+ if not hasattr(self, "_skip_retry"):
692
+ return False
693
+ return self._skip_retry
694
+
695
+ @property
696
+ def raise_validation_errors(self):
697
+ if not hasattr(self, "_raise_validation_errors"):
698
+ return False
699
+ return self._raise_validation_errors
700
+
701
+ def create_remote_inference_job(
702
+ self, iterations: int = 1, remote_inference_description: Optional[str] = None
703
+ ):
704
+ """ """
705
+ from edsl.coop.coop import Coop
706
+
707
+ coop = Coop()
708
+ self._output("Remote inference activated. Sending job to server...")
709
+ remote_job_creation_data = coop.remote_inference_create(
710
+ self,
711
+ description=remote_inference_description,
712
+ status="queued",
713
+ iterations=iterations,
714
+ )
715
+ job_uuid = remote_job_creation_data.get("uuid")
716
+ print(f"Job sent to server. (Job uuid={job_uuid}).")
717
+ return remote_job_creation_data
718
+
719
+ @staticmethod
720
+ def check_status(job_uuid):
721
+ from edsl.coop.coop import Coop
722
+
723
+ coop = Coop()
724
+ return coop.remote_inference_get(job_uuid)
725
+
726
+ def poll_remote_inference_job(
727
+ self, remote_job_creation_data: dict
728
+ ) -> Union[Results, None]:
729
+ from edsl.coop.coop import Coop
730
+ import time
731
+ from datetime import datetime
732
+ from edsl.config import CONFIG
733
+
734
+ expected_parrot_url = CONFIG.get("EXPECTED_PARROT_URL")
735
+
736
+ job_uuid = remote_job_creation_data.get("uuid")
737
+
738
+ coop = Coop()
739
+ job_in_queue = True
740
+ while job_in_queue:
741
+ remote_job_data = coop.remote_inference_get(job_uuid)
742
+ status = remote_job_data.get("status")
743
+ if status == "cancelled":
744
+ print("\r" + " " * 80 + "\r", end="")
745
+ print("Job cancelled by the user.")
746
+ print(
747
+ f"See {expected_parrot_url}/home/remote-inference for more details."
748
+ )
749
+ return None
750
+ elif status == "failed":
751
+ print("\r" + " " * 80 + "\r", end="")
752
+ print("Job failed.")
753
+ print(
754
+ f"See {expected_parrot_url}/home/remote-inference for more details."
755
+ )
756
+ return None
757
+ elif status == "completed":
758
+ results_uuid = remote_job_data.get("results_uuid")
759
+ results = coop.get(results_uuid, expected_object_type="results")
760
+ print("\r" + " " * 80 + "\r", end="")
761
+ url = f"{expected_parrot_url}/content/{results_uuid}"
762
+ print(f"Job completed and Results stored on Coop: {url}.")
763
+ return results
764
+ else:
765
+ duration = 5
766
+ time_checked = datetime.now().strftime("%Y-%m-%d %I:%M:%S %p")
767
+ frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
768
+ start_time = time.time()
769
+ i = 0
770
+ while time.time() - start_time < duration:
771
+ print(
772
+ f"\r{frames[i % len(frames)]} Job status: {status} - last update: {time_checked}",
773
+ end="",
774
+ flush=True,
775
+ )
776
+ time.sleep(0.1)
777
+ i += 1
778
+
779
+ def use_remote_inference(self, disable_remote_inference: bool):
780
+ if disable_remote_inference:
781
+ return False
782
+ if not disable_remote_inference:
783
+ try:
784
+ from edsl import Coop
785
+
786
+ user_edsl_settings = Coop().edsl_settings
787
+ return user_edsl_settings.get("remote_inference", False)
788
+ except requests.ConnectionError:
789
+ pass
790
+ except CoopServerResponseError as e:
791
+ pass
792
+
793
+ return False
794
+
795
+ def use_remote_cache(self):
796
+ try:
797
+ from edsl import Coop
798
+
799
+ user_edsl_settings = Coop().edsl_settings
800
+ return user_edsl_settings.get("remote_caching", False)
801
+ except requests.ConnectionError:
802
+ pass
803
+ except CoopServerResponseError as e:
804
+ pass
805
+
806
+ return False
807
+
808
+ def check_api_keys(self):
809
+ from edsl import Model
810
+
811
+ for model in self.models + [Model()]:
812
+ if not model.has_valid_api_key():
813
+ raise MissingAPIKeyError(
814
+ model_name=str(model.model),
815
+ inference_service=model._inference_service_,
816
+ )
817
+
818
+ def run(
819
+ self,
820
+ n: int = 1,
821
+ progress_bar: bool = False,
822
+ stop_on_exception: bool = False,
823
+ cache: Union[Cache, bool] = None,
824
+ check_api_keys: bool = False,
825
+ sidecar_model: Optional[LanguageModel] = None,
826
+ verbose: bool = False,
827
+ print_exceptions=True,
828
+ remote_cache_description: Optional[str] = None,
829
+ remote_inference_description: Optional[str] = None,
830
+ skip_retry: bool = False,
831
+ raise_validation_errors: bool = False,
832
+ disable_remote_inference: bool = False,
833
+ ) -> Results:
834
+ """
835
+ Runs the Job: conducts Interviews and returns their results.
836
+
837
+ :param n: how many times to run each interview
838
+ :param progress_bar: shows a progress bar
839
+ :param stop_on_exception: stops the job if an exception is raised
840
+ :param cache: a cache object to store results
841
+ :param check_api_keys: check if the API keys are valid
842
+ :param batch_mode: run the job in batch mode i.e., no expecation of interaction with the user
843
+ :param verbose: prints messages
844
+ :param remote_cache_description: specifies a description for this group of entries in the remote cache
845
+ :param remote_inference_description: specifies a description for the remote inference job
846
+ """
847
+ from edsl.coop.coop import Coop
848
+
849
+ self._check_parameters()
850
+ self._skip_retry = skip_retry
851
+ self._raise_validation_errors = raise_validation_errors
852
+
853
+ self.verbose = verbose
854
+
855
+ if remote_inference := self.use_remote_inference(disable_remote_inference):
856
+ remote_job_creation_data = self.create_remote_inference_job(
857
+ iterations=n, remote_inference_description=remote_inference_description
858
+ )
859
+ results = self.poll_remote_inference_job(remote_job_creation_data)
860
+ if results is None:
861
+ self._output("Job failed.")
862
+ return results
863
+
864
+ if check_api_keys:
865
+ self.check_api_keys()
866
+
867
+ # handle cache
868
+ if cache is None or cache is True:
869
+ from edsl.data.CacheHandler import CacheHandler
870
+
871
+ cache = CacheHandler().get_cache()
872
+ if cache is False:
873
+ from edsl.data.Cache import Cache
874
+
875
+ cache = Cache()
876
+
877
+ remote_cache = self.use_remote_cache()
878
+ with RemoteCacheSync(
879
+ coop=Coop(),
880
+ cache=cache,
881
+ output_func=self._output,
882
+ remote_cache=remote_cache,
883
+ remote_cache_description=remote_cache_description,
884
+ ) as r:
885
+ results = self._run_local(
886
+ n=n,
887
+ progress_bar=progress_bar,
888
+ cache=cache,
889
+ stop_on_exception=stop_on_exception,
890
+ sidecar_model=sidecar_model,
891
+ print_exceptions=print_exceptions,
892
+ raise_validation_errors=raise_validation_errors,
893
+ )
894
+
895
+ results.cache = cache.new_entries_cache()
896
+ return results
897
+
898
+ def _run_local(self, *args, **kwargs):
899
+ """Run the job locally."""
900
+
901
+ results = JobsRunnerAsyncio(self).run(*args, **kwargs)
902
+ return results
903
+
904
+ async def run_async(self, cache=None, n=1, **kwargs):
905
+ """Run asynchronously."""
906
+ results = await JobsRunnerAsyncio(self).run_async(cache=cache, n=n, **kwargs)
907
+ return results
908
+
909
+ def all_question_parameters(self):
910
+ """Return all the fields in the questions in the survey.
911
+ >>> from edsl.jobs import Jobs
912
+ >>> Jobs.example().all_question_parameters()
913
+ {'period'}
914
+ """
915
+ return set.union(*[question.parameters for question in self.survey.questions])
916
+
917
+ #######################
918
+ # Dunder methods
919
+ #######################
920
+ def print(self):
921
+ from rich import print_json
922
+ import json
923
+
924
+ print_json(json.dumps(self.to_dict()))
925
+
926
+ def __repr__(self) -> str:
927
+ """Return an eval-able string representation of the Jobs instance."""
928
+ return f"Jobs(survey={repr(self.survey)}, agents={repr(self.agents)}, models={repr(self.models)}, scenarios={repr(self.scenarios)})"
929
+
930
+ def _repr_html_(self) -> str:
931
+ from rich import print_json
932
+ import json
933
+
934
+ print_json(json.dumps(self.to_dict()))
935
+
936
+ def __len__(self) -> int:
937
+ """Return the maximum number of questions that will be asked while running this job.
938
+ Note that this is the maximum number of questions, not the actual number of questions that will be asked, as some questions may be skipped.
939
+
940
+ >>> from edsl.jobs import Jobs
941
+ >>> len(Jobs.example())
942
+ 8
943
+ """
944
+ number_of_questions = (
945
+ len(self.agents or [1])
946
+ * len(self.scenarios or [1])
947
+ * len(self.models or [1])
948
+ * len(self.survey)
949
+ )
950
+ return number_of_questions
951
+
952
+ #######################
953
+ # Serialization methods
954
+ #######################
955
+
956
+ def _to_dict(self):
957
+ return {
958
+ "survey": self.survey._to_dict(),
959
+ "agents": [agent._to_dict() for agent in self.agents],
960
+ "models": [model._to_dict() for model in self.models],
961
+ "scenarios": [scenario._to_dict() for scenario in self.scenarios],
962
+ }
963
+
964
+ @add_edsl_version
965
+ def to_dict(self) -> dict:
966
+ """Convert the Jobs instance to a dictionary."""
967
+ return self._to_dict()
968
+
969
+ @classmethod
970
+ @remove_edsl_version
971
+ def from_dict(cls, data: dict) -> Jobs:
972
+ """Creates a Jobs instance from a dictionary."""
973
+ from edsl import Survey
974
+ from edsl.agents.Agent import Agent
975
+ from edsl.language_models.LanguageModel import LanguageModel
976
+ from edsl.scenarios.Scenario import Scenario
977
+
978
+ return cls(
979
+ survey=Survey.from_dict(data["survey"]),
980
+ agents=[Agent.from_dict(agent) for agent in data["agents"]],
981
+ models=[LanguageModel.from_dict(model) for model in data["models"]],
982
+ scenarios=[Scenario.from_dict(scenario) for scenario in data["scenarios"]],
983
+ )
984
+
985
+ def __eq__(self, other: Jobs) -> bool:
986
+ """Return True if the Jobs instance is equal to another Jobs instance.
987
+
988
+ >>> from edsl.jobs import Jobs
989
+ >>> Jobs.example() == Jobs.example()
990
+ True
991
+
992
+ """
993
+ return self.to_dict() == other.to_dict()
994
+
995
+ #######################
996
+ # Example methods
997
+ #######################
998
+ @classmethod
999
+ def example(
1000
+ cls,
1001
+ throw_exception_probability: float = 0.0,
1002
+ randomize: bool = False,
1003
+ test_model=False,
1004
+ ) -> Jobs:
1005
+ """Return an example Jobs instance.
1006
+
1007
+ :param throw_exception_probability: the probability that an exception will be thrown when answering a question. This is useful for testing error handling.
1008
+ :param randomize: whether to randomize the job by adding a random string to the period
1009
+ :param test_model: whether to use a test model
1010
+
1011
+ >>> Jobs.example()
1012
+ Jobs(...)
1013
+
1014
+ """
1015
+ import random
1016
+ from uuid import uuid4
1017
+ from edsl.questions import QuestionMultipleChoice
1018
+ from edsl.agents.Agent import Agent
1019
+ from edsl.scenarios.Scenario import Scenario
1020
+
1021
+ addition = "" if not randomize else str(uuid4())
1022
+
1023
+ if test_model:
1024
+ from edsl.language_models import LanguageModel
1025
+
1026
+ m = LanguageModel.example(test_model=True)
1027
+
1028
+ # (status, question, period)
1029
+ agent_answers = {
1030
+ ("Joyful", "how_feeling", "morning"): "OK",
1031
+ ("Joyful", "how_feeling", "afternoon"): "Great",
1032
+ ("Joyful", "how_feeling_yesterday", "morning"): "Great",
1033
+ ("Joyful", "how_feeling_yesterday", "afternoon"): "Good",
1034
+ ("Sad", "how_feeling", "morning"): "Terrible",
1035
+ ("Sad", "how_feeling", "afternoon"): "OK",
1036
+ ("Sad", "how_feeling_yesterday", "morning"): "OK",
1037
+ ("Sad", "how_feeling_yesterday", "afternoon"): "Terrible",
1038
+ }
1039
+
1040
+ def answer_question_directly(self, question, scenario):
1041
+ """Return the answer to a question. This is a method that can be added to an agent."""
1042
+
1043
+ if random.random() < throw_exception_probability:
1044
+ raise Exception("Error!")
1045
+ return agent_answers[
1046
+ (self.traits["status"], question.question_name, scenario["period"])
1047
+ ]
1048
+
1049
+ sad_agent = Agent(traits={"status": "Sad"})
1050
+ joy_agent = Agent(traits={"status": "Joyful"})
1051
+
1052
+ sad_agent.add_direct_question_answering_method(answer_question_directly)
1053
+ joy_agent.add_direct_question_answering_method(answer_question_directly)
1054
+
1055
+ q1 = QuestionMultipleChoice(
1056
+ question_text="How are you this {{ period }}?",
1057
+ question_options=["Good", "Great", "OK", "Terrible"],
1058
+ question_name="how_feeling",
1059
+ )
1060
+ q2 = QuestionMultipleChoice(
1061
+ question_text="How were you feeling yesterday {{ period }}?",
1062
+ question_options=["Good", "Great", "OK", "Terrible"],
1063
+ question_name="how_feeling_yesterday",
1064
+ )
1065
+ from edsl import Survey, ScenarioList
1066
+
1067
+ base_survey = Survey(questions=[q1, q2])
1068
+
1069
+ scenario_list = ScenarioList(
1070
+ [
1071
+ Scenario({"period": f"morning{addition}"}),
1072
+ Scenario({"period": "afternoon"}),
1073
+ ]
1074
+ )
1075
+ if test_model:
1076
+ job = base_survey.by(m).by(scenario_list).by(joy_agent, sad_agent)
1077
+ else:
1078
+ job = base_survey.by(scenario_list).by(joy_agent, sad_agent)
1079
+
1080
+ return job
1081
+
1082
+ def rich_print(self):
1083
+ """Print a rich representation of the Jobs instance."""
1084
+ from rich.table import Table
1085
+
1086
+ table = Table(title="Jobs")
1087
+ table.add_column("Jobs")
1088
+ table.add_row(self.survey.rich_print())
1089
+ return table
1090
+
1091
+ def code(self):
1092
+ """Return the code to create this instance."""
1093
+ raise NotImplementedError
1094
+
1095
+
1096
+ def main():
1097
+ """Run the module's doctests."""
1098
+ from edsl.jobs import Jobs
1099
+ from edsl.data.Cache import Cache
1100
+
1101
+ job = Jobs.example()
1102
+ len(job) == 8
1103
+ results = job.run(cache=Cache())
1104
+ len(results) == 8
1105
+ results
1106
+
1107
+
1108
+ if __name__ == "__main__":
1109
+ """Run the module's doctests."""
1110
+ import doctest
1111
+
1112
+ doctest.testmod(optionflags=doctest.ELLIPSIS)