inspect-ai 0.3.81__py3-none-any.whl → 0.3.82__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 (179) hide show
  1. inspect_ai/_cli/eval.py +35 -2
  2. inspect_ai/_cli/util.py +44 -1
  3. inspect_ai/_display/core/config.py +1 -1
  4. inspect_ai/_display/core/display.py +13 -4
  5. inspect_ai/_display/core/results.py +1 -1
  6. inspect_ai/_display/textual/widgets/task_detail.py +5 -4
  7. inspect_ai/_eval/eval.py +38 -1
  8. inspect_ai/_eval/evalset.py +5 -0
  9. inspect_ai/_eval/run.py +5 -2
  10. inspect_ai/_eval/task/log.py +53 -6
  11. inspect_ai/_eval/task/run.py +51 -10
  12. inspect_ai/_util/constants.py +2 -0
  13. inspect_ai/_util/file.py +17 -1
  14. inspect_ai/_util/json.py +36 -1
  15. inspect_ai/_view/server.py +113 -1
  16. inspect_ai/_view/www/App.css +1 -1
  17. inspect_ai/_view/www/dist/assets/index.css +518 -296
  18. inspect_ai/_view/www/dist/assets/index.js +38803 -36307
  19. inspect_ai/_view/www/eslint.config.mjs +1 -1
  20. inspect_ai/_view/www/log-schema.json +13 -0
  21. inspect_ai/_view/www/node_modules/flatted/python/flatted.py +149 -0
  22. inspect_ai/_view/www/package.json +8 -2
  23. inspect_ai/_view/www/src/App.tsx +151 -855
  24. inspect_ai/_view/www/src/api/api-browser.ts +176 -5
  25. inspect_ai/_view/www/src/api/api-vscode.ts +75 -1
  26. inspect_ai/_view/www/src/api/client-api.ts +66 -10
  27. inspect_ai/_view/www/src/api/jsonrpc.ts +2 -0
  28. inspect_ai/_view/www/src/api/types.ts +107 -2
  29. inspect_ai/_view/www/src/appearance/icons.ts +1 -0
  30. inspect_ai/_view/www/src/components/AsciinemaPlayer.tsx +3 -3
  31. inspect_ai/_view/www/src/components/DownloadPanel.tsx +2 -2
  32. inspect_ai/_view/www/src/components/ExpandablePanel.tsx +56 -61
  33. inspect_ai/_view/www/src/components/FindBand.tsx +17 -9
  34. inspect_ai/_view/www/src/components/HumanBaselineView.tsx +1 -1
  35. inspect_ai/_view/www/src/components/JsonPanel.tsx +14 -24
  36. inspect_ai/_view/www/src/components/LargeModal.tsx +2 -35
  37. inspect_ai/_view/www/src/components/LightboxCarousel.tsx +27 -11
  38. inspect_ai/_view/www/src/components/LiveVirtualList.module.css +11 -0
  39. inspect_ai/_view/www/src/components/LiveVirtualList.tsx +177 -0
  40. inspect_ai/_view/www/src/components/MarkdownDiv.tsx +3 -3
  41. inspect_ai/_view/www/src/components/MessageBand.tsx +14 -9
  42. inspect_ai/_view/www/src/components/MorePopOver.tsx +3 -3
  43. inspect_ai/_view/www/src/components/NavPills.tsx +20 -8
  44. inspect_ai/_view/www/src/components/NoContentsPanel.module.css +12 -0
  45. inspect_ai/_view/www/src/components/NoContentsPanel.tsx +20 -0
  46. inspect_ai/_view/www/src/components/ProgressBar.module.css +5 -4
  47. inspect_ai/_view/www/src/components/ProgressBar.tsx +3 -2
  48. inspect_ai/_view/www/src/components/PulsingDots.module.css +81 -0
  49. inspect_ai/_view/www/src/components/PulsingDots.tsx +45 -0
  50. inspect_ai/_view/www/src/components/TabSet.tsx +4 -37
  51. inspect_ai/_view/www/src/components/ToolButton.tsx +3 -4
  52. inspect_ai/_view/www/src/index.tsx +26 -94
  53. inspect_ai/_view/www/src/logfile/remoteLogFile.ts +9 -1
  54. inspect_ai/_view/www/src/logfile/remoteZipFile.ts +30 -4
  55. inspect_ai/_view/www/src/metadata/RenderedContent.tsx +4 -6
  56. inspect_ai/_view/www/src/plan/ScorerDetailView.tsx +1 -1
  57. inspect_ai/_view/www/src/samples/InlineSampleDisplay.module.css +9 -1
  58. inspect_ai/_view/www/src/samples/InlineSampleDisplay.tsx +67 -28
  59. inspect_ai/_view/www/src/samples/SampleDialog.tsx +51 -22
  60. inspect_ai/_view/www/src/samples/SampleDisplay.module.css +4 -0
  61. inspect_ai/_view/www/src/samples/SampleDisplay.tsx +144 -90
  62. inspect_ai/_view/www/src/samples/SampleSummaryView.module.css +4 -0
  63. inspect_ai/_view/www/src/samples/SampleSummaryView.tsx +82 -35
  64. inspect_ai/_view/www/src/samples/SamplesTools.tsx +23 -30
  65. inspect_ai/_view/www/src/samples/chat/ChatMessage.tsx +2 -1
  66. inspect_ai/_view/www/src/samples/chat/ChatMessageRenderer.tsx +1 -1
  67. inspect_ai/_view/www/src/samples/chat/ChatViewVirtualList.tsx +45 -53
  68. inspect_ai/_view/www/src/samples/chat/MessageContent.tsx +4 -1
  69. inspect_ai/_view/www/src/samples/chat/MessageContents.tsx +3 -0
  70. inspect_ai/_view/www/src/samples/chat/messages.ts +34 -0
  71. inspect_ai/_view/www/src/samples/chat/tools/ToolCallView.module.css +3 -0
  72. inspect_ai/_view/www/src/samples/chat/tools/ToolCallView.tsx +10 -1
  73. inspect_ai/_view/www/src/samples/chat/tools/ToolInput.tsx +22 -46
  74. inspect_ai/_view/www/src/samples/descriptor/samplesDescriptor.tsx +25 -17
  75. inspect_ai/_view/www/src/samples/descriptor/score/ObjectScoreDescriptor.tsx +2 -1
  76. inspect_ai/_view/www/src/samples/descriptor/types.ts +6 -5
  77. inspect_ai/_view/www/src/samples/list/SampleFooter.module.css +21 -3
  78. inspect_ai/_view/www/src/samples/list/SampleFooter.tsx +20 -1
  79. inspect_ai/_view/www/src/samples/list/SampleList.tsx +105 -85
  80. inspect_ai/_view/www/src/samples/list/SampleRow.module.css +6 -0
  81. inspect_ai/_view/www/src/samples/list/SampleRow.tsx +27 -14
  82. inspect_ai/_view/www/src/samples/sample-tools/SelectScorer.tsx +29 -18
  83. inspect_ai/_view/www/src/samples/sample-tools/SortFilter.tsx +28 -28
  84. inspect_ai/_view/www/src/samples/sample-tools/sample-filter/SampleFilter.tsx +19 -9
  85. inspect_ai/_view/www/src/samples/sampleDataAdapter.ts +33 -0
  86. inspect_ai/_view/www/src/samples/sampleLimit.ts +2 -2
  87. inspect_ai/_view/www/src/samples/scores/SampleScoreView.tsx +7 -9
  88. inspect_ai/_view/www/src/samples/scores/SampleScores.tsx +7 -11
  89. inspect_ai/_view/www/src/samples/transcript/ErrorEventView.tsx +0 -13
  90. inspect_ai/_view/www/src/samples/transcript/InfoEventView.tsx +0 -13
  91. inspect_ai/_view/www/src/samples/transcript/InputEventView.tsx +0 -13
  92. inspect_ai/_view/www/src/samples/transcript/ModelEventView.module.css +4 -0
  93. inspect_ai/_view/www/src/samples/transcript/ModelEventView.tsx +10 -24
  94. inspect_ai/_view/www/src/samples/transcript/SampleInitEventView.tsx +0 -13
  95. inspect_ai/_view/www/src/samples/transcript/SampleLimitEventView.tsx +4 -22
  96. inspect_ai/_view/www/src/samples/transcript/SandboxEventView.tsx +15 -24
  97. inspect_ai/_view/www/src/samples/transcript/ScoreEventView.tsx +0 -13
  98. inspect_ai/_view/www/src/samples/transcript/StepEventView.tsx +6 -28
  99. inspect_ai/_view/www/src/samples/transcript/SubtaskEventView.tsx +24 -34
  100. inspect_ai/_view/www/src/samples/transcript/ToolEventView.module.css +4 -0
  101. inspect_ai/_view/www/src/samples/transcript/ToolEventView.tsx +8 -13
  102. inspect_ai/_view/www/src/samples/transcript/TranscriptView.tsx +197 -338
  103. inspect_ai/_view/www/src/samples/transcript/TranscriptVirtualListComponent.module.css +16 -0
  104. inspect_ai/_view/www/src/samples/transcript/TranscriptVirtualListComponent.tsx +44 -0
  105. inspect_ai/_view/www/src/samples/transcript/event/EventNav.tsx +7 -4
  106. inspect_ai/_view/www/src/samples/transcript/event/EventPanel.tsx +52 -58
  107. inspect_ai/_view/www/src/samples/transcript/event/EventProgressPanel.module.css +23 -0
  108. inspect_ai/_view/www/src/samples/transcript/event/EventProgressPanel.tsx +27 -0
  109. inspect_ai/_view/www/src/samples/transcript/state/StateEventRenderers.tsx +30 -1
  110. inspect_ai/_view/www/src/samples/transcript/state/StateEventView.tsx +102 -72
  111. inspect_ai/_view/www/src/scoring/utils.ts +87 -0
  112. inspect_ai/_view/www/src/state/appSlice.ts +244 -0
  113. inspect_ai/_view/www/src/state/hooks.ts +397 -0
  114. inspect_ai/_view/www/src/state/logPolling.ts +196 -0
  115. inspect_ai/_view/www/src/state/logSlice.ts +214 -0
  116. inspect_ai/_view/www/src/state/logsPolling.ts +118 -0
  117. inspect_ai/_view/www/src/state/logsSlice.ts +181 -0
  118. inspect_ai/_view/www/src/state/samplePolling.ts +311 -0
  119. inspect_ai/_view/www/src/state/sampleSlice.ts +127 -0
  120. inspect_ai/_view/www/src/state/sampleUtils.ts +21 -0
  121. inspect_ai/_view/www/src/state/scrolling.ts +206 -0
  122. inspect_ai/_view/www/src/state/store.ts +168 -0
  123. inspect_ai/_view/www/src/state/store_filter.ts +84 -0
  124. inspect_ai/_view/www/src/state/utils.ts +23 -0
  125. inspect_ai/_view/www/src/storage/index.ts +26 -0
  126. inspect_ai/_view/www/src/types/log.d.ts +2 -0
  127. inspect_ai/_view/www/src/types.ts +94 -32
  128. inspect_ai/_view/www/src/utils/attachments.ts +58 -23
  129. inspect_ai/_view/www/src/utils/logger.ts +52 -0
  130. inspect_ai/_view/www/src/utils/polling.ts +100 -0
  131. inspect_ai/_view/www/src/utils/react.ts +30 -0
  132. inspect_ai/_view/www/src/utils/vscode.ts +1 -1
  133. inspect_ai/_view/www/src/workspace/WorkSpace.tsx +181 -216
  134. inspect_ai/_view/www/src/workspace/WorkSpaceView.tsx +11 -53
  135. inspect_ai/_view/www/src/workspace/navbar/Navbar.tsx +8 -18
  136. inspect_ai/_view/www/src/workspace/navbar/PrimaryBar.module.css +1 -0
  137. inspect_ai/_view/www/src/workspace/navbar/PrimaryBar.tsx +40 -22
  138. inspect_ai/_view/www/src/workspace/navbar/ResultsPanel.module.css +0 -1
  139. inspect_ai/_view/www/src/workspace/navbar/ResultsPanel.tsx +98 -39
  140. inspect_ai/_view/www/src/workspace/navbar/RunningStatusPanel.module.css +32 -0
  141. inspect_ai/_view/www/src/workspace/navbar/RunningStatusPanel.tsx +32 -0
  142. inspect_ai/_view/www/src/workspace/navbar/SecondaryBar.tsx +11 -13
  143. inspect_ai/_view/www/src/workspace/navbar/StatusPanel.tsx +6 -2
  144. inspect_ai/_view/www/src/workspace/sidebar/LogDirectoryTitleView.tsx +4 -4
  145. inspect_ai/_view/www/src/workspace/sidebar/Sidebar.tsx +28 -13
  146. inspect_ai/_view/www/src/workspace/tabs/InfoTab.tsx +5 -10
  147. inspect_ai/_view/www/src/workspace/tabs/JsonTab.tsx +4 -4
  148. inspect_ai/_view/www/src/workspace/tabs/RunningNoSamples.module.css +22 -0
  149. inspect_ai/_view/www/src/workspace/tabs/RunningNoSamples.tsx +19 -0
  150. inspect_ai/_view/www/src/workspace/tabs/SamplesTab.tsx +110 -115
  151. inspect_ai/_view/www/src/workspace/tabs/grouping.ts +37 -5
  152. inspect_ai/_view/www/src/workspace/tabs/types.ts +4 -0
  153. inspect_ai/_view/www/src/workspace/types.ts +4 -3
  154. inspect_ai/_view/www/src/workspace/utils.ts +4 -4
  155. inspect_ai/_view/www/vite.config.js +6 -0
  156. inspect_ai/_view/www/yarn.lock +370 -354
  157. inspect_ai/log/_condense.py +26 -0
  158. inspect_ai/log/_log.py +6 -3
  159. inspect_ai/log/_recorders/buffer/__init__.py +14 -0
  160. inspect_ai/log/_recorders/buffer/buffer.py +30 -0
  161. inspect_ai/log/_recorders/buffer/database.py +685 -0
  162. inspect_ai/log/_recorders/buffer/filestore.py +259 -0
  163. inspect_ai/log/_recorders/buffer/types.py +84 -0
  164. inspect_ai/log/_recorders/eval.py +2 -11
  165. inspect_ai/log/_recorders/types.py +30 -0
  166. inspect_ai/log/_transcript.py +27 -1
  167. inspect_ai/model/_call_tools.py +1 -0
  168. inspect_ai/model/_generate_config.py +2 -2
  169. inspect_ai/model/_model.py +1 -0
  170. inspect_ai/tool/_tool_support_helpers.py +4 -4
  171. inspect_ai/tool/_tools/_web_browser/_web_browser.py +3 -1
  172. inspect_ai/util/_subtask.py +1 -0
  173. {inspect_ai-0.3.81.dist-info → inspect_ai-0.3.82.dist-info}/METADATA +1 -1
  174. {inspect_ai-0.3.81.dist-info → inspect_ai-0.3.82.dist-info}/RECORD +178 -138
  175. inspect_ai/_view/www/src/samples/transcript/SampleTranscript.tsx +0 -22
  176. {inspect_ai-0.3.81.dist-info → inspect_ai-0.3.82.dist-info}/WHEEL +0 -0
  177. {inspect_ai-0.3.81.dist-info → inspect_ai-0.3.82.dist-info}/entry_points.txt +0 -0
  178. {inspect_ai-0.3.81.dist-info → inspect_ai-0.3.82.dist-info}/licenses/LICENSE +0 -0
  179. {inspect_ai-0.3.81.dist-info → inspect_ai-0.3.82.dist-info}/top_level.txt +0 -0
