docent-python 0.1.24a0__py3-none-any.whl → 0.1.28a0__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.

Potentially problematic release.


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

@@ -8,6 +8,7 @@ from pydantic import BaseModel
8
8
  from docent._llm_util.data_models.exceptions import (
9
9
  LLM_ERROR_TYPES,
10
10
  CompletionTooLongException,
11
+ ContextWindowException,
11
12
  LLMException,
12
13
  )
13
14
  from docent._log_util import get_logger
@@ -148,6 +149,13 @@ class LLMOutput:
148
149
  def from_dict(cls, data: dict[str, Any]) -> "LLMOutput":
149
150
  error_type_map = {e.error_type_id: e for e in LLM_ERROR_TYPES}
150
151
  errors = data.get("errors", [])
152
+ error_types_to_not_log: list[str] = [
153
+ CompletionTooLongException.error_type_id,
154
+ ContextWindowException.error_type_id,
155
+ ]
156
+ errors_to_log = [e for e in errors if e not in error_types_to_not_log]
157
+ if errors_to_log:
158
+ logger.error(f"Loading LLM output with errors: {errors}")
151
159
  errors = [error_type_map.get(e, LLMException)() for e in errors]
152
160
 
153
161
  completions = data.get("completions", [])
@@ -75,7 +75,7 @@ async def _parallelize_calls(
75
75
  completion_callback: AsyncLLMOutputStreamingCallback | None,
76
76
  # Arguments for the individual completion getter
77
77
  client: Any,
78
- inputs: list[MessagesInput],
78
+ inputs: Sequence[MessagesInput],
79
79
  model_name: str,
80
80
  tools: list[ToolInfo] | None,
81
81
  tool_choice: Literal["auto", "required"] | None,
@@ -176,7 +176,7 @@ async def _parallelize_calls(
176
176
  )
177
177
  if retry_count >= MAX_VALIDATION_ATTEMPTS:
178
178
  logger.error(
179
- f"Validation failed for {model_name} after {MAX_VALIDATION_ATTEMPTS} attempts: {e}"
179
+ f"Validation failed for {model_name} after {retry_count} attempts. Original output: {e.failed_output}"
180
180
  )
181
181
  result = LLMOutput(
182
182
  model=model_name,
@@ -195,8 +195,8 @@ async def _parallelize_calls(
195
195
  break
196
196
  except Exception as e:
197
197
  if not isinstance(e, LLMException):
198
- logger.warning(
199
- f"LLM call raised an exception that is not an LLMException: {e}"
198
+ logger.error(
199
+ f"LLM call raised an exception that is not an LLMException: {e}. Failure traceback:\n{traceback.format_exc()}"
200
200
  )
201
201
  llm_exception = LLMException(e)
202
202
  llm_exception.__cause__ = e
@@ -306,7 +306,7 @@ async def _parallelize_calls(
306
306
 
307
307
  class BaseLLMService:
308
308
  def __init__(self, max_concurrency: int = DEFAULT_SVC_MAX_CONCURRENCY):
309
- self._semaphore = Semaphore(max_concurrency)
309
+ self.max_concurrency, self._semaphore = max_concurrency, Semaphore(max_concurrency)
310
310
  self._client_cache: dict[tuple[str, str | None], Any] = {} # (provider, api_key) -> client
311
311
  self._client_cache_lock = Lock()
312
312
 
@@ -326,7 +326,7 @@ class BaseLLMService:
326
326
  async def get_completions(
327
327
  self,
328
328
  *,
329
- inputs: list[MessagesInput],
329
+ inputs: Sequence[MessagesInput],
330
330
  model_options: list[ModelOption],
331
331
  tools: list[ToolInfo] | None = None,
332
332
  tool_choice: Literal["auto", "required"] | None = None,
@@ -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-haiku-4-5",
59
+ ModelInfo(rate={"input": 1.0, "output": 5.0}, context_window=200_000),
60
+ ),
57
61
  (
58
62
  "gemini-2.5-flash-lite",
59
63
  ModelInfo(
@@ -178,7 +178,7 @@ def _parse_tool_choice(tool_choice: Literal["auto", "required"] | None) -> ToolC
178
178
 
179
179
  def _convert_anthropic_error(e: Exception):
180
180
  if isinstance(e, BadRequestError):
181
- if "context limit" in e.message.lower():
181
+ if "context limit" in e.message.lower() or "prompt is too long" in e.message.lower():
182
182
  return ContextWindowException()
183
183
  if isinstance(e, RateLimitError):
184
184
  return RateLimitException(e)
@@ -125,6 +125,7 @@ class AgentRun(BaseModel):
125
125
  # )
126
126
 
127
127
  # Append the text field
128
+ result.append({"name": "agent_run_id", "type": "str"})
128
129
  result.append({"name": "text", "type": "str"})
129
130
 
130
131
  return result
docent/judges/runner.py CHANGED
@@ -1,3 +1,5 @@
1
+ from typing import Protocol, Sequence, runtime_checkable
2
+
1
3
  import anyio
2
4
  from tqdm.auto import tqdm
3
5
 
@@ -14,12 +16,28 @@ from docent.judges.impl import build_judge
14
16
  logger = get_logger(__name__)
15
17
 
16
18
 
19
+ @runtime_checkable
20
+ class AgentRunResolver(Protocol):
21
+ async def __call__(self) -> AgentRun | None: ...
22
+
23
+
24
+ AgentRunInput = AgentRun | AgentRunResolver
25
+
26
+
27
+ async def _resolve_agent_run(agent_run_input: AgentRunInput) -> AgentRun | None:
28
+ if isinstance(agent_run_input, AgentRun):
29
+ return agent_run_input
30
+ else:
31
+ return await agent_run_input()
32
+
33
+
17
34
  async def run_rubric(
18
- agent_runs: list[AgentRun],
35
+ agent_runs: Sequence[AgentRunInput],
19
36
  rubric: Rubric,
20
37
  llm_svc: BaseLLMService,
21
38
  callback: JudgeResultCompletionCallback | None = None,
22
39
  *,
40
+ n_rollouts_per_input: int | list[int] = 1,
23
41
  show_progress: bool = True,
24
42
  ) -> list[JudgeResult | None]:
25
43
  if not agent_runs:
@@ -27,26 +45,70 @@ async def run_rubric(
27
45
  if rubric.n_rollouts_per_input <= 0:
28
46
  raise ValueError("rubric.n_rollouts_per_input must be greater than 0")
29
47
 
48
+ # Normalize n_rollouts_per_input to a list
49
+ if isinstance(n_rollouts_per_input, int):
50
+ if n_rollouts_per_input < 0:
51
+ raise ValueError("n_rollouts_per_input must be non-negative")
52
+ rollouts_per_run = [n_rollouts_per_input] * len(agent_runs)
53
+ else:
54
+ rollouts_per_run = n_rollouts_per_input
55
+ if len(rollouts_per_run) != len(agent_runs):
56
+ raise ValueError("n_rollouts_per_input list must match agent_runs length")
57
+ if any(n < 0 for n in rollouts_per_run):
58
+ raise ValueError("All values in n_rollouts_per_input must be non-negative")
59
+
30
60
  judge = build_judge(rubric, llm_svc)
31
61
 
62
+ total_rollouts = sum(rollouts_per_run)
32
63
  logger.info(
33
- "Running rubric %s version %s against %d agent runs",
64
+ "Running rubric %s version %s against %d agent runs with %d total rollouts",
34
65
  rubric.id,
35
66
  rubric.version,
36
67
  len(agent_runs),
68
+ total_rollouts,
37
69
  )
38
70
 
39
- agent_results: list[JudgeResult | None] = [None for _ in agent_runs]
71
+ agent_results: list[list[JudgeResult | None]] = [[] for _ in agent_runs]
40
72
  progress_bar = tqdm(
41
- total=len(agent_runs), desc=f"Rubric {rubric.id}", disable=not show_progress
73
+ total=total_rollouts,
74
+ desc=f"Rubric {rubric.id}",
75
+ disable=not show_progress,
42
76
  )
43
77
 
44
- async def _run_single_judge(index: int, agent_run: AgentRun):
45
- agent_results[index] = result = await judge(agent_run)
78
+ # NOTE(mengk): using a (2 * llm max concurrency) semaphore is a hack to avoid
79
+ # hammering _resolve_agent_run, which makes expensive DB calls, when they aren't going to be
80
+ # immediately processed by the LLMService anyways.
81
+ # TODO(mengk): We should eventually implement a more idiomatic solution to this.
82
+ # It's related to the idea of a global concurrency limiter.
83
+ run_judge_semaphore = anyio.Semaphore(llm_svc.max_concurrency * 2)
84
+
85
+ async def _run_single_judge(index: int, agent_run_input: AgentRunInput):
86
+ async with run_judge_semaphore:
87
+ rollout_results: list[JudgeResult | None] = []
88
+
89
+ if rollouts_per_run[index] == 0:
90
+ agent_results[index] = []
91
+ if callback is not None:
92
+ await callback(index, None)
93
+ return
94
+
95
+ agent_run = await _resolve_agent_run(agent_run_input)
96
+ if agent_run is None:
97
+ if callback is not None:
98
+ await callback(index, None)
99
+ return
100
+
101
+ for _ in range(rollouts_per_run[index]):
102
+ result = await judge(agent_run)
103
+ rollout_results.append(result)
104
+ progress_bar.update()
105
+
106
+ agent_results[index] = rollout_results
46
107
 
47
- if callback is not None:
48
- await callback(index, [result] if result is not None else None)
49
- progress_bar.update()
108
+ if callback is not None:
109
+ # Filter out None results for the callback
110
+ valid_results = [r for r in rollout_results if r is not None]
111
+ await callback(index, valid_results if valid_results else None)
50
112
 
51
113
  try:
52
114
  async with anyio.create_task_group() as tg:
@@ -55,12 +117,13 @@ async def run_rubric(
55
117
  finally:
56
118
  progress_bar.close()
57
119
 
58
- successful = sum(result is not None for result in agent_results)
120
+ flattened_results = [result for rollouts in agent_results for result in rollouts]
121
+ successful = sum(result is not None for result in flattened_results)
59
122
  logger.info(
60
123
  "Finished rubric %s: produced %d/%d judge results",
61
124
  rubric.id,
62
125
  successful,
63
- len(agent_results),
126
+ len(flattened_results),
64
127
  )
65
128
 
66
- return agent_results
129
+ return flattened_results
docent/sdk/client.py CHANGED
@@ -200,7 +200,7 @@ class Docent:
200
200
  version: The version of the rubric to get run state for. If None, the latest version is used.
201
201
 
202
202
  Returns:
203
- dict: Dictionary containing rubric run state with results, job_id, and total_agent_runs.
203
+ dict: Dictionary containing rubric run state with results, job_id, and total_results_needed.
204
204
 
205
205
  Raises:
206
206
  requests.exceptions.HTTPError: If the API request fails.
@@ -450,6 +450,123 @@ class Docent:
450
450
  logger.info(f"Successfully shared Collection '{collection_id}' with {email}")
451
451
  return response.json()
452
452
 
453
+ def collection_exists(self, collection_id: str) -> bool:
454
+ """Check if a collection exists without raising if it does not."""
455
+ url = f"{self._server_url}/{collection_id}/exists"
456
+ response = self._session.get(url)
457
+ self._handle_response_errors(response)
458
+ return bool(response.json())
459
+
460
+ def has_collection_permission(self, collection_id: str, permission: str = "write") -> bool:
461
+ """Check whether the authenticated user has a specific permission on a collection.
462
+
463
+ Args:
464
+ collection_id: Collection to check.
465
+ permission: Permission level to verify (`read`, `write`, or `admin`).
466
+
467
+ Returns:
468
+ bool: True if the current API key has the requested permission; otherwise False.
469
+
470
+ Raises:
471
+ ValueError: If an unsupported permission value is provided.
472
+ requests.exceptions.HTTPError: If the API request fails.
473
+ """
474
+ valid_permissions = {"read", "write", "admin"}
475
+ if permission not in valid_permissions:
476
+ raise ValueError(f"permission must be one of {sorted(valid_permissions)}")
477
+
478
+ url = f"{self._server_url}/{collection_id}/has_permission"
479
+ response = self._session.get(url, params={"permission": permission})
480
+ self._handle_response_errors(response)
481
+
482
+ payload = response.json()
483
+ return bool(payload.get("has_permission", False))
484
+
485
+ def get_dql_schema(self, collection_id: str) -> dict[str, Any]:
486
+ """Retrieve the DQL schema for a collection.
487
+
488
+ Args:
489
+ collection_id: ID of the Collection.
490
+
491
+ Returns:
492
+ dict: Dictionary containing available tables, columns, and metadata for DQL queries.
493
+
494
+ Raises:
495
+ requests.exceptions.HTTPError: If the API request fails.
496
+ """
497
+ url = f"{self._server_url}/dql/{collection_id}/schema"
498
+ response = self._session.get(url)
499
+ self._handle_response_errors(response)
500
+ return response.json()
501
+
502
+ def execute_dql(self, collection_id: str, dql: str) -> dict[str, Any]:
503
+ """Execute a DQL query against a collection.
504
+
505
+ Args:
506
+ collection_id: ID of the Collection.
507
+ dql: The DQL query string to execute.
508
+
509
+ Returns:
510
+ dict: Query execution results including rows, columns, execution metadata, and selected columns.
511
+
512
+ Raises:
513
+ ValueError: If `dql` is empty.
514
+ requests.exceptions.HTTPError: If the API request fails or the query is invalid.
515
+ """
516
+ if not dql.strip():
517
+ raise ValueError("dql must be a non-empty string")
518
+
519
+ url = f"{self._server_url}/dql/{collection_id}/execute"
520
+ response = self._session.post(url, json={"dql": dql})
521
+ self._handle_response_errors(response)
522
+ return response.json()
523
+
524
+ def select_agent_run_ids(
525
+ self,
526
+ collection_id: str,
527
+ where_clause: str | None = None,
528
+ limit: int | None = None,
529
+ ) -> list[str]:
530
+ """Convenience helper to fetch agent run IDs via DQL.
531
+
532
+ Args:
533
+ collection_id: ID of the Collection to query.
534
+ where_clause: Optional DQL WHERE clause applied to the agent_runs table.
535
+ limit: Optional LIMIT applied to the underlying DQL query.
536
+
537
+ Returns:
538
+ list[str]: Agent run IDs matching the criteria.
539
+
540
+ Raises:
541
+ ValueError: If the inputs are invalid.
542
+ requests.exceptions.HTTPError: If the API request fails.
543
+ """
544
+ query = "SELECT agent_runs.id AS agent_run_id FROM agent_runs"
545
+
546
+ if where_clause:
547
+ where_clause = where_clause.strip()
548
+ if not where_clause:
549
+ raise ValueError("where_clause must be a non-empty string when provided")
550
+ query += f" WHERE {where_clause}"
551
+
552
+ if limit is not None:
553
+ if limit <= 0:
554
+ raise ValueError("limit must be a positive integer when provided")
555
+ query += f" LIMIT {limit}"
556
+
557
+ result = self.execute_dql(collection_id, query)
558
+ rows = result.get("rows", [])
559
+ agent_run_ids = [str(row[0]) for row in rows if row]
560
+
561
+ if result.get("truncated"):
562
+ logger.warning(
563
+ "DQL query truncated at applied limit %s; returning %s agent run IDs",
564
+ result.get("applied_limit"),
565
+ len(agent_run_ids),
566
+ )
567
+
568
+ return agent_run_ids
569
+
453
570
  def list_agent_run_ids(self, collection_id: str) -> list[str]:
454
571
  """Get all agent run IDs for a collection.
455
572
 
docent/trace.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import atexit
2
2
  import contextvars
3
3
  import itertools
4
+ import json
4
5
  import logging
5
6
  import os
6
7
  import sys
@@ -12,7 +13,19 @@ from contextvars import ContextVar, Token
12
13
  from datetime import datetime, timezone
13
14
  from enum import Enum
14
15
  from importlib.metadata import Distribution, distributions
15
- from typing import Any, AsyncIterator, Callable, Dict, Iterator, List, Optional, Set, Union
16
+ from typing import (
17
+ Any,
18
+ AsyncIterator,
19
+ Callable,
20
+ Dict,
21
+ Iterator,
22
+ List,
23
+ Mapping,
24
+ Optional,
25
+ Set,
26
+ Union,
27
+ cast,
28
+ )
16
29
 
17
30
  import requests
18
31
  from opentelemetry import trace
@@ -28,12 +41,23 @@ from opentelemetry.sdk.trace.export import (
28
41
  SimpleSpanProcessor,
29
42
  )
30
43
  from opentelemetry.trace import Span
44
+ from requests import Response
31
45
 
32
46
  logger = logging.getLogger(__name__)
33
47
 
34
48
  # Default configuration
35
49
  DEFAULT_ENDPOINT = "https://api.docent.transluce.org/rest/telemetry"
36
50
  DEFAULT_COLLECTION_NAME = "default-collection-name"
51
+ ERROR_DETAIL_MAX_CHARS = 500
52
+
53
+ # Sentinel values for when tracing is disabled
54
+ DISABLED_AGENT_RUN_ID = "disabled"
55
+ DISABLED_TRANSCRIPT_ID = "disabled"
56
+ DISABLED_TRANSCRIPT_GROUP_ID = "disabled"
57
+
58
+
59
+ class DocentTelemetryRequestError(RuntimeError):
60
+ """Raised when the Docent telemetry backend rejects a client request."""
37
61
 
38
62
 
39
63
  class Instruments(Enum):
@@ -129,6 +153,8 @@ class DocentTracer:
129
153
  lambda: itertools.count(0)
130
154
  )
131
155
  self._transcript_counter_lock = threading.Lock()
156
+ self._transcript_group_states: dict[str, dict[str, Optional[str]]] = {}
157
+ self._transcript_group_state_lock = threading.Lock()
132
158
  self._flush_lock = threading.Lock()
133
159
 
134
160
  def get_current_agent_run_id(self) -> Optional[str]:
@@ -487,6 +513,24 @@ class DocentTracer:
487
513
  """Verify if the manager is properly initialized."""
488
514
  return self._initialized
489
515
 
516
+ def get_disabled_agent_run_id(self, agent_run_id: Optional[str]) -> str:
517
+ """Return sentinel value for agent run ID when tracing is disabled."""
518
+ if agent_run_id is None:
519
+ return DISABLED_AGENT_RUN_ID
520
+ return agent_run_id
521
+
522
+ def get_disabled_transcript_id(self, transcript_id: Optional[str]) -> str:
523
+ """Return sentinel value for transcript ID when tracing is disabled."""
524
+ if transcript_id is None:
525
+ return DISABLED_TRANSCRIPT_ID
526
+ return transcript_id
527
+
528
+ def get_disabled_transcript_group_id(self, transcript_group_id: Optional[str]) -> str:
529
+ """Return sentinel value for transcript group ID when tracing is disabled."""
530
+ if transcript_group_id is None:
531
+ return DISABLED_TRANSCRIPT_GROUP_ID
532
+ return transcript_group_id
533
+
490
534
  @contextmanager
491
535
  def agent_run_context(
492
536
  self,
@@ -508,11 +552,8 @@ class DocentTracer:
508
552
  Tuple of (agent_run_id, transcript_id)
509
553
  """
510
554
  if self._disabled:
511
- # Return dummy IDs when tracing is disabled
512
- if agent_run_id is None:
513
- agent_run_id = str(uuid.uuid4())
514
- if transcript_id is None:
515
- transcript_id = str(uuid.uuid4())
555
+ agent_run_id = self.get_disabled_agent_run_id(agent_run_id)
556
+ transcript_id = self.get_disabled_transcript_id(transcript_id)
516
557
  yield agent_run_id, transcript_id
517
558
  return
518
559
 
@@ -535,7 +576,7 @@ class DocentTracer:
535
576
  try:
536
577
  self.send_agent_run_metadata(agent_run_id, metadata)
537
578
  except Exception as e:
538
- logger.warning(f"Failed sending agent run metadata: {e}")
579
+ logger.error(f"Failed sending agent run metadata: {e}")
539
580
 
540
581
  yield agent_run_id, transcript_id
541
582
  finally:
@@ -565,11 +606,8 @@ class DocentTracer:
565
606
  Tuple of (agent_run_id, transcript_id)
566
607
  """
567
608
  if self._disabled:
568
- # Return dummy IDs when tracing is disabled
569
- if agent_run_id is None:
570
- agent_run_id = str(uuid.uuid4())
571
- if transcript_id is None:
572
- transcript_id = str(uuid.uuid4())
609
+ agent_run_id = self.get_disabled_agent_run_id(agent_run_id)
610
+ transcript_id = self.get_disabled_transcript_id(transcript_id)
573
611
  yield agent_run_id, transcript_id
574
612
  return
575
613
 
@@ -615,15 +653,184 @@ class DocentTracer:
615
653
 
616
654
  return headers
617
655
 
656
+ def _ensure_json_serializable_metadata(self, metadata: Dict[str, Any], context: str) -> None:
657
+ """
658
+ Validate that metadata can be serialized to JSON before sending it to the backend.
659
+ """
660
+ try:
661
+ json.dumps(metadata)
662
+ except (TypeError, ValueError) as exc:
663
+ raise TypeError(f"{context} metadata must be JSON serializable") from exc
664
+ offending_path = self._find_null_character_path(metadata)
665
+ if offending_path is not None:
666
+ raise ValueError(
667
+ f"{context} metadata cannot contain null characters (found at {offending_path}). "
668
+ "Remove or replace '\\u0000' before calling Docent tracing APIs."
669
+ )
670
+
618
671
  def _post_json(self, path: str, data: Dict[str, Any]) -> None:
672
+ self._post_json_sync(path, data)
673
+
674
+ def _post_json_sync(self, path: str, data: Dict[str, Any]) -> None:
619
675
  if not self._api_endpoint_base:
620
676
  raise RuntimeError("API endpoint base is not configured")
621
677
  url = f"{self._api_endpoint_base}{path}"
622
678
  try:
623
679
  resp = requests.post(url, json=data, headers=self._api_headers(), timeout=(10, 60))
624
680
  resp.raise_for_status()
625
- except requests.exceptions.RequestException as e:
626
- logger.error(f"Failed POST {url}: {e}")
681
+ except requests.exceptions.RequestException as exc:
682
+ message = self._format_request_exception(url, exc)
683
+ raise DocentTelemetryRequestError(message) from exc
684
+
685
+ def _format_request_exception(self, url: str, exc: requests.exceptions.RequestException) -> str:
686
+ response: Optional[Response] = getattr(exc, "response", None)
687
+ message_parts: List[str] = [f"Failed POST {url}"]
688
+ suggestion: Optional[str]
689
+
690
+ if response is not None:
691
+ status_phrase = f"HTTP {response.status_code}"
692
+ if response.reason:
693
+ status_phrase = f"{status_phrase} {response.reason}"
694
+ message_parts.append(f"({status_phrase})")
695
+
696
+ detail = self._extract_response_detail(response)
697
+ if detail:
698
+ message_parts.append(f"- Backend detail: {detail}")
699
+
700
+ request_id = response.headers.get("x-request-id")
701
+ if request_id:
702
+ message_parts.append(f"(request-id: {request_id})")
703
+
704
+ suggestion = self._suggest_fix_for_status(response.status_code)
705
+ else:
706
+ message_parts.append(f"- {exc}")
707
+ suggestion = self._suggest_fix_for_status(None)
708
+
709
+ if suggestion:
710
+ message_parts.append(suggestion)
711
+
712
+ return " ".join(part for part in message_parts if part)
713
+
714
+ def _extract_response_detail(self, response: Response) -> Optional[str]:
715
+ try:
716
+ body = response.json()
717
+ except ValueError:
718
+ text = response.text.strip()
719
+ if not text:
720
+ return None
721
+ normalized = " ".join(text.split())
722
+ return self._truncate_error_message(normalized)
723
+
724
+ if isinstance(body, dict):
725
+ typed_body = cast(Dict[str, Any], body)
726
+ structured_message = self._structured_detail_message(typed_body)
727
+ if structured_message:
728
+ return self._truncate_error_message(structured_message)
729
+ return self._truncate_error_message(self._normalize_error_value(typed_body))
730
+
731
+ return self._truncate_error_message(self._normalize_error_value(body))
732
+
733
+ def _structured_detail_message(self, data: Dict[str, Any]) -> Optional[str]:
734
+ for key in ("detail", "message", "error"):
735
+ if key in data:
736
+ structured_value = self._structured_detail_value(data[key])
737
+ if structured_value:
738
+ return structured_value
739
+ return self._structured_detail_value(data)
740
+
741
+ def _structured_detail_value(self, value: Any) -> Optional[str]:
742
+ if isinstance(value, Mapping):
743
+ mapping_value = cast(Mapping[str, Any], value)
744
+ message = mapping_value.get("message")
745
+ hint = mapping_value.get("hint")
746
+ error_code = mapping_value.get("error_code")
747
+ request_id = mapping_value.get("request_id")
748
+ fallback_detail = mapping_value.get("detail")
749
+
750
+ parts: List[str] = []
751
+ if isinstance(message, str) and message.strip():
752
+ parts.append(message.strip())
753
+ elif isinstance(fallback_detail, str) and fallback_detail.strip():
754
+ parts.append(fallback_detail.strip())
755
+
756
+ if isinstance(hint, str) and hint.strip():
757
+ parts.append(f"(hint: {hint.strip()})")
758
+ if isinstance(error_code, str) and error_code.strip():
759
+ parts.append(f"[code: {error_code.strip()}]")
760
+ if isinstance(request_id, str) and request_id.strip():
761
+ parts.append(f"(request-id: {request_id.strip()})")
762
+
763
+ return " ".join(parts) if parts else None
764
+
765
+ if isinstance(value, str) and value.strip():
766
+ return value.strip()
767
+
768
+ return None
769
+
770
+ def _normalize_error_value(self, value: Any) -> str:
771
+ if isinstance(value, str):
772
+ return " ".join(value.split())
773
+
774
+ try:
775
+ serialized = json.dumps(value)
776
+ except (TypeError, ValueError):
777
+ serialized = str(value)
778
+
779
+ return " ".join(serialized.split())
780
+
781
+ def _truncate_error_message(self, message: str) -> str:
782
+ message = message.strip()
783
+ if len(message) <= ERROR_DETAIL_MAX_CHARS:
784
+ return message
785
+ return f"{message[:ERROR_DETAIL_MAX_CHARS]}..."
786
+
787
+ def _suggest_fix_for_status(self, status_code: Optional[int]) -> Optional[str]:
788
+ if status_code in (401, 403):
789
+ return (
790
+ "Verify that the Authorization header or DOCENT_API_KEY grants write access to the "
791
+ "target collection."
792
+ )
793
+ if status_code == 404:
794
+ return (
795
+ "Ensure the tracing endpoint passed to initialize_tracing matches the Docent server's "
796
+ "/rest/telemetry route."
797
+ )
798
+ if status_code in (400, 422):
799
+ return (
800
+ "Confirm the payload includes collection_id, agent_run_id, metadata, and timestamp in "
801
+ "the expected format."
802
+ )
803
+ if status_code and status_code >= 500:
804
+ return "Inspect the Docent backend logs for the referenced request."
805
+ if status_code is None:
806
+ return "Confirm the Docent telemetry endpoint is reachable from this process."
807
+ return None
808
+
809
+ def _find_null_character_path(self, value: Any, path: str = "") -> Optional[str]:
810
+ """Backend rejects NUL bytes, so detect them before we send metadata to the backend."""
811
+ return None
812
+ if isinstance(value, str):
813
+ if "\x00" in value or "\\u0000" in value or "\\x00" in value:
814
+ return path or "<root>"
815
+ return None
816
+
817
+ if isinstance(value, dict):
818
+ for key, item in value.items():
819
+ next_path = f"{path}.{key}" if path else str(key)
820
+ result = self._find_null_character_path(item, next_path)
821
+ if result:
822
+ return result
823
+ return None
824
+
825
+ if isinstance(value, (list, tuple)):
826
+ for index, item in enumerate(value):
827
+ next_path = f"{path}[{index}]" if path else f"[{index}]"
828
+ result = self._find_null_character_path(item, next_path)
829
+ if result:
830
+ return result
831
+ return None
832
+
833
+ return None
627
834
 
628
835
  def send_agent_run_score(
629
836
  self,
@@ -660,6 +867,8 @@ class DocentTracer:
660
867
  if self._disabled:
661
868
  return
662
869
 
870
+ self._ensure_json_serializable_metadata(metadata, "Agent run")
871
+
663
872
  collection_id = self.collection_id
664
873
  payload: Dict[str, Any] = {
665
874
  "collection_id": collection_id,
@@ -705,6 +914,7 @@ class DocentTracer:
705
914
  if transcript_group_id is not None:
706
915
  payload["transcript_group_id"] = transcript_group_id
707
916
  if metadata is not None:
917
+ self._ensure_json_serializable_metadata(metadata, "Transcript")
708
918
  payload["metadata"] = metadata
709
919
 
710
920
  self._post_json("/v1/transcript-metadata", payload)
@@ -756,9 +966,7 @@ class DocentTracer:
756
966
  The transcript ID
757
967
  """
758
968
  if self._disabled:
759
- # Return dummy ID when tracing is disabled
760
- if transcript_id is None:
761
- transcript_id = str(uuid.uuid4())
969
+ transcript_id = self.get_disabled_transcript_id(transcript_id)
762
970
  yield transcript_id
763
971
  return
764
972
 
@@ -788,7 +996,7 @@ class DocentTracer:
788
996
  transcript_id, name, description, transcript_group_id, metadata
789
997
  )
790
998
  except Exception as e:
791
- logger.warning(f"Failed sending transcript data: {e}")
999
+ logger.error(f"Failed sending transcript data: {e}")
792
1000
 
793
1001
  yield transcript_id
794
1002
  finally:
@@ -818,9 +1026,7 @@ class DocentTracer:
818
1026
  The transcript ID
819
1027
  """
820
1028
  if self._disabled:
821
- # Return dummy ID when tracing is disabled
822
- if transcript_id is None:
823
- transcript_id = str(uuid.uuid4())
1029
+ transcript_id = self.get_disabled_transcript_id(transcript_id)
824
1030
  yield transcript_id
825
1031
  return
826
1032
 
@@ -850,7 +1056,7 @@ class DocentTracer:
850
1056
  transcript_id, name, description, transcript_group_id, metadata
851
1057
  )
852
1058
  except Exception as e:
853
- logger.warning(f"Failed sending transcript data: {e}")
1059
+ logger.error(f"Failed sending transcript data: {e}")
854
1060
 
855
1061
  yield transcript_id
856
1062
  finally:
@@ -888,6 +1094,27 @@ class DocentTracer:
888
1094
  )
889
1095
  return
890
1096
 
1097
+ with self._transcript_group_state_lock:
1098
+ state: dict[str, Optional[str]] = self._transcript_group_states.setdefault(
1099
+ transcript_group_id, {}
1100
+ )
1101
+ final_name: Optional[str] = name if name is not None else state.get("name")
1102
+ final_description: Optional[str] = (
1103
+ description if description is not None else state.get("description")
1104
+ )
1105
+ final_parent_transcript_group_id: Optional[str] = (
1106
+ parent_transcript_group_id
1107
+ if parent_transcript_group_id is not None
1108
+ else state.get("parent_transcript_group_id")
1109
+ )
1110
+
1111
+ if final_name is not None:
1112
+ state["name"] = final_name
1113
+ if final_description is not None:
1114
+ state["description"] = final_description
1115
+ if final_parent_transcript_group_id is not None:
1116
+ state["parent_transcript_group_id"] = final_parent_transcript_group_id
1117
+
891
1118
  payload: Dict[str, Any] = {
892
1119
  "collection_id": collection_id,
893
1120
  "transcript_group_id": transcript_group_id,
@@ -895,13 +1122,14 @@ class DocentTracer:
895
1122
  "timestamp": datetime.now(timezone.utc).isoformat(),
896
1123
  }
897
1124
 
898
- if name is not None:
899
- payload["name"] = name
900
- if description is not None:
901
- payload["description"] = description
902
- if parent_transcript_group_id is not None:
903
- payload["parent_transcript_group_id"] = parent_transcript_group_id
1125
+ if final_name is not None:
1126
+ payload["name"] = final_name
1127
+ if final_description is not None:
1128
+ payload["description"] = final_description
1129
+ if final_parent_transcript_group_id is not None:
1130
+ payload["parent_transcript_group_id"] = final_parent_transcript_group_id
904
1131
  if metadata is not None:
1132
+ self._ensure_json_serializable_metadata(metadata, "Transcript group")
905
1133
  payload["metadata"] = metadata
906
1134
 
907
1135
  self._post_json("/v1/transcript-group-metadata", payload)
@@ -929,9 +1157,7 @@ class DocentTracer:
929
1157
  The transcript group ID
930
1158
  """
931
1159
  if self._disabled:
932
- # Return dummy ID when tracing is disabled
933
- if transcript_group_id is None:
934
- transcript_group_id = str(uuid.uuid4())
1160
+ transcript_group_id = self.get_disabled_transcript_group_id(transcript_group_id)
935
1161
  yield transcript_group_id
936
1162
  return
937
1163
 
@@ -963,7 +1189,7 @@ class DocentTracer:
963
1189
  transcript_group_id, name, description, parent_transcript_group_id, metadata
964
1190
  )
965
1191
  except Exception as e:
966
- logger.warning(f"Failed sending transcript group data: {e}")
1192
+ logger.error(f"Failed sending transcript group data: {e}")
967
1193
 
968
1194
  yield transcript_group_id
969
1195
  finally:
@@ -993,9 +1219,7 @@ class DocentTracer:
993
1219
  The transcript group ID
994
1220
  """
995
1221
  if self._disabled:
996
- # Return dummy ID when tracing is disabled
997
- if transcript_group_id is None:
998
- transcript_group_id = str(uuid.uuid4())
1222
+ transcript_group_id = self.get_disabled_transcript_group_id(transcript_group_id)
999
1223
  yield transcript_group_id
1000
1224
  return
1001
1225
 
@@ -1027,7 +1251,7 @@ class DocentTracer:
1027
1251
  transcript_group_id, name, description, parent_transcript_group_id, metadata
1028
1252
  )
1029
1253
  except Exception as e:
1030
- logger.warning(f"Failed sending transcript group data: {e}")
1254
+ logger.error(f"Failed sending transcript group data: {e}")
1031
1255
 
1032
1256
  yield transcript_group_id
1033
1257
  finally:
@@ -1231,28 +1455,33 @@ def agent_run_metadata(metadata: Dict[str, Any]) -> None:
1231
1455
 
1232
1456
  tracer.send_agent_run_metadata(agent_run_id, metadata)
1233
1457
  except Exception as e:
1234
- logger.error(f"Failed to send metadata: {e}")
1458
+ logger.error(f"Failed to send agent run metadata: {e}")
1235
1459
 
1236
1460
 
1237
1461
  def transcript_metadata(
1462
+ metadata: Dict[str, Any],
1463
+ *,
1238
1464
  name: Optional[str] = None,
1239
1465
  description: Optional[str] = None,
1240
1466
  transcript_group_id: Optional[str] = None,
1241
- metadata: Optional[Dict[str, Any]] = None,
1242
1467
  ) -> None:
1243
1468
  """
1244
1469
  Send transcript metadata directly to the backend for the current transcript.
1245
1470
 
1246
1471
  Args:
1472
+ metadata: Dictionary of metadata to attach to the current transcript (required)
1247
1473
  name: Optional transcript name
1248
1474
  description: Optional transcript description
1249
- parent_transcript_id: Optional parent transcript ID
1250
- metadata: Optional metadata to send
1475
+ transcript_group_id: Optional transcript group ID to associate with
1251
1476
 
1252
1477
  Example:
1253
- transcript_metadata(name="data_processing", description="Process user data")
1254
- transcript_metadata(metadata={"user": "John", "model": "gpt-4"})
1255
- transcript_metadata(name="validation", parent_transcript_id="parent-123")
1478
+ transcript_metadata({"user": "John", "model": "gpt-4"})
1479
+ transcript_metadata({"env": "prod"}, name="data_processing")
1480
+ transcript_metadata(
1481
+ {"team": "search"},
1482
+ name="validation",
1483
+ transcript_group_id="group-123",
1484
+ )
1256
1485
  """
1257
1486
  try:
1258
1487
  tracer = get_tracer()
@@ -1271,23 +1500,29 @@ def transcript_metadata(
1271
1500
 
1272
1501
 
1273
1502
  def transcript_group_metadata(
1503
+ metadata: Dict[str, Any],
1504
+ *,
1274
1505
  name: Optional[str] = None,
1275
1506
  description: Optional[str] = None,
1276
1507
  parent_transcript_group_id: Optional[str] = None,
1277
- metadata: Optional[Dict[str, Any]] = None,
1278
1508
  ) -> None:
1279
1509
  """
1280
1510
  Send transcript group metadata directly to the backend for the current transcript group.
1281
1511
 
1282
1512
  Args:
1513
+ metadata: Dictionary of metadata to attach to the current transcript group (required)
1283
1514
  name: Optional transcript group name
1284
1515
  description: Optional transcript group description
1285
1516
  parent_transcript_group_id: Optional parent transcript group ID
1286
- metadata: Optional metadata to send
1287
1517
 
1288
1518
  Example:
1289
- transcript_group_metadata(name="pipeline", description="Main processing pipeline")
1290
- transcript_group_metadata(metadata={"team": "search", "env": "prod"})
1519
+ transcript_group_metadata({"team": "search", "env": "prod"})
1520
+ transcript_group_metadata({"env": "prod"}, name="pipeline")
1521
+ transcript_group_metadata(
1522
+ {"team": "search"},
1523
+ name="pipeline",
1524
+ parent_transcript_group_id="root-group",
1525
+ )
1291
1526
  """
1292
1527
  try:
1293
1528
  tracer = get_tracer()
@@ -1324,6 +1559,11 @@ class AgentRunContext:
1324
1559
 
1325
1560
  def __enter__(self) -> tuple[str, str]:
1326
1561
  """Sync context manager entry."""
1562
+ if is_disabled():
1563
+ tracer = get_tracer()
1564
+ self.agent_run_id = tracer.get_disabled_agent_run_id(self.agent_run_id)
1565
+ self.transcript_id = tracer.get_disabled_transcript_id(self.transcript_id)
1566
+ return self.agent_run_id, self.transcript_id
1327
1567
  self._sync_context = get_tracer().agent_run_context(
1328
1568
  self.agent_run_id, self.transcript_id, metadata=self.metadata, **self.attributes
1329
1569
  )
@@ -1336,6 +1576,11 @@ class AgentRunContext:
1336
1576
 
1337
1577
  async def __aenter__(self) -> tuple[str, str]:
1338
1578
  """Async context manager entry."""
1579
+ if is_disabled():
1580
+ tracer = get_tracer()
1581
+ self.agent_run_id = tracer.get_disabled_agent_run_id(self.agent_run_id)
1582
+ self.transcript_id = tracer.get_disabled_transcript_id(self.transcript_id)
1583
+ return self.agent_run_id, self.transcript_id
1339
1584
  self._async_context = get_tracer().async_agent_run_context(
1340
1585
  self.agent_run_id, self.transcript_id, metadata=self.metadata, **self.attributes
1341
1586
  )
@@ -1476,6 +1721,10 @@ class TranscriptContext:
1476
1721
 
1477
1722
  def __enter__(self) -> str:
1478
1723
  """Sync context manager entry."""
1724
+ if is_disabled():
1725
+ tracer = get_tracer()
1726
+ self.transcript_id = tracer.get_disabled_transcript_id(self.transcript_id)
1727
+ return self.transcript_id
1479
1728
  self._sync_context = get_tracer().transcript_context(
1480
1729
  name=self.name,
1481
1730
  transcript_id=self.transcript_id,
@@ -1492,6 +1741,10 @@ class TranscriptContext:
1492
1741
 
1493
1742
  async def __aenter__(self) -> str:
1494
1743
  """Async context manager entry."""
1744
+ if is_disabled():
1745
+ tracer = get_tracer()
1746
+ self.transcript_id = tracer.get_disabled_transcript_id(self.transcript_id)
1747
+ return self.transcript_id
1495
1748
  self._async_context = get_tracer().async_transcript_context(
1496
1749
  name=self.name,
1497
1750
  transcript_id=self.transcript_id,
@@ -1653,6 +1906,12 @@ class TranscriptGroupContext:
1653
1906
 
1654
1907
  def __enter__(self) -> str:
1655
1908
  """Sync context manager entry."""
1909
+ if is_disabled():
1910
+ tracer = get_tracer()
1911
+ self.transcript_group_id = tracer.get_disabled_transcript_group_id(
1912
+ self.transcript_group_id
1913
+ )
1914
+ return self.transcript_group_id
1656
1915
  self._sync_context = get_tracer().transcript_group_context(
1657
1916
  name=self.name,
1658
1917
  transcript_group_id=self.transcript_group_id,
@@ -1669,6 +1928,12 @@ class TranscriptGroupContext:
1669
1928
 
1670
1929
  async def __aenter__(self) -> str:
1671
1930
  """Async context manager entry."""
1931
+ if is_disabled():
1932
+ tracer = get_tracer()
1933
+ self.transcript_group_id = tracer.get_disabled_transcript_group_id(
1934
+ self.transcript_group_id
1935
+ )
1936
+ return self.transcript_group_id
1672
1937
  self._async_context = get_tracer().async_transcript_group_context(
1673
1938
  name=self.name,
1674
1939
  transcript_group_id=self.transcript_group_id,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docent-python
3
- Version: 0.1.24a0
3
+ Version: 0.1.28a0
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
@@ -1,16 +1,16 @@
1
1
  docent/__init__.py,sha256=fuhETwJPcesiB76Zxa64HBJxeaaTyRalIH-fs77TWsU,112
2
2
  docent/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- docent/trace.py,sha256=PXMvXxtnYsd4xDxAX30SUZ32OoMuMrTpLbfq8f_QVmo,68565
3
+ docent/trace.py,sha256=J05K9MykKGkeBjh9idTOPtiMA5_h0AdL8zRR-yKu5Yg,79525
4
4
  docent/trace_temp.py,sha256=Z0lAPwVzXjFvxpiU-CuvfWIslq9Q4alNkZMoQ77Xudk,40711
5
5
  docent/_llm_util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  docent/_llm_util/llm_cache.py,sha256=nGrvfFikFbEnfmzZRvWvZ60gfVSTvW1iC8-ciCXwbAk,6430
7
- docent/_llm_util/llm_svc.py,sha256=PQ-96UDJrnPa9csTKL_JDO8jzOrLzysVBqUHywuij0w,18046
8
- docent/_llm_util/model_registry.py,sha256=8Y4VwrA2f2EX78cG1VBIBHVvT_p4qqBTdu9a9zJpfTo,3382
7
+ docent/_llm_util/llm_svc.py,sha256=LqrI8DdhqOmkcz3tsyzSlhrJv2gA4-0DE105WLys6sw,18156
8
+ docent/_llm_util/model_registry.py,sha256=CdOi4g3eZCBQjLQDNQtprXpby0Ldc6AIRvLAD6Ajc90,3502
9
9
  docent/_llm_util/data_models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  docent/_llm_util/data_models/exceptions.py,sha256=IW4BVMVp8r5TufNXyrhy3acgwJiQQQPQjB9VA4RVXw8,1489
11
- docent/_llm_util/data_models/llm_output.py,sha256=ZAIIcgfxMZtTft8bXTPAhUcXEO48GLG3epkul_4gQNQ,10239
11
+ docent/_llm_util/data_models/llm_output.py,sha256=UCYewoXN72skigN_fm414TzQol1KxmVbQGwgGVROE_4,10602
12
12
  docent/_llm_util/providers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- docent/_llm_util/providers/anthropic.py,sha256=-1oPd5FB4aFwKSmNvXzG8PVewjhgsogLRX1SCpnCxoA,18720
13
+ docent/_llm_util/providers/anthropic.py,sha256=M5ryu_lGKZ3PDJLSCV07zsiAvEeAyEgZ13rbzOeutS8,18765
14
14
  docent/_llm_util/providers/common.py,sha256=dgcTuU4XkCKoAaM48UW8zMgRYUzj7TDBhvWqtnxBO7g,1166
15
15
  docent/_llm_util/providers/google.py,sha256=2D9mDgenZW0pt0_V7koX-aoZzpl8jo8xE5EWOLK7I0k,20314
16
16
  docent/_llm_util/providers/openai.py,sha256=4niQV9CNaJ-iiEwYG0BSFxCwcsCAWZz0JuUs4wBKu9M,25904
@@ -21,7 +21,7 @@ docent/_log_util/__init__.py,sha256=3HXXrxrSm8PxwG4llotrCnSnp7GuroK1FNHsdg6f7aE,
21
21
  docent/_log_util/logger.py,sha256=kwM0yRW1IJd6-XTorjWn48B4l8qvD2ZM6VDjY5eskQI,4422
22
22
  docent/data_models/__init__.py,sha256=vEcFppE6wtKFp37KF_hUv00Ncn6fK_qUbVGZE5ltz-o,383
23
23
  docent/data_models/_tiktoken_util.py,sha256=hC0EDDWItv5-0cONBnHWgZtQOflDU7ZNEhXPFo4DvPc,3057
24
- docent/data_models/agent_run.py,sha256=7_37I9aS9rhDTkAvMPwoJGssQldvvKte8qVb93EnAiY,19329
24
+ docent/data_models/agent_run.py,sha256=D9KVGVChm2q4B_cruVYtQH-5Xk31ZxTYhoZn6RGrc_o,19392
25
25
  docent/data_models/citation.py,sha256=2_M1-_olVOJtjCGGFx1GIwGYWl0ILHxRsW8-EFDS9j0,7844
26
26
  docent/data_models/judge.py,sha256=BOKAfZmNoLPclJNz_b7NvH8G8FzfR7kc6OpIv91GMDQ,336
27
27
  docent/data_models/metadata_util.py,sha256=E-EClAP5vVm9xbfTlPSz0tUyCalOfN9Jujd6JGoRnBg,487
@@ -37,7 +37,7 @@ docent/data_models/chat/tool.py,sha256=MMglNHzkwHqUoK0xDWqs2FtelPsgHqwVpGpI1F8KZ
37
37
  docent/judges/__init__.py,sha256=aTsQ2mIQnZt8HEMau02KrEA4m5w-lGC3U9Dirkj3to4,500
38
38
  docent/judges/analysis.py,sha256=bn7XIT7mj77LjFHMh1PqjALknq3nN-fRXqgg8cfJF8o,2486
39
39
  docent/judges/impl.py,sha256=JOq2tEBTqNbWIG2gRuI8OmEW2dHdx7nfnJnHeGwdyOk,24035
40
- docent/judges/runner.py,sha256=ANUVrrfgT61_zTV9pErLXoerMiD6x_RIJQGpwxWIIMg,1928
40
+ docent/judges/runner.py,sha256=k1OyEPEhAUiRiJpOAwbaAqsPHsKfseD7URXGqhVI974,4496
41
41
  docent/judges/stats.py,sha256=zejJle583xHG2G3gcYHiWcHoIOkeKwpSkl8lfeKQhFs,7805
42
42
  docent/judges/types.py,sha256=goNaKs3PF5wMHWLnFerYCEjUjPR0IVI9cVrxCK2TfjI,11539
43
43
  docent/judges/util/forgiving_json.py,sha256=zSh0LF3UVHdSjuMNvEiqUmSxpxPaqK1rSLiI6KCNihg,3549
@@ -52,8 +52,8 @@ docent/samples/log.eval,sha256=orrW__9WBfANq7NwKsPSq9oTsQRcG6KohG5tMr_X_XY,39770
52
52
  docent/samples/tb_airline.json,sha256=eR2jFFRtOw06xqbEglh6-dPewjifOk-cuxJq67Dtu5I,47028
53
53
  docent/sdk/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
54
54
  docent/sdk/agent_run_writer.py,sha256=0AWdxejoqZyuj9JSA39WlEwGcMSYTWNqnzIuluySY-M,11043
55
- docent/sdk/client.py,sha256=aB_ILmzzK9JAC2kobtnp50stfINpSfNh54siaDlMEKc,19880
56
- docent_python-0.1.24a0.dist-info/METADATA,sha256=jTg2sD4AXMPXBpXJOGcwvE2GsJ9oO6zDp6g1UJhPqk0,1351
57
- docent_python-0.1.24a0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
58
- docent_python-0.1.24a0.dist-info/licenses/LICENSE.md,sha256=QIMv2UiT6MppRasso4ymaA0w7ltkqmlL0HCt8CLD7Rc,580
59
- docent_python-0.1.24a0.dist-info/RECORD,,
55
+ docent/sdk/client.py,sha256=BeW9nMlCVOyLN8o7S81ePX0ngFrmzJHMxa8YbundKgs,24321
56
+ docent_python-0.1.28a0.dist-info/METADATA,sha256=7uIPnlYJFyZpE6xCEXwz4OlGD-_br4B4GY6DZ0uj7i8,1351
57
+ docent_python-0.1.28a0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
58
+ docent_python-0.1.28a0.dist-info/licenses/LICENSE.md,sha256=QIMv2UiT6MppRasso4ymaA0w7ltkqmlL0HCt8CLD7Rc,580
59
+ docent_python-0.1.28a0.dist-info/RECORD,,