edsl 0.1.54__py3-none-any.whl → 0.1.55__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 (101) hide show
  1. edsl/__init__.py +8 -1
  2. edsl/__init__original.py +134 -0
  3. edsl/__version__.py +1 -1
  4. edsl/agents/agent.py +29 -0
  5. edsl/agents/agent_list.py +36 -1
  6. edsl/base/base_class.py +281 -151
  7. edsl/buckets/__init__.py +8 -3
  8. edsl/buckets/bucket_collection.py +9 -3
  9. edsl/buckets/model_buckets.py +4 -2
  10. edsl/buckets/token_bucket.py +2 -2
  11. edsl/buckets/token_bucket_client.py +5 -3
  12. edsl/caching/cache.py +131 -62
  13. edsl/caching/cache_entry.py +70 -58
  14. edsl/caching/sql_dict.py +17 -0
  15. edsl/cli.py +99 -0
  16. edsl/config/config_class.py +16 -0
  17. edsl/conversation/__init__.py +31 -0
  18. edsl/coop/coop.py +276 -242
  19. edsl/coop/coop_jobs_objects.py +59 -0
  20. edsl/coop/coop_objects.py +29 -0
  21. edsl/coop/coop_regular_objects.py +26 -0
  22. edsl/coop/utils.py +24 -19
  23. edsl/dataset/dataset.py +338 -101
  24. edsl/db_list/sqlite_list.py +349 -0
  25. edsl/inference_services/__init__.py +40 -5
  26. edsl/inference_services/exceptions.py +11 -0
  27. edsl/inference_services/services/anthropic_service.py +5 -2
  28. edsl/inference_services/services/aws_bedrock.py +6 -2
  29. edsl/inference_services/services/azure_ai.py +6 -2
  30. edsl/inference_services/services/google_service.py +3 -2
  31. edsl/inference_services/services/mistral_ai_service.py +6 -2
  32. edsl/inference_services/services/open_ai_service.py +6 -2
  33. edsl/inference_services/services/perplexity_service.py +6 -2
  34. edsl/inference_services/services/test_service.py +94 -5
  35. edsl/interviews/answering_function.py +167 -59
  36. edsl/interviews/interview.py +124 -72
  37. edsl/interviews/interview_task_manager.py +10 -0
  38. edsl/invigilators/invigilators.py +9 -0
  39. edsl/jobs/async_interview_runner.py +146 -104
  40. edsl/jobs/data_structures.py +6 -4
  41. edsl/jobs/decorators.py +61 -0
  42. edsl/jobs/fetch_invigilator.py +61 -18
  43. edsl/jobs/html_table_job_logger.py +14 -2
  44. edsl/jobs/jobs.py +180 -104
  45. edsl/jobs/jobs_component_constructor.py +2 -2
  46. edsl/jobs/jobs_interview_constructor.py +2 -0
  47. edsl/jobs/jobs_remote_inference_logger.py +4 -0
  48. edsl/jobs/jobs_runner_status.py +30 -25
  49. edsl/jobs/progress_bar_manager.py +79 -0
  50. edsl/jobs/remote_inference.py +35 -1
  51. edsl/key_management/key_lookup_builder.py +6 -1
  52. edsl/language_models/language_model.py +86 -6
  53. edsl/language_models/model.py +10 -3
  54. edsl/language_models/price_manager.py +45 -75
  55. edsl/language_models/registry.py +5 -0
  56. edsl/notebooks/notebook.py +77 -10
  57. edsl/questions/VALIDATION_README.md +134 -0
  58. edsl/questions/__init__.py +24 -1
  59. edsl/questions/exceptions.py +21 -0
  60. edsl/questions/question_dict.py +201 -16
  61. edsl/questions/question_multiple_choice_with_other.py +624 -0
  62. edsl/questions/question_registry.py +2 -1
  63. edsl/questions/templates/multiple_choice_with_other/__init__.py +0 -0
  64. edsl/questions/templates/multiple_choice_with_other/answering_instructions.jinja +15 -0
  65. edsl/questions/templates/multiple_choice_with_other/question_presentation.jinja +17 -0
  66. edsl/questions/validation_analysis.py +185 -0
  67. edsl/questions/validation_cli.py +131 -0
  68. edsl/questions/validation_html_report.py +404 -0
  69. edsl/questions/validation_logger.py +136 -0
  70. edsl/results/result.py +63 -16
  71. edsl/results/results.py +702 -171
  72. edsl/scenarios/construct_download_link.py +16 -3
  73. edsl/scenarios/directory_scanner.py +226 -226
  74. edsl/scenarios/file_methods.py +5 -0
  75. edsl/scenarios/file_store.py +117 -6
  76. edsl/scenarios/handlers/__init__.py +5 -1
  77. edsl/scenarios/handlers/mp4_file_store.py +104 -0
  78. edsl/scenarios/handlers/webm_file_store.py +104 -0
  79. edsl/scenarios/scenario.py +120 -101
  80. edsl/scenarios/scenario_list.py +800 -727
  81. edsl/scenarios/scenario_list_gc_test.py +146 -0
  82. edsl/scenarios/scenario_list_memory_test.py +214 -0
  83. edsl/scenarios/scenario_list_source_refactor.md +35 -0
  84. edsl/scenarios/scenario_selector.py +5 -4
  85. edsl/scenarios/scenario_source.py +1990 -0
  86. edsl/scenarios/tests/test_scenario_list_sources.py +52 -0
  87. edsl/surveys/survey.py +22 -0
  88. edsl/tasks/__init__.py +4 -2
  89. edsl/tasks/task_history.py +198 -36
  90. edsl/tests/scenarios/test_ScenarioSource.py +51 -0
  91. edsl/tests/scenarios/test_scenario_list_sources.py +51 -0
  92. edsl/utilities/__init__.py +2 -1
  93. edsl/utilities/decorators.py +121 -0
  94. edsl/utilities/memory_debugger.py +1010 -0
  95. {edsl-0.1.54.dist-info → edsl-0.1.55.dist-info}/METADATA +51 -76
  96. {edsl-0.1.54.dist-info → edsl-0.1.55.dist-info}/RECORD +99 -75
  97. edsl/jobs/jobs_runner_asyncio.py +0 -281
  98. edsl/language_models/unused/fake_openai_service.py +0 -60
  99. {edsl-0.1.54.dist-info → edsl-0.1.55.dist-info}/LICENSE +0 -0
  100. {edsl-0.1.54.dist-info → edsl-0.1.55.dist-info}/WHEEL +0 -0
  101. {edsl-0.1.54.dist-info → edsl-0.1.55.dist-info}/entry_points.txt +0 -0