@@ -19,7 +19,7 @@ from inspect_ai._display import (
19
19
  TaskSuccess,
20
20
  display,
21
21
  )
22
- from inspect_ai._display.core.display import TaskDisplay, TaskDisplayMetric
22
+ from inspect_ai._display.core.display import TaskDisplayMetric
23
23
  from inspect_ai._util._async import tg_collect
24
24
  from inspect_ai._util.constants import (
25
25
  DEFAULT_EPOCHS,
@@ -29,6 +29,7 @@ from inspect_ai._util.constants import (
29
29
  from inspect_ai._util.datetime import iso_now
30
30
  from inspect_ai._util.error import exception_message
31
31
  from inspect_ai._util.hooks import send_telemetry
32
+ from inspect_ai._util.json import to_json_str_safe
32
33
  from inspect_ai._util.registry import (
33
34
  is_registry_object,
34
35
  registry_log_name,
@@ -51,13 +52,17 @@ from inspect_ai.log import (
51
52
  from inspect_ai.log._condense import condense_sample
52
53
  from inspect_ai.log._file import eval_log_json_str
53
54
  from inspect_ai.log._log import EvalSampleLimit, EvalSampleReductions, eval_error
54
- from inspect_ai.log._samples import active_sample
55
+ from inspect_ai.log._recorders.types import SampleSummary
56
+ from inspect_ai.log._samples import (
57
+ active_sample,
58
+ )
55
59
  from inspect_ai.log._transcript import (
56
60
  ErrorEvent,
57
61
  SampleInitEvent,
58
62
  SampleLimitEvent,
59
63
  ScoreEvent,
60
64
  StepEvent,
65
+ Transcript,
61
66
  transcript,
62
67
  )
63
68
  from inspect_ai.model import (
@@ -264,8 +269,13 @@ async def task_run(options: TaskRunOptions) -> EvalLog:
264
269
 
265
270
  # track when samples complete and update progress as we go
266
271
  progress_results: list[dict[str, SampleScore]] = []
272
+
273
+ def update_metrics(metrics: list[TaskDisplayMetric]) -> None:
274
+ td.update_metrics(metrics)
275
+ logger.update_metrics(metrics)
276
+
267
277
  update_metrics_display = update_metrics_display_fn(
268
- td,
278
+ update_metrics,
269
279
  display_metrics=profile.eval_config.score_display is not False,
270
280
  )
271
281
 
@@ -423,7 +433,7 @@ async def task_run(options: TaskRunOptions) -> EvalLog:
423
433
 
424
434
 
425
435
  def update_metrics_display_fn(
426
- td: TaskDisplay,
436
+ update_fn: Callable[[list[TaskDisplayMetric]], None],
427
437
  initial_interval: float = 0,
428
438
  min_interval: float = 0.9,
429
439
  display_metrics: bool = True,
@@ -463,7 +473,7 @@ def update_metrics_display_fn(
463
473
  )
464
474
 
465
475
  # Name, reducer, value
466
- task_metrics = []
476
+ task_metrics: list[TaskDisplayMetric] = []
467
477
  if len(results.scores) > 0:
468
478
  for score in results.scores:
469
479
  for key, metric in score.metrics.items():
@@ -475,7 +485,7 @@ def update_metrics_display_fn(
475
485
  reducer=score.reducer,
476
486
  )
477
487
  )
478
- td.update_metrics(task_metrics)
488
+ update_fn(task_metrics)
479
489
 
480
490
  # determine how long to wait before recomputing metrics
481
491
  time_end = time.perf_counter()
@@ -516,7 +526,7 @@ async def task_run_sample(
516
526
 
517
527
  # log if requested
518
528
  if logger:
519
- await logger.log_sample(previous_sample, flush=False)
529
+ await logger.complete_sample(previous_sample, flush=False)
520
530
 
521
531
  # return score
522
532
  sample_scores = (
@@ -539,10 +549,19 @@ async def task_run_sample(
539
549
  semaphore if semaphore else contextlib.nullcontext()
540
550
  )
541
551
 
552
+ # validate that we have sample_id (mostly for the typechecker)
553
+ sample_id = sample.id
554
+ if sample_id is None:
555
+ raise ValueError("sample must have id to run")
556
+
542
557
  # initialise subtask and scoring context
543
558
  init_sample_model_usage()
544
559
  set_sample_state(state)
545
- sample_transcript = init_subtask(SAMPLE_SUBTASK, state.store)
560
+ sample_transcript: Transcript = init_subtask(SAMPLE_SUBTASK, state.store)
561
+ if logger:
562
+ sample_transcript._subscribe(
563
+ lambda event: logger.log_sample_event(sample_id, state.epoch, event)
564
+ )
546
565
  if scorers:
547
566
  init_scoring_context(scorers, Target(sample.target))
548
567
 
@@ -626,6 +645,28 @@ async def task_run_sample(
626
645
  # mark started
627
646
  active.started = datetime.now().timestamp()
628
647
 
648
+ if logger is not None:
649
+ await logger.start_sample(
650
+ SampleSummary(
651
+ id=sample_id,
652
+ epoch=state.epoch,
653
+ input=sample.input,
654
+ target=sample.target,
655
+ )
656
+ )
657
+
658
+ # sample init event (remove file bodies as they have content or absolute paths)
659
+ event_sample = sample.model_copy(
660
+ update=dict(files={k: "" for k in sample.files.keys()})
661
+ if sample.files
662
+ else None
663
+ )
664
+ transcript()._event(
665
+ SampleInitEvent(
666
+ sample=event_sample, state=state_jsonable(state)
667
+ )
668
+ )
669
+
629
670
  # set progress for plan then run it
630
671
  state = await plan(state, generate)
631
672
 
@@ -824,7 +865,7 @@ async def log_sample(
824
865
  id = sample.id
825
866
  if id is None:
826
867
  raise ValueError(
827
- f"Samples without IDs cannot be logged: {sample.model_dump_json()}"
868
+ f"Samples without IDs cannot be logged: {to_json_str_safe(sample)}"
828
869
  )
829
870
 
830
871
  # construct sample for logging
@@ -866,7 +907,7 @@ async def log_sample(
866
907
  limit=limit,
867
908
  )
868
909
 
869
- await logger.log_sample(condense_sample(eval_sample, log_images), flush=True)
910
+ await logger.complete_sample(condense_sample(eval_sample, log_images), flush=True)
870
911
 
871
912
 
872
913
  async def resolve_dataset(
@@ -25,8 +25,10 @@ ALL_LOG_LEVELS = [
25
25
  ]
26
26
  DEFAULT_LOG_LEVEL = "warning"
27
27
  DEFAULT_LOG_LEVEL_TRANSCRIPT = "info"
28
+ DEFAULT_LOG_SHARED = 10
28
29
  ALL_LOG_FORMATS = ["eval", "json"]
29
30
  DEFAULT_LOG_FORMAT: Literal["eval", "json"] = "eval"
31
+ JSON_LOG_FORMAT = "json"
30
32
  EVAL_LOG_FORMAT = "eval"
31
33
  DEFAULT_DISPLAY = "full"
32
34
  LOG_SCHEMA_VERSION = 2
inspect_ai/_util/file.py CHANGED
@@ -13,7 +13,7 @@ from urllib.parse import urlparse
13
13
  import fsspec # type: ignore # type: ignore
14
14
  from fsspec.core import split_protocol # type: ignore # type: ignore
15
15
  from fsspec.implementations.local import make_path_posix # type: ignore
16
- from pydantic import BaseModel
16
+ from pydantic import BaseModel, Field
17
17
  from s3fs import S3FileSystem # type: ignore
18
18
  from shortuuid import uuid
19
19
 
@@ -158,6 +158,9 @@ class FileInfo(BaseModel):
158
158
  mtime: float | None
159
159
  """File modification time (None if the file is a directory on S3)."""
160
160
 
161
+ etag: str | None = Field(default=None)
162
+ """Etag (provided by some remote filesystems)"""
163
+
161
164
 
162
165
  class FileSystem:
163
166
  def __init__(self, fs: Any) -> None:
@@ -178,6 +181,9 @@ class FileSystem:
178
181
  ) -> None:
179
182
  self.fs.rm(path, recursive=recursive, maxdepth=maxdepth)
180
183
 
184
+ def mv(self, lpath: str, rpath: str) -> None:
185
+ self.fs.mv(lpath, rpath)
186
+
181
187
  def mkdir(self, path: str, exist_ok: bool = False) -> None:
182
188
  if self.is_s3():
183
189
  # try to avoid calling create_bucket on s3 filesystems (as that requires distinct
@@ -199,6 +205,9 @@ class FileSystem:
199
205
  def info(self, path: str, **kwargs: dict[str, Any]) -> FileInfo:
200
206
  return self._file_info(self.fs.info(path, **kwargs))
201
207
 
208
+ def path_as_uri(self, path: str) -> str:
209
+ return str(self.fs.unstrip_protocol(path))
210
+
202
211
  def ls(
203
212
  self, path: str, recursive: bool = False, **kwargs: dict[str, Any]
204
213
  ) -> list[FileInfo]:
@@ -267,11 +276,18 @@ class FileSystem:
267
276
  else:
268
277
  file["mtime"] = None
269
278
 
279
+ # S3 filesystems provided an ETag
280
+ if "ETag" in file.keys():
281
+ etag: str | None = file["ETag"].strip('"')
282
+ else:
283
+ etag = None
284
+
270
285
  return FileInfo(
271
286
  name=file["name"],
272
287
  type=file["type"],
273
288
  size=file["size"],
274
289
  mtime=file["mtime"],
290
+ etag=etag,
275
291
  )
276
292
 
277
293
 
inspect_ai/_util/json.py CHANGED
@@ -6,7 +6,9 @@ from typing import (
6
6
 
7
7
  import jsonpatch
8
8
  from pydantic import BaseModel, Field, JsonValue
9
- from pydantic_core import to_jsonable_python
9
+ from pydantic_core import to_json, to_jsonable_python
10
+
11
+ from inspect_ai.util._json import JSONType
10
12
 
11
13
 
12
14
  def jsonable_python(x: Any) -> Any:
@@ -23,6 +25,39 @@ def jsonable_dict(x: Any) -> dict[str, JsonValue]:
23
25
  )
24
26
 
25
27
 
28
+ def to_json_safe(x: Any) -> bytes:
29
+ return to_json(value=x, indent=2, exclude_none=True, fallback=lambda _x: None)
30
+
31
+
32
+ def to_json_str_safe(x: Any) -> str:
33
+ return to_json_safe(x).decode()
34
+
35
+
36
+ def python_type_to_json_type(python_type: str | None) -> JSONType:
37
+ match python_type:
38
+ case "str":
39
+ return "string"
40
+ case "int":
41
+ return "integer"
42
+ case "float":
43
+ return "number"
44
+ case "bool":
45
+ return "boolean"
46
+ case "list":
47
+ return "array"
48
+ case "dict":
49
+ return "object"
50
+ case "None":
51
+ return "null"
52
+ # treat 'unknown' as string as anything can be converted to string
53
+ case None:
54
+ return "string"
55
+ case _:
56
+ raise ValueError(
57
+ f"Unsupported type: {python_type} for Python to JSON conversion."
58
+ )
59
+
60
+
26
61
  class JsonChange(BaseModel):
27
62
  """Describes a change to data using JSON Patch format."""
28
63
 
@@ -5,7 +5,7 @@ import os
5
5
  import urllib.parse
6
6
  from logging import LogRecord, getLogger
7
7
  from pathlib import Path
8
- from typing import Any, AsyncIterator, Awaitable, Callable, Literal, cast
8
+ from typing import Any, AsyncIterator, Awaitable, Callable, Literal, TypeVar, cast
9
9
 
10
10
  import fsspec # type: ignore
11
11
  from aiohttp import web
@@ -25,6 +25,7 @@ from inspect_ai.log._file import (
25
25
  read_eval_log_async,
26
26
  read_eval_log_headers_async,
27
27
  )
28
+ from inspect_ai.log._recorders.buffer.buffer import sample_buffer
28
29
 
29
30
  from .notify import view_last_eval_time
30
31
 
@@ -131,6 +132,60 @@ def view_server(
131
132
  )
132
133
  return web.json_response(actions)
133
134
 
135
+ @routes.get("/api/pending-samples")
136
+ async def api_pending_samples(request: web.Request) -> web.Response:
137
+ # log file requested
138
+ file = query_param_required("log", request, str)
139
+
140
+ file = urllib.parse.unquote(file)
141
+ validate_log_file_request(file)
142
+
143
+ # see if there is an etag
144
+ client_etag = request.headers.get("If-None-Match")
145
+
146
+ # get samples and respond
147
+ buffer = sample_buffer(file)
148
+ samples = buffer.get_samples(client_etag)
149
+ if samples == "NotModified":
150
+ return web.Response(status=304)
151
+ elif samples is None:
152
+ return web.Response(status=404)
153
+ else:
154
+ return web.Response(
155
+ body=samples.model_dump_json(), headers={"ETag": samples.etag}
156
+ )
157
+
158
+ @routes.get("/api/pending-sample-data")
159
+ async def api_sample_events(request: web.Request) -> web.Response:
160
+ # log file requested
161
+ file = query_param_required("log", request, str)
162
+
163
+ file = urllib.parse.unquote(file)
164
+ validate_log_file_request(file)
165
+
166
+ # sample id information
167
+ id = query_param_required("id", request, str)
168
+ epoch = query_param_required("epoch", request, int)
169
+
170
+ # get sync info
171
+ after_event_id = query_param_optional("last-event-id", request, int)
172
+ after_attachment_id = query_param_optional("after-attachment-id", request, int)
173
+
174
+ # get samples and responsd
175
+ buffer = sample_buffer(file)
176
+ sample_data = buffer.get_sample_data(
177
+ id=id,
178
+ epoch=epoch,
179
+ after_event_id=after_event_id,
180
+ after_attachment_id=after_attachment_id,
181
+ )
182
+
183
+ # respond
184
+ if sample_data is None:
185
+ return web.Response(status=404)
186
+ else:
187
+ return web.Response(body=sample_data.model_dump_json())
188
+
134
189
  # optional auth middleware
135
190
  @web.middleware
136
191
  async def authorize(
@@ -430,3 +485,60 @@ async def async_fileystem(
430
485
  else:
431
486
  options.update({"asynchronous": True, "loop": asyncio.get_event_loop()})
432
487
  yield fsspec.filesystem(protocol, **options)
488
+
489
+
490
+ T = TypeVar("T") # Define type variable
491
+
492
+
493
+ def query_param_required(
494
+ key: str, request: web.Request, converter: Callable[[str], T]
495
+ ) -> T:
496
+ """
497
+ Generic parameter validation function.
498
+
499
+ Args:
500
+ key: Parameter key to look up
501
+ request: aiohttp Request object
502
+ converter: Function to convert the string parameter to type T
503
+
504
+ Returns:
505
+ Converted parameter value of type T
506
+
507
+ Raises:
508
+ HTTPBadRequest: If parameter is missing or invalid
509
+ """
510
+ value = request.query.get(key)
511
+ if value is None:
512
+ raise web.HTTPBadRequest(text=f"Missing parameter {key}")
513
+
514
+ try:
515
+ return converter(value)
516
+ except ValueError:
517
+ raise web.HTTPBadRequest(text=f"Invalid value {value} for {key}")
518
+
519
+
520
+ def query_param_optional(
521
+ key: str, request: web.Request, converter: Callable[[str], T]
522
+ ) -> T | None:
523
+ """
524
+ Generic parameter validation function.
525
+
526
+ Args:
527
+ key: Parameter key to look up
528
+ request: aiohttp Request object
529
+ converter: Function to convert the string parameter to type T
530
+
531
+ Returns:
532
+ Converted parameter value of type T
533
+
534
+ Raises:
535
+ HTTPBadRequest: If parameter is missing or invalid
536
+ """
537
+ value = request.query.get(key)
538
+ if value is None:
539
+ return None
540
+
541
+ try:
542
+ return converter(value)
543
+ except ValueError:
544
+ raise web.HTTPBadRequest(text=f"Invalid value {value} for {key}")
@@ -335,7 +335,7 @@ body {
335
335
  padding-top: 0;
336
336
  padding-bottom: 0;
337
337
  background-color: var(--bs-light);
338
- top: 2px;
338
+ top: 0;
339
339
  }
340
340
 
341
341
  .navbar-title-grid {