docent-python 0.1.11a0__tar.gz → 0.1.13a0__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.

Potentially problematic release.


This version of docent-python might be problematic. Click here for more details.

Files changed (34) hide show
  1. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/PKG-INFO +1 -1
  2. docent_python-0.1.13a0/docent/__init__.py +4 -0
  3. docent_python-0.1.13a0/docent/agent_run_writer.py +266 -0
  4. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/data_models/chat/tool.py +1 -1
  5. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/trace.py +33 -52
  6. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/pyproject.toml +1 -1
  7. docent_python-0.1.11a0/docent/__init__.py +0 -3
  8. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/.gitignore +0 -0
  9. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/LICENSE.md +0 -0
  10. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/README.md +0 -0
  11. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/_log_util/__init__.py +0 -0
  12. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/_log_util/logger.py +0 -0
  13. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/data_models/__init__.py +0 -0
  14. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/data_models/_tiktoken_util.py +0 -0
  15. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/data_models/agent_run.py +0 -0
  16. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/data_models/chat/__init__.py +0 -0
  17. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/data_models/chat/content.py +0 -0
  18. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/data_models/chat/message.py +0 -0
  19. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/data_models/citation.py +0 -0
  20. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/data_models/metadata.py +0 -0
  21. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/data_models/regex.py +0 -0
  22. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/data_models/remove_invalid_citation_ranges.py +0 -0
  23. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/data_models/shared_types.py +0 -0
  24. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/data_models/transcript.py +0 -0
  25. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/loaders/load_inspect.py +0 -0
  26. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/py.typed +0 -0
  27. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/samples/__init__.py +0 -0
  28. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/samples/load.py +0 -0
  29. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/samples/log.eval +0 -0
  30. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/samples/tb_airline.json +0 -0
  31. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/sdk/__init__.py +0 -0
  32. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/sdk/client.py +0 -0
  33. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/docent/trace_temp.py +0 -0
  34. {docent_python-0.1.11a0 → docent_python-0.1.13a0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docent-python
3
- Version: 0.1.11a0
3
+ Version: 0.1.13a0
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
@@ -0,0 +1,4 @@
1
+ __all__ = ["Docent", "init"]
2
+
3
+ from docent.agent_run_writer import init
4
+ from docent.sdk.client import Docent
@@ -0,0 +1,266 @@
1
+ import atexit
2
+ import os
3
+ import queue
4
+ import signal
5
+ import threading
6
+ import time
7
+ from typing import Any, Callable, Coroutine, Optional
8
+
9
+ import anyio
10
+ import backoff
11
+ import httpx
12
+ from backoff.types import Details
13
+
14
+ from docent._log_util.logger import get_logger
15
+ from docent.data_models.agent_run import AgentRun
16
+ from docent.sdk.client import Docent
17
+
18
+ logger = get_logger(__name__)
19
+
20
+
21
+ def _giveup(exc: BaseException) -> bool:
22
+ """Give up on client errors."""
23
+
24
+ if isinstance(exc, httpx.HTTPStatusError):
25
+ status = exc.response.status_code
26
+ return status < 500 and status != 429
27
+ return False
28
+
29
+
30
+ def _print_backoff_message(e: Details):
31
+ logger.warning(
32
+ f"AgentRunWriter backing off for {e['wait']:.2f}s due to {e['exception'].__class__.__name__}" # type: ignore
33
+ )
34
+
35
+
36
+ class AgentRunWriter:
37
+ """Background thread for logging agent runs.
38
+
39
+ Args:
40
+ api_key (str): API key for the Docent API.
41
+ collection_id (str): ID of the collection to log agent runs to.
42
+ server_url (str): URL of the Docent server.
43
+ num_workers (int): Max number of concurrent tasks to run,
44
+ managed by anyio.CapacityLimiter.
45
+ queue_maxsize (int): Maximum size of the queue.
46
+ If maxsize is <= 0, the queue size is infinite.
47
+ request_timeout (float): Timeout for the HTTP request.
48
+ flush_interval (float): Interval to flush the queue.
49
+ batch_size (int): Number of agent runs to batch together.
50
+ max_retries (int): Maximum number of retries for the HTTP request.
51
+ shutdown_timeout (int): Timeout to wait for the background thread to finish
52
+ after the main thread has requested shutdown.
53
+ """
54
+
55
+ _instance: Optional["AgentRunWriter"] = None
56
+ _instance_lock = threading.Lock()
57
+
58
+ def __init__(
59
+ self,
60
+ api_key: str,
61
+ collection_id: str,
62
+ server_url: str = "https://api.docent.transluce.org",
63
+ num_workers: int = 2,
64
+ queue_maxsize: int = 20_000,
65
+ request_timeout: float = 30.0,
66
+ flush_interval: float = 1.0,
67
+ batch_size: int = 1_000,
68
+ max_retries: int = 5,
69
+ shutdown_timeout: int = 60,
70
+ ) -> None:
71
+ with self._instance_lock:
72
+ if AgentRunWriter._instance is not None:
73
+ return
74
+ AgentRunWriter._instance = self
75
+
76
+ # Request parameters
77
+ self._headers = {"Authorization": f"Bearer {api_key}"}
78
+ self._base_url = server_url.rstrip("/") + "/rest"
79
+ self._endpoint = f"{collection_id}/agent_runs"
80
+
81
+ self._num_workers = num_workers
82
+ self._request_timeout = request_timeout
83
+ self._flush_interval = flush_interval
84
+ self._batch_size = batch_size
85
+ self._max_retries = max_retries
86
+ self._shutdown_timeout = shutdown_timeout
87
+
88
+ self._queue: queue.Queue[AgentRun] = queue.Queue(maxsize=queue_maxsize)
89
+ self._cancel_event = threading.Event()
90
+
91
+ # Start background thread
92
+ self._thread = threading.Thread(
93
+ target=lambda: anyio.run(self._async_main),
94
+ name="AgentRunWriterThread",
95
+ daemon=True,
96
+ )
97
+ self._thread.start()
98
+ logger.info("AgentRunWriter thread started")
99
+
100
+ self._register_shutdown_hooks()
101
+
102
+ def _register_shutdown_hooks(self) -> None:
103
+ """Register shutdown hooks for atexit and signals."""
104
+
105
+ # Register shutdown hooks
106
+ atexit.register(self.finish)
107
+
108
+ # Register signal handlers for graceful shutdown
109
+ signal.signal(signal.SIGINT, lambda s, f: self._shutdown()) # Ctrl+C
110
+ signal.signal(signal.SIGTERM, lambda s, f: self._shutdown()) # Kill signal
111
+
112
+ def log_agent_runs(self, agent_runs: list[AgentRun]) -> None:
113
+ """Put a list of AgentRun objects into the queue.
114
+
115
+ If the queue is full, the method will block until the queue has space.
116
+
117
+ Args:
118
+ agent_runs (list[AgentRun]): List of AgentRun objects to put into the queue.
119
+ """
120
+
121
+ p_full = (
122
+ (self._queue.qsize() + len(agent_runs)) / self._queue.maxsize
123
+ if self._queue.maxsize > 0
124
+ else 0
125
+ )
126
+ if p_full >= 0.9:
127
+ logger.warning("AgentRunWriter queue is almost full (>=90%).")
128
+
129
+ for run in agent_runs:
130
+ try:
131
+ self._queue.put_nowait(run)
132
+ except queue.Full:
133
+ logger.warning("AgentRunWriter queue is full, blocking...")
134
+ self._queue.put(run, block=True)
135
+
136
+ def finish(self, force: bool = False) -> None:
137
+ """Request shutdown and wait up to timeout for pending tasks to complete.
138
+
139
+ Args:
140
+ force (bool): If True, shut down immediately. If False, wait for pending tasks to complete.
141
+ """
142
+ if not force:
143
+ # Wait for background thread to finish up to timeout
144
+ logger.info("Waiting for pending tasks to complete")
145
+
146
+ for i in range(0, self._shutdown_timeout, 5):
147
+ if not self._thread.is_alive():
148
+ break
149
+
150
+ if self._queue.empty():
151
+ break
152
+
153
+ logger.info(
154
+ f"Waiting for pending tasks to complete " f"({i}/{self._shutdown_timeout})s"
155
+ )
156
+ time.sleep(5)
157
+
158
+ self._shutdown()
159
+
160
+ def _shutdown(self) -> None:
161
+ """Shutdown the AgentRunWriter thread."""
162
+ if self._thread.is_alive():
163
+ logger.info("Cancelling pending tasks...")
164
+ self._cancel_event.set()
165
+ n_pending = self._queue.qsize()
166
+ logger.info(f"Cancelled ~{n_pending} pending tasks")
167
+
168
+ # Give a brief moment to exit
169
+ logger.info("Waiting for thread to exit...")
170
+ self._thread.join(timeout=1.0)
171
+
172
+ def get_post_batch_fcn(
173
+ self, client: httpx.AsyncClient
174
+ ) -> Callable[[list[AgentRun], anyio.CapacityLimiter], Coroutine[Any, Any, None]]:
175
+ """Return a function that will post a batch of agent runs to the API."""
176
+
177
+ @backoff.on_exception(
178
+ backoff.expo,
179
+ exception=httpx.HTTPError,
180
+ giveup=_giveup,
181
+ max_tries=self._max_retries,
182
+ on_backoff=_print_backoff_message,
183
+ )
184
+ async def _post_batch(batch: list[AgentRun], limiter: anyio.CapacityLimiter) -> None:
185
+ async with limiter:
186
+ payload = {"agent_runs": [ar.model_dump(mode="json") for ar in batch]}
187
+ resp = await client.post(
188
+ self._endpoint, json=payload, timeout=self._request_timeout
189
+ )
190
+ resp.raise_for_status()
191
+
192
+ return _post_batch
193
+
194
+ async def _async_main(self) -> None:
195
+ """Main async function for the AgentRunWriter thread."""
196
+
197
+ limiter = anyio.CapacityLimiter(self._num_workers)
198
+
199
+ async with httpx.AsyncClient(base_url=self._base_url, headers=self._headers) as client:
200
+ async with anyio.create_task_group() as tg:
201
+ _post_batch = self.get_post_batch_fcn(client)
202
+
203
+ async def batch_loop() -> None:
204
+ while not self._cancel_event.is_set():
205
+ batch = await self._gather_next_batch_from_queue()
206
+ if not batch:
207
+ continue
208
+
209
+ tg.start_soon(_post_batch, batch, limiter)
210
+
211
+ tg.start_soon(batch_loop)
212
+
213
+ async def _gather_next_batch_from_queue(self) -> list[AgentRun]:
214
+ """Gather a batch of agent runs from the queue.
215
+
216
+ Fetches items from the queue until the batch is full or the timeout expires.
217
+ """
218
+ batch: list[AgentRun] = []
219
+ with anyio.move_on_after(self._flush_interval):
220
+ while len(batch) < self._batch_size:
221
+ try:
222
+ item = self._queue.get_nowait()
223
+ batch.append(item)
224
+ except queue.Empty:
225
+ await anyio.sleep(0.1)
226
+
227
+ return batch
228
+
229
+
230
+ def init(
231
+ collection_name: str = "Agent Run Collection",
232
+ collection_id: str | None = None,
233
+ server_url: str = "https://api.docent.transluce.org",
234
+ web_url: str = "https://docent.transluce.org",
235
+ api_key: str | None = None,
236
+ ):
237
+ """Initialize the AgentRunWriter thread.
238
+
239
+ Args:
240
+ collection_name (str): Name of the agent run collection.
241
+ collection_id (str): ID of the agent run collection.
242
+ server_url (str): URL of the Docent server.
243
+ web_url (str): URL of the Docent web UI.
244
+ api_key (str): API key for the Docent API.
245
+ """
246
+ api_key = api_key or os.getenv("DOCENT_API_KEY")
247
+
248
+ if api_key is None:
249
+ raise ValueError(
250
+ "api_key is required. Please provide an "
251
+ "api_key or set the DOCENT_API_KEY environment variable."
252
+ )
253
+
254
+ sdk = Docent(
255
+ server_url=server_url,
256
+ web_url=web_url,
257
+ api_key=api_key,
258
+ )
259
+
260
+ collection_id = collection_id or sdk.create_collection(name=collection_name)
261
+
262
+ return AgentRunWriter(
263
+ api_key=api_key,
264
+ collection_id=collection_id,
265
+ server_url=server_url,
266
+ )
@@ -20,9 +20,9 @@ class ToolCall:
20
20
  """
21
21
 
22
22
  id: str
23
- type: Literal["function"] | None
24
23
  function: str
25
24
  arguments: dict[str, Any]
25
+ type: Literal["function"] | None = None
26
26
  parse_error: str | None = None
27
27
  view: ToolCallContent | None = None
28
28
 
@@ -3,7 +3,6 @@ import contextvars
3
3
  import itertools
4
4
  import logging
5
5
  import os
6
- import signal
7
6
  import sys
8
7
  import threading
9
8
  import uuid
@@ -158,6 +157,7 @@ class DocentTracer:
158
157
  lambda: itertools.count(0)
159
158
  )
160
159
  self._transcript_counter_lock = threading.Lock()
160
+ self._flush_lock = threading.Lock()
161
161
 
162
162
  def get_current_agent_run_id(self) -> Optional[str]:
163
163
  """
@@ -179,14 +179,6 @@ class DocentTracer:
179
179
  # Register atexit handler
180
180
  atexit.register(self.cleanup)
181
181
 
182
- # Register signal handlers for graceful shutdown
183
- try:
184
- signal.signal(signal.SIGINT, self._signal_handler)
185
- signal.signal(signal.SIGTERM, self._signal_handler)
186
- except (ValueError, OSError):
187
- # Signal handlers might not work in all environments
188
- pass
189
-
190
182
  self._cleanup_registered = True
191
183
 
192
184
  def _next_span_order(self, transcript_id: str) -> int:
@@ -197,10 +189,6 @@ class DocentTracer:
197
189
  with self._transcript_counter_lock:
198
190
  return next(self._transcript_counters[transcript_id])
199
191
 
200
- def _signal_handler(self, signum: int, frame: Optional[object]):
201
- """Handle shutdown signals."""
202
- self.cleanup()
203
-
204
192
  def _init_spans_exporter(self, endpoint: str) -> Optional[Union[HTTPExporter, GRPCExporter]]:
205
193
  """Initialize the appropriate span exporter based on endpoint."""
206
194
  if not self.enable_otlp_export:
@@ -211,9 +199,11 @@ class DocentTracer:
211
199
  http_exporter: HTTPExporter = HTTPExporter(
212
200
  endpoint=f"{endpoint}/v1/traces", headers=self.headers
213
201
  )
202
+ logger.debug(f"Initialized HTTP exporter for endpoint: {endpoint}/v1/traces")
214
203
  return http_exporter
215
204
  else:
216
205
  grpc_exporter: GRPCExporter = GRPCExporter(endpoint=endpoint, headers=self.headers)
206
+ logger.debug(f"Initialized gRPC exporter for endpoint: {endpoint}")
217
207
  return grpc_exporter
218
208
  except Exception as e:
219
209
  logger.error(f"Failed to initialize span exporter for {endpoint}: {e}")
@@ -239,9 +229,11 @@ class DocentTracer:
239
229
  """Create appropriate span processor based on configuration."""
240
230
  if self.disable_batch or _is_notebook():
241
231
  simple_processor: SimpleSpanProcessor = SimpleSpanProcessor(exporter)
232
+ logger.debug("Created SimpleSpanProcessor for immediate export")
242
233
  return simple_processor
243
234
  else:
244
235
  batch_processor: BatchSpanProcessor = BatchSpanProcessor(exporter)
236
+ logger.debug("Created BatchSpanProcessor for batched export")
245
237
  return batch_processor
246
238
 
247
239
  def initialize(self):
@@ -310,8 +302,19 @@ class DocentTracer:
310
302
  # attributes not available, skip them
311
303
  pass
312
304
 
305
+ # Debug logging for span creation
306
+ span_name = getattr(span, "name", "unknown")
307
+ span_attrs = getattr(span, "attributes", {})
308
+ logger.debug(
309
+ f"Created span: name='{span_name}', collection_id={self.manager.collection_id}, agent_run_id={span_attrs.get('agent_run_id')}, transcript_id={span_attrs.get('transcript_id')}"
310
+ )
311
+
313
312
  def on_end(self, span: ReadableSpan) -> None:
314
- pass
313
+ # Debug logging for span completion
314
+ span_attrs = span.attributes or {}
315
+ logger.debug(
316
+ f"Completed span: name='{span.name}', collection_id={span_attrs.get('collection_id')}, agent_run_id={span_attrs.get('agent_run_id')}, transcript_id={span_attrs.get('transcript_id')}, duration_ns={span.end_time - span.start_time if span.end_time and span.start_time else 'unknown'}"
317
+ )
315
318
 
316
319
  def shutdown(self) -> None:
317
320
  pass
@@ -422,15 +425,8 @@ class DocentTracer:
422
425
  return
423
426
 
424
427
  try:
425
- # Notify backend that trace is done (no span creation)
426
- try:
427
- self._send_trace_done()
428
- except Exception as e:
429
- logger.warning(f"Failed to notify trace done: {e}")
430
-
431
- self._root_context = None # type: ignore
428
+ self.flush()
432
429
 
433
- # Shutdown our isolated tracer provider
434
430
  if self._tracer_provider:
435
431
  self._tracer_provider.shutdown()
436
432
  self._tracer_provider = None
@@ -456,9 +452,12 @@ class DocentTracer:
456
452
  return
457
453
 
458
454
  try:
459
- for processor in self._spans_processors:
455
+ logger.debug(f"Flushing {len(self._spans_processors)} span processors")
456
+ for i, processor in enumerate(self._spans_processors):
460
457
  if hasattr(processor, "force_flush"):
461
- processor.force_flush()
458
+ logger.debug(f"Flushing span processor {i}")
459
+ processor.force_flush(timeout_millis=50)
460
+ logger.debug("Span flush completed")
462
461
  except Exception as e:
463
462
  logger.error(f"Error during flush: {e}")
464
463
 
@@ -476,29 +475,6 @@ class DocentTracer:
476
475
  """Verify if the manager is properly initialized."""
477
476
  return self._initialized
478
477
 
479
- def __enter__(self) -> "DocentTracer":
480
- """Context manager entry."""
481
- self.initialize()
482
- return self
483
-
484
- def __exit__(self, exc_type: type[BaseException], exc_val: Any, exc_tb: Any) -> None:
485
- """Context manager exit."""
486
- self.close()
487
-
488
- @property
489
- def tracer(self) -> Optional[trace.Tracer]:
490
- """Get the tracer instance."""
491
- if not self._initialized:
492
- self.initialize()
493
- return self._tracer
494
-
495
- @property
496
- def root_context(self) -> Optional[Context]:
497
- """Get the root context."""
498
- if not self._initialized:
499
- self.initialize()
500
- return self._root_context
501
-
502
478
  @contextmanager
503
479
  def agent_run_context(
504
480
  self,
@@ -617,13 +593,15 @@ class DocentTracer:
617
593
  Get the API headers for HTTP requests.
618
594
 
619
595
  Returns:
620
- Dictionary of headers including Authorization
596
+ Dictionary of headers including Authorization if set
621
597
  """
598
+ headers = {"Content-Type": "application/json"}
622
599
 
623
- return {
624
- "Content-Type": "application/json",
625
- "Authorization": self.headers.get("Authorization", ""),
626
- }
600
+ authorization = self.headers.get("Authorization")
601
+ if authorization:
602
+ headers["Authorization"] = authorization
603
+
604
+ return headers
627
605
 
628
606
  def _post_json(self, path: str, data: Dict[str, Any]) -> None:
629
607
  if not self._api_endpoint_base:
@@ -1157,7 +1135,10 @@ def close_tracing() -> None:
1157
1135
  def flush_tracing() -> None:
1158
1136
  """Force flush all spans to exporters."""
1159
1137
  if _global_tracer:
1138
+ logger.debug("Flushing global tracer")
1160
1139
  _global_tracer.flush()
1140
+ else:
1141
+ logger.debug("No global tracer available to flush")
1161
1142
 
1162
1143
 
1163
1144
  def verify_initialized() -> bool:
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "docent-python"
3
3
  description = "Docent SDK"
4
- version = "0.1.11-alpha"
4
+ version = "0.1.13-alpha"
5
5
  authors = [
6
6
  { name="Transluce", email="info@transluce.org" },
7
7
  ]
@@ -1,3 +0,0 @@
1
- __all__ = ["Docent"]
2
-
3
- from docent.sdk.client import Docent