@@ -5,8 +5,9 @@ This module provides functionality to run multiple interviews in parallel
5
5
  with controlled concurrency, supporting both error handling and result collection.
6
6
  """
7
7
 
8
- from collections.abc import AsyncGenerator
9
- from typing import List, Generator, Tuple, TYPE_CHECKING
8
+ from collections.abc import AsyncGenerator, AsyncIterator
9
+ from contextlib import asynccontextmanager
10
+ from typing import List, Generator, Tuple, TYPE_CHECKING, AsyncIterator
10
11
  from dataclasses import dataclass
11
12
  import asyncio
12
13
  from ..data_transfer_models import EDSLResultObjectInput
@@ -22,19 +23,15 @@ if TYPE_CHECKING:
22
23
  from ..jobs import Jobs
23
24
 
24
25
  @dataclass
25
- class InterviewResult:
26
- """Container for the result of an interview along with metadata.
27
-
28
- Attributes:
29
- result: The Result object containing the interview answers
30
- interview: The Interview object used to conduct the interview
31
- order: The original position of this interview in the processing queue
32
- """
33
-
34
- result: Result
35
- interview: Interview
36
- order: int
26
+ class InterviewBatch:
27
+ """Container for a batch of interviews being processed."""
28
+ chunks: List[Tuple[int, Interview]]
29
+ results: List[Tuple[Result, Interview, int]]
30
+ failed: List[Tuple[int, Interview, Exception]]
37
31
 
32
+ @classmethod
33
+ def create(cls, chunks: List[Tuple[int, Interview]]) -> 'InterviewBatch':
34
+ return cls(chunks=chunks, results=[], failed=[])
38
35
 
39
36
  class AsyncInterviewRunner:
40
37
  """
