docent-python 0.1.38a0__tar.gz → 0.1.40a0__tar.gz

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 (62) hide show
  1. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/PKG-INFO +1 -1
  2. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_llm_util/model_registry.py +4 -0
  3. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_llm_util/providers/preference_types.py +1 -1
  4. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/data_models/agent_run.py +44 -8
  5. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/data_models/chat/message.py +2 -2
  6. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/data_models/judge.py +0 -2
  7. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/sdk/client.py +272 -16
  8. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/sdk/llm_context.py +3 -1
  9. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/pyproject.toml +1 -1
  10. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/.gitignore +0 -0
  11. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/LICENSE.md +0 -0
  12. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/README.md +0 -0
  13. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/__init__.py +0 -0
  14. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_llm_util/__init__.py +0 -0
  15. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_llm_util/data_models/__init__.py +0 -0
  16. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_llm_util/data_models/exceptions.py +0 -0
  17. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_llm_util/data_models/llm_output.py +0 -0
  18. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_llm_util/llm_cache.py +0 -0
  19. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_llm_util/llm_svc.py +0 -0
  20. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_llm_util/providers/__init__.py +0 -0
  21. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_llm_util/providers/anthropic.py +0 -0
  22. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_llm_util/providers/common.py +0 -0
  23. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_llm_util/providers/google.py +0 -0
  24. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_llm_util/providers/openai.py +0 -0
  25. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_llm_util/providers/openrouter.py +0 -0
  26. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_llm_util/providers/provider_registry.py +0 -0
  27. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_log_util/__init__.py +0 -0
  28. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/_log_util/logger.py +0 -0
  29. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/data_models/__init__.py +0 -0
  30. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/data_models/_tiktoken_util.py +0 -0
  31. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/data_models/chat/__init__.py +0 -0
  32. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/data_models/chat/content.py +0 -0
  33. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/data_models/chat/tool.py +0 -0
  34. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/data_models/citation.py +0 -0
  35. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/data_models/collection.py +0 -0
  36. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/data_models/formatted_objects.py +0 -0
  37. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/data_models/metadata_util.py +0 -0
  38. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/data_models/regex.py +0 -0
  39. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/data_models/transcript.py +0 -0
  40. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/data_models/util.py +0 -0
  41. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/judges/__init__.py +0 -0
  42. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/judges/analysis.py +0 -0
  43. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/judges/impl.py +0 -0
  44. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/judges/runner.py +0 -0
  45. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/judges/stats.py +0 -0
  46. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/judges/types.py +0 -0
  47. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/judges/util/forgiving_json.py +0 -0
  48. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/judges/util/meta_schema.json +0 -0
  49. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/judges/util/meta_schema.py +0 -0
  50. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/judges/util/parse_output.py +0 -0
  51. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/judges/util/voting.py +0 -0
  52. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/loaders/load_inspect.py +0 -0
  53. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/py.typed +0 -0
  54. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/samples/__init__.py +0 -0
  55. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/samples/load.py +0 -0
  56. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/samples/log.eval +0 -0
  57. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/samples/tb_airline.json +0 -0
  58. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/sdk/__init__.py +0 -0
  59. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/sdk/agent_run_writer.py +0 -0
  60. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/trace.py +0 -0
  61. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/docent/trace_temp.py +0 -0
  62. {docent_python-0.1.38a0 → docent_python-0.1.40a0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docent-python
3
- Version: 0.1.38a0
3
+ Version: 0.1.40a0
4
4
  Summary: Docent SDK
5
5
  Project-URL: Homepage, https://github.com/TransluceAI/docent
6
6
  Project-URL: Issues, https://github.com/TransluceAI/docent/issues
@@ -54,6 +54,10 @@ _REGISTRY: list[tuple[str, ModelInfo]] = [
54
54
  "claude-sonnet-4",
55
55
  ModelInfo(rate={"input": 3.0, "output": 15.0}, context_window=200_000),
56
56
  ),
57
+ (
58
+ "claude-sonnet-4-5",
59
+ ModelInfo(rate={"input": 3.0, "output": 15.0}, context_window=200_000),
60
+ ),
57
61
  (
58
62
  "claude-haiku-4-5",
59
63
  ModelInfo(rate={"input": 1.0, "output": 5.0}, context_window=200_000),
@@ -95,7 +95,7 @@ class PublicProviderPreferences(BaseModel):
95
95
  ModelOption(provider="openai", model_name="gpt-5-mini", reasoning_effort="high"),
96
96
  ModelOption(
97
97
  provider="anthropic",
98
- model_name="claude-sonnet-4-20250514",
98
+ model_name="claude-sonnet-4-5",
99
99
  reasoning_effort="medium",
100
100
  ),
101
101
  ]
@@ -1,7 +1,7 @@
1
1
  import sys
2
2
  import textwrap
3
+ from collections import deque
3
4
  from datetime import datetime
4
- from queue import Queue
5
5
  from typing import Any, Literal, TypedDict, cast
6
6
  from uuid import uuid4
7
7
 
@@ -257,6 +257,13 @@ class AgentRun(BaseModel):
257
257
  self._transcript_group_dict = {tg.id: tg for tg in self.transcript_groups}
258
258
  return self._transcript_group_dict
259
259
 
260
+ def _invalidate_caches(self) -> None:
261
+ """Reset cached lookups after mutating transcripts or transcript groups."""
262
+ self._transcript_dict = None
263
+ self._transcript_group_dict = None
264
+ self._canonical_tree_cache.clear()
265
+ self._transcript_ids_ordered_cache.clear()
266
+
260
267
  def get_canonical_tree(
261
268
  self, full_tree: bool = False
262
269
  ) -> dict[str | None, list[tuple[Literal["t", "tg"], str]]]:
@@ -328,14 +335,11 @@ class AgentRun(BaseModel):
328
335
  tg_tree.setdefault(t.transcript_group_id or "__global_root", set()).add(("t", t_id))
329
336
  else:
330
337
  # Initialize q with "important" tgs
331
- q, seen = Queue[str](), set[str]()
332
- for tg_id in tgs_to_transcripts.keys():
333
- q.put(tg_id)
334
- seen.add(tg_id)
338
+ q, seen = deque(tgs_to_transcripts.keys()), set(tgs_to_transcripts.keys())
335
339
 
336
340
  # Do an "upwards BFS" from leaves up to the root. Builds a tree of only relevant nodes.
337
- while q.qsize() > 0:
338
- u_id = q.get()
341
+ while q:
342
+ u_id = q.popleft()
339
343
  u = tg_dict.get(u_id) # None if __global_root
340
344
 
341
345
  # Add the transcripts under this tg
@@ -349,7 +353,7 @@ class AgentRun(BaseModel):
349
353
  tg_tree.setdefault(par_id, set()).add(("tg", u_id))
350
354
  # If we haven't investigated the parent before, add to q
351
355
  if par_id not in seen:
352
- q.put(par_id)
356
+ q.append(par_id)
353
357
  seen.add(par_id)
354
358
 
355
359
  # For each node, sort by created_at timestamp
@@ -384,6 +388,38 @@ class AgentRun(BaseModel):
384
388
 
385
389
  return c_tree, transcript_idx_map
386
390
 
391
+ def delete_transcript_group_subtree(self, transcript_group_id: str) -> None:
392
+ """Delete a transcript group and all descendant groups/transcripts using the canonical tree."""
393
+ if transcript_group_id == "__global_root":
394
+ raise ValueError("Cannot delete the global root sentinel")
395
+ if transcript_group_id not in self.transcript_group_dict:
396
+ raise ValueError(
397
+ f"Transcript group '{transcript_group_id}' does not exist on this run."
398
+ )
399
+
400
+ canonical_tree = self.get_canonical_tree(full_tree=True)
401
+ groups_to_delete: set[str] = set()
402
+ transcripts_to_delete: set[str] = set()
403
+
404
+ queue: deque[str] = deque([transcript_group_id])
405
+ while queue:
406
+ current_group = queue.popleft()
407
+ groups_to_delete.add(current_group)
408
+ for child_type, child_id in canonical_tree.get(current_group, []):
409
+ if child_type == "tg":
410
+ queue.append(child_id)
411
+ else:
412
+ transcripts_to_delete.add(child_id)
413
+
414
+ if groups_to_delete:
415
+ self.transcript_groups = [
416
+ tg for tg in self.transcript_groups if tg.id not in groups_to_delete
417
+ ]
418
+ if transcripts_to_delete:
419
+ self.transcripts = [t for t in self.transcripts if t.id not in transcripts_to_delete]
420
+
421
+ self._invalidate_caches()
422
+
387
423
  def to_text_new(
388
424
  self,
389
425
  agent_run_alias: int | str = 0,
@@ -1,7 +1,7 @@
1
1
  from logging import getLogger
2
2
  from typing import Annotated, Any, Literal
3
3
 
4
- from pydantic import BaseModel, Discriminator, Field
4
+ from pydantic import BaseModel, Discriminator
5
5
 
6
6
  from docent.data_models.chat.content import Content
7
7
  from docent.data_models.chat.tool import ToolCall
@@ -23,7 +23,7 @@ class BaseChatMessage(BaseModel):
23
23
  id: str | None = None
24
24
  content: str | list[Content]
25
25
  role: Literal["system", "user", "assistant", "tool"]
26
- metadata: dict[str, Any] = Field(default_factory=dict)
26
+ metadata: dict[str, Any] | None = None
27
27
 
28
28
  @property
29
29
  def text(self) -> str:
@@ -10,9 +10,7 @@ class Label(BaseModel):
10
10
  id: str = Field(default_factory=lambda: str(uuid4()))
11
11
 
12
12
  label_set_id: str
13
-
14
13
  label_value: dict[str, Any]
15
-
16
14
  agent_run_id: str
17
15
 
18
16
 
@@ -1,11 +1,15 @@
1
+ import gzip
1
2
  import itertools
3
+ import json
2
4
  import os
5
+ import time
3
6
  import webbrowser
4
7
  from pathlib import Path
5
- from typing import Any, Literal
8
+ from typing import Any, Iterator, Literal
6
9
 
7
10
  import pandas as pd
8
11
  import requests
12
+ from pydantic_core import to_jsonable_python
9
13
  from tqdm import tqdm
10
14
 
11
15
  from docent._log_util.logger import get_logger
@@ -16,6 +20,61 @@ from docent.judges.util.meta_schema import validate_judge_result_schema
16
20
  from docent.loaders import load_inspect
17
21
  from docent.sdk.llm_context import LLMContext, LLMContextItem
18
22
 
23
+ MAX_AGENT_RUN_PAYLOAD_BYTES = 100 * 1024 * 1024 # 100MB backend limit
24
+ _AGENT_RUNS_PAYLOAD_PREFIX = b'{"agent_runs":['
25
+ _AGENT_RUNS_PAYLOAD_SUFFIX = b"]}"
26
+
27
+
28
+ def _serialize_agent_run(agent_run: AgentRun) -> bytes:
29
+ """Serialize an AgentRun to compact JSON bytes."""
30
+ return json.dumps(to_jsonable_python(agent_run), separators=(",", ":")).encode("utf-8")
31
+
32
+
33
+ def _build_agent_runs_payload(serialized_runs: list[bytes]) -> bytes:
34
+ """Wrap serialized individual runs into the API payload envelope."""
35
+ body = b",".join(serialized_runs)
36
+ return _AGENT_RUNS_PAYLOAD_PREFIX + body + _AGENT_RUNS_PAYLOAD_SUFFIX
37
+
38
+
39
+ def _yield_agent_run_batches_by_size(
40
+ agent_runs: list[AgentRun], max_payload_bytes: int
41
+ ) -> Iterator[tuple[int, bytes]]:
42
+ """Yield batches of agent runs whose serialized payloads stay within max_payload_bytes."""
43
+ envelope_len = len(_AGENT_RUNS_PAYLOAD_PREFIX) + len(_AGENT_RUNS_PAYLOAD_SUFFIX)
44
+ comma_len = 1
45
+
46
+ current_serialized: list[bytes] = []
47
+ current_size = envelope_len
48
+
49
+ for agent_run in agent_runs:
50
+ serialized = _serialize_agent_run(agent_run)
51
+ serialized_len = len(serialized)
52
+
53
+ if envelope_len + serialized_len > max_payload_bytes:
54
+ raise ValueError(
55
+ f"A single agent run (id={agent_run.id}) exceeds the maximum payload size of "
56
+ f"{max_payload_bytes} bytes. Reduce the size of that run before uploading."
57
+ )
58
+
59
+ delimiter = 0 if not current_serialized else comma_len
60
+ projected_size = current_size + delimiter + serialized_len
61
+
62
+ # If adding the next run would exceed the max payload size, yield the current batch
63
+ if current_serialized and projected_size > max_payload_bytes:
64
+ yield len(current_serialized), _build_agent_runs_payload(current_serialized)
65
+
66
+ # Add the "next run" as the first run in the next batch
67
+ current_serialized = [serialized]
68
+ current_size = envelope_len + serialized_len
69
+ # Otherwise, add to the current batch and continue
70
+ else:
71
+ current_serialized.append(serialized)
72
+ current_size = projected_size
73
+
74
+ if current_serialized:
75
+ yield len(current_serialized), _build_agent_runs_payload(current_serialized)
76
+
77
+
19
78
  logger = get_logger(__name__)
20
79
 
21
80
 
@@ -37,6 +96,7 @@ class Docent:
37
96
  self,
38
97
  *,
39
98
  domain: str = "docent.transluce.org",
99
+ use_https: bool = True,
40
100
  api_key: str | None = None,
41
101
  # Deprecated
42
102
  server_url: str | None = None, # Use domain instead
@@ -73,13 +133,14 @@ class Docent:
73
133
  self._domain = domain
74
134
 
75
135
  # Set server URL; server_url takes precedence over domain
76
- server_url = (server_url or f"https://api.{domain}").rstrip("/")
136
+ prefix = "https://" if use_https else "http://"
137
+ server_url = (server_url or f"{prefix}api.{domain}").rstrip("/")
77
138
  if not server_url.endswith("/rest"):
78
139
  server_url = f"{server_url}/rest"
79
140
  self._server_url = server_url
80
141
 
81
142
  # Set web URL; web_url takes precedence over domain
82
- self._web_url = (web_url or f"https://{domain}").rstrip("/")
143
+ self._web_url = (web_url or f"{prefix}{domain}").rstrip("/")
83
144
 
84
145
  # Use requests.Session for connection pooling and persistent headers
85
146
  self._session = requests.Session()
@@ -192,41 +253,199 @@ class Docent:
192
253
  logger.info(f"Successfully updated Collection '{collection_id}'")
193
254
 
194
255
  def add_agent_runs(
195
- self, collection_id: str, agent_runs: list[AgentRun], batch_size: int = 1000
256
+ self,
257
+ collection_id: str,
258
+ agent_runs: list[AgentRun],
259
+ *,
260
+ compression: Literal["gzip", "none"] = "gzip",
261
+ wait: bool = True,
262
+ poll_interval: float = 1.0,
263
+ # Deprecated
264
+ batch_size: int | None = None,
196
265
  ) -> dict[str, Any]:
197
266
  """Adds agent runs to a Collection.
198
267
 
199
268
  Agent runs represent execution traces that can be visualized and analyzed.
200
- This method batches the insertion in groups of 1,000 for better performance.
269
+ Requests are automatically chunked to stay under the backend's payload limit.
201
270
 
202
271
  Args:
203
272
  collection_id: ID of the Collection.
204
273
  agent_runs: List of AgentRun objects to add.
274
+ compression: Compression algorithm for request bodies. Defaults to gzip.
275
+ Set to "none" to retain legacy behavior.
276
+ wait: If True (default), wait for all ingestion jobs to complete before returning.
277
+ If False, return immediately after enqueuing jobs.
278
+ poll_interval: Seconds between status checks when wait=True. Defaults to 1.0.
205
279
 
206
280
  Returns:
207
- dict: API response data.
281
+ dict: API response data containing:
282
+ - status: "success" if all jobs completed, "enqueued" if wait=False
283
+ - total_runs_added: Number of agent runs submitted
284
+ - job_ids: List of job IDs for tracking
208
285
 
209
286
  Raises:
287
+ ValueError: If any single agent run exceeds the maximum payload size.
210
288
  requests.exceptions.HTTPError: If the API request fails.
289
+ RuntimeError: If any job fails during processing (when wait=True).
211
290
  """
212
- from tqdm import tqdm
291
+
292
+ if batch_size is not None:
293
+ logger.warning(
294
+ "The 'batch_size' parameter is deprecated and will be removed in a future version. "
295
+ "We have transitioned to a new batching strategy based on the size of the payload."
296
+ )
213
297
 
214
298
  url = f"{self._server_url}/{collection_id}/agent_runs"
215
299
  total_runs = len(agent_runs)
300
+ job_ids: list[str] = []
216
301
 
217
302
  # Process agent runs in batches
218
- with tqdm(total=total_runs, desc="Adding agent runs", unit="runs") as pbar:
219
- for i in range(0, total_runs, batch_size):
220
- batch = agent_runs[i : i + batch_size]
221
- payload = {"agent_runs": [ar.model_dump(mode="json") for ar in batch]}
222
-
223
- response = self._session.post(url, json=payload)
303
+ desc = f"Uploading agent runs (compression={compression})"
304
+ with tqdm(total=total_runs, desc=desc, unit="runs") as pbar:
305
+ for batch_size, payload_bytes in _yield_agent_run_batches_by_size(
306
+ agent_runs, MAX_AGENT_RUN_PAYLOAD_BYTES
307
+ ):
308
+ request_kwargs: dict[str, Any] = {}
309
+ if compression == "none":
310
+ request_kwargs["data"] = payload_bytes
311
+ request_kwargs["headers"] = {"Content-Type": "application/json"}
312
+ elif compression == "gzip":
313
+ request_kwargs["data"] = gzip.compress(payload_bytes)
314
+ request_kwargs["headers"] = {
315
+ "Content-Type": "application/json",
316
+ "Content-Encoding": "gzip",
317
+ }
318
+ else:
319
+ raise ValueError(f"Unsupported compression '{compression}'")
320
+
321
+ response = self._session.post(url, **request_kwargs)
224
322
  self._handle_response_errors(response)
225
323
 
226
- pbar.update(len(batch))
324
+ # Server returns 202 with job_id for async processing
325
+ response_data = response.json()
326
+ job_id = response_data.get("job_id")
327
+ if job_id:
328
+ job_ids.append(job_id)
329
+
330
+ pbar.update(batch_size)
331
+
332
+ if not wait:
333
+ logger.info(
334
+ f"Enqueued {total_runs} agent runs to Collection '{collection_id}' "
335
+ f"({len(job_ids)} job(s)). Use get_agent_run_job_status() to check progress."
336
+ )
337
+ return {
338
+ "status": "enqueued",
339
+ "total_runs_added": total_runs,
340
+ "job_ids": job_ids,
341
+ }
342
+
343
+ # Wait for all jobs to complete
344
+ if job_ids:
345
+ logger.info(
346
+ f"Uploaded {total_runs} agent runs in {len(job_ids)} batch(es). "
347
+ f"Waiting for server-side processing to complete... "
348
+ f"(set wait=False to skip waiting)"
349
+ )
350
+ self._wait_for_jobs(collection_id, job_ids, poll_interval)
351
+
352
+ logger.info(
353
+ f"Successfully added {total_runs} agent runs to Collection '{collection_id}'. "
354
+ f"All {len(job_ids)} job(s) completed."
355
+ )
356
+ return {"status": "success", "total_runs_added": total_runs, "job_ids": job_ids}
357
+
358
+ def _wait_for_jobs(
359
+ self,
360
+ collection_id: str,
361
+ job_ids: list[str],
362
+ poll_interval: float = 1.0,
363
+ ) -> None:
364
+ """Wait for all jobs to complete, showing progress.
365
+
366
+ Args:
367
+ collection_id: ID of the Collection.
368
+ job_ids: List of job IDs to wait for.
369
+ poll_interval: Seconds between status checks.
370
+
371
+ Raises:
372
+ RuntimeError: If any job fails or is canceled.
373
+ """
374
+ pending_jobs = set(job_ids)
375
+ failed_jobs: dict[str, str] = {}
376
+
377
+ with tqdm(total=len(job_ids), desc="Waiting for server processing", unit="jobs") as pbar:
378
+ while pending_jobs:
379
+ statuses = self.get_agent_run_job_statuses(collection_id, list(pending_jobs))
380
+
381
+ for job_status in statuses:
382
+ job_id = job_status["job_id"]
383
+ status = job_status["status"]
384
+
385
+ if status == "completed":
386
+ pending_jobs.discard(job_id)
387
+ pbar.update(1)
388
+ elif status == "canceled":
389
+ pending_jobs.discard(job_id)
390
+ failed_jobs[job_id] = "Job was canceled"
391
+ pbar.update(1)
392
+
393
+ if pending_jobs:
394
+ time.sleep(poll_interval)
395
+
396
+ if failed_jobs:
397
+ failed_msg = ", ".join(f"{k}: {v}" for k, v in failed_jobs.items())
398
+ raise RuntimeError(f"Some jobs failed: {failed_msg}")
399
+
400
+ def get_agent_run_job_statuses(
401
+ self, collection_id: str, job_ids: list[str]
402
+ ) -> list[dict[str, Any]]:
403
+ """Get the status of multiple agent run ingestion jobs.
404
+
405
+ Args:
406
+ collection_id: ID of the Collection.
407
+ job_ids: List of job IDs to check (max 100).
408
+
409
+ Returns:
410
+ list: List of job status dictionaries, each containing:
411
+ - job_id: The job ID
412
+ - status: One of "pending", "running", "completed", "canceled"
413
+ - type: The job type
414
+ - created_at: ISO timestamp of job creation
415
+
416
+ Raises:
417
+ ValueError: If more than 100 job IDs are provided.
418
+ requests.exceptions.HTTPError: If the API request fails.
419
+ """
420
+ if len(job_ids) > 100:
421
+ raise ValueError("Cannot request more than 100 job IDs at once")
422
+
423
+ url = f"{self._server_url}/{collection_id}/agent_runs/jobs/batch_status"
424
+ response = self._session.post(url, json={"job_ids": job_ids})
425
+ self._handle_response_errors(response)
426
+ return response.json()["jobs"]
427
+
428
+ def get_agent_run_job_status(self, collection_id: str, job_id: str) -> dict[str, Any]:
429
+ """Get the status of an agent run ingestion job.
430
+
431
+ Args:
432
+ collection_id: ID of the Collection.
433
+ job_id: The ID of the job to check.
227
434
 
228
- logger.info(f"Successfully added {total_runs} agent runs to Collection '{collection_id}'")
229
- return {"status": "success", "total_runs_added": total_runs}
435
+ Returns:
436
+ dict: Job status information including:
437
+ - job_id: The job ID
438
+ - status: One of "pending", "running", "completed", "canceled"
439
+ - type: The job type
440
+ - created_at: ISO timestamp of job creation
441
+
442
+ Raises:
443
+ requests.exceptions.HTTPError: If the API request fails.
444
+ """
445
+ url = f"{self._server_url}/{collection_id}/agent_runs/jobs/{job_id}"
446
+ response = self._session.get(url)
447
+ self._handle_response_errors(response)
448
+ return response.json()
230
449
 
231
450
  def list_collections(self) -> list[Collection]:
232
451
  """Lists all available Collections.
@@ -459,6 +678,43 @@ class Docent:
459
678
  self._handle_response_errors(response)
460
679
  return response.json()
461
680
 
681
+ def tag_transcript(self, collection_id: str, agent_run_id: str, value: str) -> None:
682
+ """Add a tag to an agent run transcript.
683
+
684
+ Args:
685
+ collection_id: ID of the Collection.
686
+ agent_run_id: The agent run to tag.
687
+ value: The tag value (max length enforced by the server).
688
+
689
+ Raises:
690
+ requests.exceptions.HTTPError: If the API request fails.
691
+ """
692
+ url = f"{self._server_url}/label/{collection_id}/tag"
693
+ payload = {"agent_run_id": agent_run_id, "value": value}
694
+ response = self._session.post(url, json=payload)
695
+ self._handle_response_errors(response)
696
+
697
+ def get_tags(self, collection_id: str, value: str | None = None) -> list[dict[str, Any]]:
698
+ """Get all tags in a collection, optionally filtered by value."""
699
+ url = f"{self._server_url}/label/{collection_id}/tags"
700
+ params = {"value": value} if value is not None else None
701
+ response = self._session.get(url, params=params)
702
+ self._handle_response_errors(response)
703
+ return response.json()
704
+
705
+ def get_tags_for_agent_run(self, collection_id: str, agent_run_id: str) -> list[dict[str, Any]]:
706
+ """Get all tags attached to a specific agent run."""
707
+ url = f"{self._server_url}/label/{collection_id}/agent_run/{agent_run_id}/tags"
708
+ response = self._session.get(url)
709
+ self._handle_response_errors(response)
710
+ return response.json()
711
+
712
+ def delete_tag(self, collection_id: str, tag_id: str) -> None:
713
+ """Delete a tag by ID."""
714
+ url = f"{self._server_url}/label/{collection_id}/tag/{tag_id}"
715
+ response = self._session.delete(url)
716
+ self._handle_response_errors(response)
717
+
462
718
  def get_agent_run(self, collection_id: str, agent_run_id: str) -> AgentRun | None:
463
719
  """Get a specific agent run by its ID.
464
720
 
@@ -365,7 +365,9 @@ def _get_text_for_citation_target(target: CitationTarget, context: LLMContext) -
365
365
  if transcript.id == item.transcript_id:
366
366
  if 0 <= item.block_idx < len(transcript.messages):
367
367
  message = transcript.messages[item.block_idx]
368
- metadata_value = message.metadata.get(item.metadata_key)
368
+ metadata_value = (
369
+ message.metadata.get(item.metadata_key) if message.metadata else None
370
+ )
369
371
  if metadata_value is not None:
370
372
  return json.dumps(metadata_value)
371
373
  return None
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "docent-python"
3
3
  description = "Docent SDK"
4
- version = "0.1.38-alpha"
4
+ version = "0.1.40-alpha"
5
5
  authors = [
6
6
  { name="Transluce", email="info@transluce.org" },
7
7
  ]