@@ -68,6 +65,110 @@ class AsyncInterviewRunner:
68
65
  self.run_config = run_config
69
66
  self._initialized = asyncio.Event()
70
67
 
68
+ @asynccontextmanager
69
+ async def _manage_tasks(self, tasks: List[asyncio.Task]) -> AsyncIterator[None]:
70
+ """Context manager for handling task lifecycle and cleanup."""
71
+ try:
72
+ yield
73
+ finally:
74
+ for task in tasks:
75
+ if not task.done():
76
+ task.cancel()
77
+
78
+ @asynccontextmanager
79
+ async def _interview_batch_processor(self) -> AsyncIterator[AsyncGenerator[tuple[Result, Interview, int], None]]:
80
+ """Context manager for processing batches of interviews.
81
+
82
+ Handles initialization, cleanup, and error management for the entire
83
+ interview processing lifecycle.
84
+ """
85
+ self._initialized.set()
86
+ self._current_idx = 0
87
+ interview_generator = self._expand_interviews()
88
+
89
+ try:
90
+ async def process_batches() -> AsyncGenerator[tuple[Result, Interview, int], None]:
91
+ while True:
92
+ chunk = self._get_next_chunk(interview_generator)
93
+ if not chunk:
94
+ break
95
+
96
+ async with self._process_chunk(chunk) as results:
97
+ for result_tuple in results:
98
+ # Yield the full tuple (result, interview, idx)
99
+ yield result_tuple
100
+
101
+ # Clean up chunk to help with garbage collection
102
+ for idx, interview in chunk:
103
+ # Explicitly clear any interview references when done with the chunk
104
+ if hasattr(interview, 'clear_references'):
105
+ interview.clear_references()
106
+ del chunk
107
+
108
+ yield process_batches()
109
+
110
+ finally:
111
+ # Cleanup code to help garbage collection
112
+ self._current_idx = 0
113
+ self._initialized.clear()
114
+ # Clear the generator to avoid references
115
+ if 'interview_generator' in locals():
116
+ del interview_generator
117
+
118
+ async def _run_single_interview(
119
+ self, interview: Interview, idx: int
120
+ ) -> Tuple[Result, Interview, int]:
121
+ """Execute a single interview with error handling."""
122
+ try:
123
+ await interview.async_conduct_interview(self.run_config)
124
+ # Create result and explicitly break reference to interview
125
+ result = Result.from_interview(interview)
126
+ # Update the status
127
+ self.run_config.environment.jobs_runner_status.add_completed_interview(
128
+ interview
129
+ )
130
+ # Return tuple that keeps the interview reference
131
+ return (result, interview, idx)
132
+ except Exception as e:
133
+ if self.run_config.parameters.stop_on_exception:
134
+ raise
135
+ # Could log the error here if needed
136
+ return None
137
+
138
+ @asynccontextmanager
139
+ async def _process_chunk(
140
+ self, chunk: List[Tuple[int, Interview]]
141
+ ) -> AsyncIterator[List[Tuple[Result, Interview, int]]]:
142
+ """Process a chunk of interviews concurrently."""
143
+ tasks = [
144
+ asyncio.create_task(self._run_single_interview(interview, idx))
145
+ for idx, interview in chunk
146
+ ]
147
+
148
+ async with self._manage_tasks(tasks):
149
+ results = await asyncio.gather(
150
+ *tasks,
151
+ return_exceptions=not self.run_config.parameters.stop_on_exception
152
+ )
153
+ # Filter out None results and yield a new list to avoid keeping the original tuple references
154
+ valid_results = []
155
+ for r in results:
156
+ if r is not None:
157
+ result, interview, idx = r
158
+ # Create a new tuple to break reference to the original
159
+ new_tuple = (result, interview, idx)
160
+ valid_results.append(new_tuple)
161
+
162
+ # Clear original tuple to help GC
163
+ del r
164
+
165
+ yield valid_results
166
+
167
+ # Manually clean up the valid_results list and its contents to help garbage collection
168
+ for tup in valid_results:
169
+ del tup
170
+ del valid_results
171
+
71
172
  def _expand_interviews(self) -> Generator["Interview", None, None]:
72
173
  """
73
174
  Create multiple copies of each interview based on the run configuration.
@@ -101,104 +202,45 @@ class AsyncInterviewRunner:
101
202
  interview.cache = self.run_config.environment.cache
102
203
  yield interview
103
204
 
104
- async def _conduct_interview(
105
- self, interview: "Interview"
106
- ) -> Tuple["Result", "Interview"]:
107
- """
108
- Asynchronously conduct a single interview.
109
-
110
- This method performs the interview and creates a Result object with
111
- the extracted answers and model responses.
112
-
113
- Args:
114
- interview: The interview to conduct
115
-
116
- Returns:
117
- Tuple containing the Result object and the Interview object
118
-
119
- Notes:
120
- 'extracted_answers' contains the processed and validated answers
121
- from the interview, which may differ from the raw model output.
122
- """
123
- extracted_answers: dict[str, str]
124
- model_response_objects: List[EDSLResultObjectInput]
125
-
126
- extracted_answers, model_response_objects = (
127
- await interview.async_conduct_interview(self.run_config)
128
- )
129
- result = Result.from_interview(
130
- interview=interview,
131
- extracted_answers=extracted_answers,
132
- model_response_objects=model_response_objects,
133
- )
134
- return result, interview
135
-
136
- async def run(
205
+ def _get_next_chunk(
137
206
  self,
138
- ) -> AsyncGenerator[tuple[Result, Interview], None]:
207
+ gen: Generator[Interview, None, None]
208
+ ) -> List[Tuple[int, Interview]]:
209
+ """Take interviews from the generator up to MAX_CONCURRENT."""
210
+ chunk = []
211
+ while len(chunk) < self.MAX_CONCURRENT:
212
+ try:
213
+ interview = next(gen)
214
+ chunk.append((self._current_idx, interview))
215
+ self._current_idx += 1
216
+ except StopIteration:
217
+ break
218
+ return chunk
219
+
220
+ async def run(self) -> AsyncGenerator[tuple[Result, Interview, int], None]:
139
221
  """
140
222
  Run all interviews asynchronously and yield results as they complete.
141
223
 
142
- This method processes interviews in chunks based on MAX_CONCURRENT,
143
- maintaining controlled concurrency while yielding results as soon as
224
+ This method orchestrates the parallel execution of interviews while
225
+ maintaining controlled concurrency. Results are yielded as soon as
144
226
  they become available.
145
227
 
146
228
  Yields:
147
- Tuples of (Result, Interview) as interviews complete
148
-
149
- Notes:
150
- - Uses structured concurrency patterns for proper resource management
151
- - Handles exceptions according to the run configuration
152
- - Ensures task cleanup even in case of failures
229
+ Tuples of (Result, Interview, idx) as interviews complete, where idx is the
230
+ original position index of the interview.
231
+
232
+ Raises:
233
+ Exception: If stop_on_exception is True and any interview fails
153
234
  """
154
- interviews = list(self._expand_interviews())
155
- self._initialized.set()
156
-
157
- async def _process_single_interview(
158
- interview: Interview, idx: int
159
- ) -> InterviewResult:
160
- try:
161
- result, interview = await self._conduct_interview(interview)
162
- self.run_config.environment.jobs_runner_status.add_completed_interview(
163
- interview
164
- )
165
- result.order = idx
166
- return InterviewResult(result, interview, idx)
167
- except Exception:
168
- if self.run_config.parameters.stop_on_exception:
169
- raise
170
- return None
171
-
172
- # Process interviews in chunks
173
- for i in range(0, len(interviews), self.MAX_CONCURRENT):
174
- chunk = interviews[i : i + self.MAX_CONCURRENT]
175
- tasks = [
176
- asyncio.create_task(_process_single_interview(interview, idx))
177
- for idx, interview in enumerate(chunk, start=i)
178
- ]
179
-
180
- try:
181
- # Wait for all tasks in the chunk to complete
182
- results = await asyncio.gather(
183
- *tasks,
184
- return_exceptions=not self.run_config.parameters.stop_on_exception
185
- )
186
-
187
- # Process successful results
188
- for result in (r for r in results if r is not None):
189
- yield result.result, result.interview
190
-
191
- except Exception:
192
- if self.run_config.parameters.stop_on_exception:
193
- raise
194
- continue
195
-
196
- finally:
197
- # Clean up any remaining tasks
198
- for task in tasks:
199
- if not task.done():
200
- task.cancel()
201
-
235
+ async with self._interview_batch_processor() as processor:
236
+ async for result_tuple in processor:
237
+ # For each result tuple in the processor
238
+ result, interview, idx = result_tuple
239
+ # Yield a new tuple to break reference to the original tuple
240
+ yield result, interview, idx
241
+
242
+ # Help garbage collection by removing references
243
+ del result_tuple
202
244
 
203
245
  if __name__ == "__main__":
204
246
  import doctest
@@ -1,11 +1,11 @@
1
- from typing import Optional, Literal, TYPE_CHECKING
1
+ from typing import Optional, Literal, TYPE_CHECKING, Any
2
2
  from dataclasses import dataclass, asdict
3
3
  from collections import UserDict
4
4
  from ..data_transfer_models import EDSLResultObjectInput
5
5
 
6
6
  # from edsl.data_transfer_models import VisibilityType
7
7
  from ..caching import Cache
8
- from ..buckets import BucketCollection
8
+ # Import BucketCollection lazily to avoid circular imports
9
9
  from ..key_management import KeyLookup
10
10
  from ..base import Base
11
11
 
@@ -14,6 +14,7 @@ from .jobs_runner_status import JobsRunnerStatus
14
14
  if TYPE_CHECKING:
15
15
  from ..questions.question_base import QuestionBase
16
16
  from ..surveys import Survey
17
+ from ..buckets import BucketCollection
17
18
 
18
19
  VisibilityType = Literal["private", "public", "unlisted"]
19
20
 
@@ -33,7 +34,7 @@ class RunEnvironment:
33
34
  jobs_runner_status (JobsRunnerStatus, optional): Tracker for job execution progress
34
35
  """
35
36
  cache: Optional[Cache] = None
36
- bucket_collection: Optional[BucketCollection] = None
37
+ bucket_collection: Optional[Any] = None # Using Any to avoid circular import of BucketCollection
37
38
  key_lookup: Optional[KeyLookup] = None
38
39
  jobs_runner_status: Optional["JobsRunnerStatus"] = None
39
40
 
@@ -82,6 +83,7 @@ class RunParameters(Base):
82
83
  disable_remote_inference: bool = False
83
84
  job_uuid: Optional[str] = None
84
85
  fresh: bool = False # if True, will not use cache and will save new results to cache
86
+ memory_threshold: Optional[int] = None # Threshold in bytes for Results SQLList memory management
85
87
 
86
88
  def to_dict(self, add_edsl_version=False) -> dict:
87
89
  d = asdict(self)
@@ -131,7 +133,7 @@ class RunConfig:
131
133
  """
132
134
  self.environment = environment
133
135
 
134
- def add_bucket_collection(self, bucket_collection: BucketCollection) -> None:
136
+ def add_bucket_collection(self, bucket_collection: "BucketCollection") -> None:
135
137
  """
136
138
  Set or replace the bucket collection in the environment.
137
139
 
edsl/jobs/decorators.py CHANGED
@@ -1,6 +1,67 @@
1
1
  from functools import wraps
2
2
  from threading import RLock
3
3
  import inspect
4
+ from typing import Optional, Union, TypeVar, Callable, cast
5
+
6
+ try:
7
+ from typing import ParamSpec
8
+ except ImportError:
9
+ from typing_extensions import ParamSpec
10
+
11
+ from ..jobs.data_structures import RunEnvironment, RunParameters, RunConfig
12
+
13
+
14
+ P = ParamSpec("P")
15
+ T = TypeVar("T")
16
+
17
+
18
+ def with_config(f: Callable[P, T]) -> Callable[P, T]:
19
+ """
20
+ Decorator that processes function parameters to match the RunConfig dataclass structure.
21
+
22
+ This decorator is used primarily with the run() and run_async() methods to provide
23
+ a consistent interface for job configuration while maintaining a clean API.
24
+
25
+ The decorator:
26
+ 1. Extracts environment-related parameters into a RunEnvironment instance
27
+ 2. Extracts execution-related parameters into a RunParameters instance
28
+ 3. Combines both into a single RunConfig object
29
+ 4. Passes this RunConfig to the decorated function as a keyword argument
30
+
31
+ Parameters:
32
+ f (Callable): The function to decorate, typically run() or run_async()
33
+
34
+ Returns:
35
+ Callable: A wrapped function that accepts all RunConfig parameters directly
36
+
37
+ Example:
38
+ @with_config
39
+ def run(self, *, config: RunConfig) -> Results:
40
+ # Function can now access config.parameters and config.environment
41
+ """
42
+ parameter_fields = {
43
+ name: field.default
44
+ for name, field in RunParameters.__dataclass_fields__.items()
45
+ }
46
+ environment_fields = {
47
+ name: field.default
48
+ for name, field in RunEnvironment.__dataclass_fields__.items()
49
+ }
50
+ # Combined fields dict used for reference during development
51
+ # combined = {**parameter_fields, **environment_fields}
52
+
53
+ @wraps(f)
54
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
55
+ environment = RunEnvironment(
56
+ **{k: v for k, v in kwargs.items() if k in environment_fields}
57
+ )
58
+ parameters = RunParameters(
59
+ **{k: v for k, v in kwargs.items() if k in parameter_fields}
60
+ )
61
+ config = RunConfig(environment=environment, parameters=parameters)
62
+ return f(*args, config=config)
63
+
64
+ return cast(Callable[P, T], wrapper)
4
65
 
5
66
 
6
67
  def synchronized_class(wrapped_class):
@@ -1,10 +1,11 @@
1
1
  from typing import Dict, Any, Optional, TYPE_CHECKING
2
+ import weakref
2
3
 
3
4
  if TYPE_CHECKING:
4
5
  from ..questions import QuestionBase
5
6
  from ..agents import InvigilatorBase
6
- from ..language_models.key_management import KeyLookup
7
- from .interviews import Interview
7
+ from ..key_management import KeyLookup
8
+ from ..interviews import Interview
8
9
 
9
10
 
10
11
  class FetchInvigilator:
@@ -14,34 +15,76 @@ class FetchInvigilator:
14
15
  current_answers: Optional[Dict[str, Any]] = None,
15
16
  key_lookup: Optional["KeyLookup"] = None,
16
17
  ):
17
- self.interview = interview
18
- if current_answers is None:
19
- self.current_answers = self.interview.answers
20
- else:
21
- self.current_answers = current_answers
18
+ # Store a weak reference to the interview instead of a strong reference
19
+ self._interview_ref = weakref.ref(interview)
20
+
21
+ # Store external parameters that don't create reference cycles
22
+ self._current_answers = current_answers
22
23
  self.key_lookup = key_lookup
23
24
 
25
+ @property
26
+ def interview(self):
27
+ """Access the interview via weak reference if it still exists."""
28
+ interview = self._interview_ref()
29
+ if interview is None:
30
+ raise RuntimeError("Interview has been garbage collected")
31
+ return interview
32
+
33
+ @property
34
+ def _scenario(self):
35
+ return self.interview.scenario
36
+
37
+ @property
38
+ def _model(self):
39
+ return self.interview.model
40
+
41
+ @property
42
+ def _survey(self):
43
+ return self.interview.survey
44
+
45
+ @property
46
+ def _agent(self):
47
+ return self.interview.agent
48
+
49
+ @property
50
+ def _iteration(self):
51
+ return self.interview.iteration
52
+
53
+ @property
54
+ def _cache(self):
55
+ return self.interview.cache
56
+
57
+ @property
58
+ def _raise_validation_errors(self):
59
+ return self.interview.raise_validation_errors
60
+
61
+ @property
62
+ def current_answers(self):
63
+ if self._current_answers is not None:
64
+ return self._current_answers
65
+ return self.interview.answers
66
+
24
67
  def get_invigilator(self, question: "QuestionBase") -> "InvigilatorBase":
25
68
  """Return an invigilator for the given question.
26
69
 
27
70
  :param question: the question to be answered
28
71
  :param debug: whether to use debug mode, in which case `InvigilatorDebug` is used.
29
72
  """
30
-
31
- invigilator = self.interview.agent.create_invigilator(
73
+ # Use cached properties instead of accessing through the interview reference
74
+ invigilator = self._agent.create_invigilator(
32
75
  question=question,
33
- scenario=self.interview.scenario,
34
- model=self.interview.model,
35
- survey=self.interview.survey,
36
- memory_plan=self.interview.survey.memory_plan,
37
- current_answers=self.current_answers, # not yet known
38
- iteration=self.interview.iteration,
39
- cache=self.interview.cache,
40
- raise_validation_errors=self.interview.raise_validation_errors,
76
+ scenario=self._scenario,
77
+ model=self._model,
78
+ survey=self._survey,
79
+ memory_plan=self._survey.memory_plan,
80
+ current_answers=self.current_answers,
81
+ iteration=self._iteration,
82
+ cache=self._cache,
83
+ raise_validation_errors=self._raise_validation_errors,
41
84
  key_lookup=self.key_lookup,
42
85
  )
43
- """Return an invigilator for the given question."""
44
86
  return invigilator
45
87
 
46
88
  def __call__(self, question):
47
89
  return self.get_invigilator(question)
90
+
@@ -362,7 +362,11 @@ class HTMLTableJobLogger(JobLogger):
362
362
  other_fields = []
363
363
 
364
364
  for field, _ in self.jobs_info.__annotations__.items():
365
- if field != "pretty_names":
365
+ if field not in [
366
+ "pretty_names",
367
+ "completed_interviews",
368
+ "failed_interviews",
369
+ ]:
366
370
  value = getattr(self.jobs_info, field)
367
371
  if not value:
368
372
  continue
@@ -522,6 +526,14 @@ class HTMLTableJobLogger(JobLogger):
522
526
 
523
527
  display_style = "block" if self.is_expanded else "none"
524
528
 
529
+ header_status_text = status_text
530
+ if (
531
+ current_status == JobsStatus.PARTIALLY_FAILED
532
+ and self.jobs_info.completed_interviews is not None
533
+ and self.jobs_info.failed_interviews is not None
534
+ ):
535
+ header_status_text += f" ({self.jobs_info.completed_interviews:,} completed, {self.jobs_info.failed_interviews:,} failed)"
536
+
525
537
  return f"""
526
538
  {css}
527
539
  <div class="jobs-container">
@@ -539,7 +551,7 @@ class HTMLTableJobLogger(JobLogger):
539
551
  <span id="arrow-{self.log_id}" class="expand-toggle">{'&#8963;' if self.is_expanded else '&#8964;'}</span>
540
552
  Job Status 🦜
541
553
  </div>
542
- <div class="{status_class}">{status_text}</div>
554
+ <div class="{status_class}">{header_status_text}</div>
543
555
  </div>
544
556
  <div id="content-{self.log_id}" class="jobs-content" style="display: {display_style};">
545
557
  {content_html}