docent-python 0.1.12a0__tar.gz → 0.1.14a0__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.
- docent_python-0.1.14a0/LICENSE.md +13 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/PKG-INFO +2 -2
- docent_python-0.1.14a0/docent/__init__.py +4 -0
- docent_python-0.1.14a0/docent/data_models/agent_run.py +468 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/data_models/chat/tool.py +1 -1
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/data_models/remove_invalid_citation_ranges.py +3 -6
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/data_models/transcript.py +59 -37
- docent_python-0.1.14a0/docent/data_models/yaml_util.py +12 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/loaders/load_inspect.py +15 -10
- docent_python-0.1.14a0/docent/sdk/agent_run_writer.py +266 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/sdk/client.py +90 -46
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/trace.py +4 -2
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/pyproject.toml +2 -2
- docent_python-0.1.12a0/LICENSE.md +0 -7
- docent_python-0.1.12a0/docent/__init__.py +0 -3
- docent_python-0.1.12a0/docent/data_models/agent_run.py +0 -299
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/.gitignore +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/README.md +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/_log_util/__init__.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/_log_util/logger.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/data_models/__init__.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/data_models/_tiktoken_util.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/data_models/chat/__init__.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/data_models/chat/content.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/data_models/chat/message.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/data_models/citation.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/data_models/metadata.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/data_models/regex.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/data_models/shared_types.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/py.typed +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/samples/__init__.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/samples/load.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/samples/log.eval +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/samples/tb_airline.json +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/sdk/__init__.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/docent/trace_temp.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.14a0}/uv.lock +0 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2025 Clarity AI Research Inc., dba Transluce
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: docent-python
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.14a0
|
|
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
|
|
7
7
|
Project-URL: Docs, https://transluce-docent.readthedocs-hosted.com/en/latest
|
|
8
8
|
Author-email: Transluce <info@transluce.org>
|
|
9
|
-
License-Expression:
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
10
|
License-File: LICENSE.md
|
|
11
11
|
Requires-Python: >=3.11
|
|
12
12
|
Requires-Dist: opentelemetry-api>=1.34.1
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import textwrap
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from queue import Queue
|
|
5
|
+
from typing import Any, Literal, TypedDict, cast
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import (
|
|
10
|
+
BaseModel,
|
|
11
|
+
Field,
|
|
12
|
+
PrivateAttr,
|
|
13
|
+
field_validator,
|
|
14
|
+
model_validator,
|
|
15
|
+
)
|
|
16
|
+
from pydantic_core import to_jsonable_python
|
|
17
|
+
|
|
18
|
+
from docent._log_util import get_logger
|
|
19
|
+
from docent.data_models._tiktoken_util import get_token_count, group_messages_into_ranges
|
|
20
|
+
from docent.data_models.transcript import Transcript, TranscriptGroup
|
|
21
|
+
from docent.data_models.yaml_util import yaml_dump_metadata
|
|
22
|
+
|
|
23
|
+
logger = get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FilterableField(TypedDict):
|
|
27
|
+
name: str
|
|
28
|
+
type: Literal["str", "bool", "int", "float"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AgentRun(BaseModel):
|
|
32
|
+
"""Represents a complete run of an agent with transcripts and metadata.
|
|
33
|
+
|
|
34
|
+
An AgentRun encapsulates the execution of an agent, storing all communication
|
|
35
|
+
transcripts and associated metadata. It must contain at least one transcript.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
id: Unique identifier for the agent run, auto-generated by default.
|
|
39
|
+
name: Optional human-readable name for the agent run.
|
|
40
|
+
description: Optional description of the agent run.
|
|
41
|
+
transcripts: List of Transcript objects.
|
|
42
|
+
transcript_groups: List of TranscriptGroup objects.
|
|
43
|
+
metadata: Additional structured metadata about the agent run as a JSON-serializable dictionary.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
id: str = Field(default_factory=lambda: str(uuid4()))
|
|
47
|
+
name: str | None = None
|
|
48
|
+
description: str | None = None
|
|
49
|
+
|
|
50
|
+
transcripts: list[Transcript]
|
|
51
|
+
transcript_groups: list[TranscriptGroup] = Field(default_factory=list)
|
|
52
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
@field_validator("transcripts", mode="before")
|
|
55
|
+
@classmethod
|
|
56
|
+
def _validate_transcripts_type(cls, v: Any) -> Any:
|
|
57
|
+
if isinstance(v, dict):
|
|
58
|
+
logger.warning(
|
|
59
|
+
"dict[str, Transcript] for transcripts is deprecated. Use list[Transcript] instead."
|
|
60
|
+
)
|
|
61
|
+
v = cast(dict[str, Transcript], v)
|
|
62
|
+
return [Transcript.model_validate(t) for t in v.values()]
|
|
63
|
+
return v
|
|
64
|
+
|
|
65
|
+
@field_validator("transcript_groups", mode="before")
|
|
66
|
+
@classmethod
|
|
67
|
+
def _validate_transcript_groups_type(cls, v: Any) -> Any:
|
|
68
|
+
if isinstance(v, dict):
|
|
69
|
+
logger.warning(
|
|
70
|
+
"dict[str, TranscriptGroup] for transcript_groups is deprecated. Use list[TranscriptGroup] instead."
|
|
71
|
+
)
|
|
72
|
+
v = cast(dict[str, TranscriptGroup], v)
|
|
73
|
+
return [TranscriptGroup.model_validate(tg) for tg in v.values()]
|
|
74
|
+
return v
|
|
75
|
+
|
|
76
|
+
@model_validator(mode="after")
|
|
77
|
+
def _validate_transcripts_not_empty(self):
|
|
78
|
+
"""Validates that the agent run contains at least one transcript.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: If the transcripts list is empty.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
AgentRun: The validated AgentRun instance.
|
|
85
|
+
"""
|
|
86
|
+
if len(self.transcripts) == 0:
|
|
87
|
+
raise ValueError("AgentRun must have at least one transcript")
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def get_filterable_fields(self, max_depth: int = 1) -> list[FilterableField]:
|
|
91
|
+
"""Returns a list of all fields that can be used to filter the agent run,
|
|
92
|
+
by recursively exploring the model_dump() for singleton types in dictionaries.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
list[FilterableField]: A list of filterable fields, where each field is a
|
|
96
|
+
dictionary containing its 'name' (path) and 'type'.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
result: list[FilterableField] = []
|
|
100
|
+
|
|
101
|
+
def _explore_dict(d: dict[str, Any], prefix: str, depth: int):
|
|
102
|
+
nonlocal result
|
|
103
|
+
|
|
104
|
+
if depth > max_depth:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
for k, v in d.items():
|
|
108
|
+
if isinstance(v, (str, int, float, bool)):
|
|
109
|
+
result.append(
|
|
110
|
+
{
|
|
111
|
+
"name": f"{prefix}.{k}",
|
|
112
|
+
"type": cast(Literal["str", "bool", "int", "float"], type(v).__name__),
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
elif isinstance(v, dict):
|
|
116
|
+
_explore_dict(cast(dict[str, Any], v), f"{prefix}.{k}", depth + 1)
|
|
117
|
+
|
|
118
|
+
# Look at the agent run metadata
|
|
119
|
+
_explore_dict(to_jsonable_python(self.metadata), "metadata", 0)
|
|
120
|
+
# Look at the transcript metadata
|
|
121
|
+
# TODO(mengk): restore this later when we have the ability to integrate with SQL.
|
|
122
|
+
# for t_id, t in self.transcripts.items():
|
|
123
|
+
# _explore_dict(
|
|
124
|
+
# t.metadata.model_dump(strip_internal_fields=True), f"transcript.{t_id}.metadata", 0
|
|
125
|
+
# )
|
|
126
|
+
|
|
127
|
+
# Append the text field
|
|
128
|
+
result.append({"name": "text", "type": "str"})
|
|
129
|
+
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
######################
|
|
133
|
+
# Converting to text #
|
|
134
|
+
######################
|
|
135
|
+
|
|
136
|
+
def _to_text_impl(self, token_limit: int = sys.maxsize, use_blocks: bool = False) -> list[str]:
|
|
137
|
+
"""
|
|
138
|
+
Core implementation for converting agent run to text representation.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
token_limit: Maximum tokens per returned string under the GPT-4 tokenization scheme
|
|
142
|
+
use_blocks: If True, use individual message blocks. If False, use action units.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
List of strings, each at most token_limit tokens
|
|
146
|
+
"""
|
|
147
|
+
# Generate transcript strings using appropriate method
|
|
148
|
+
transcript_strs: list[str] = []
|
|
149
|
+
for i, t in enumerate(self.transcripts):
|
|
150
|
+
if use_blocks:
|
|
151
|
+
transcript_content = t.to_str_blocks_with_token_limit(
|
|
152
|
+
token_limit=sys.maxsize,
|
|
153
|
+
transcript_idx=i,
|
|
154
|
+
agent_run_idx=None,
|
|
155
|
+
)[0]
|
|
156
|
+
else:
|
|
157
|
+
transcript_content = t.to_str_with_token_limit(
|
|
158
|
+
token_limit=sys.maxsize,
|
|
159
|
+
transcript_idx=i,
|
|
160
|
+
agent_run_idx=None,
|
|
161
|
+
)[0]
|
|
162
|
+
transcript_strs.append(f"<transcript>\n{transcript_content}\n</transcript>")
|
|
163
|
+
|
|
164
|
+
transcripts_str = "\n\n".join(transcript_strs)
|
|
165
|
+
|
|
166
|
+
# Gather metadata
|
|
167
|
+
metadata_obj = to_jsonable_python(self.metadata)
|
|
168
|
+
if self.name is not None:
|
|
169
|
+
metadata_obj["name"] = self.name
|
|
170
|
+
if self.description is not None:
|
|
171
|
+
metadata_obj["description"] = self.description
|
|
172
|
+
|
|
173
|
+
yaml_width = float("inf")
|
|
174
|
+
transcripts_str = (
|
|
175
|
+
f"Here is a complete agent run for analysis purposes only:\n{transcripts_str}\n\n"
|
|
176
|
+
)
|
|
177
|
+
metadata_str = f"Metadata about the complete agent run:\n<agent run metadata>\n{yaml.dump(metadata_obj, width=yaml_width)}\n</agent run metadata>"
|
|
178
|
+
|
|
179
|
+
if token_limit == sys.maxsize:
|
|
180
|
+
return [f"{transcripts_str}" f"{metadata_str}"]
|
|
181
|
+
|
|
182
|
+
# Compute message length; if fits, return the full transcript and metadata
|
|
183
|
+
transcript_str_tokens = get_token_count(transcripts_str)
|
|
184
|
+
metadata_str_tokens = get_token_count(metadata_str)
|
|
185
|
+
if transcript_str_tokens + metadata_str_tokens <= token_limit:
|
|
186
|
+
return [f"{transcripts_str}" f"{metadata_str}"]
|
|
187
|
+
|
|
188
|
+
# Otherwise, split up the transcript and metadata into chunks
|
|
189
|
+
else:
|
|
190
|
+
results: list[str] = []
|
|
191
|
+
transcript_token_counts = [get_token_count(t) for t in transcript_strs]
|
|
192
|
+
ranges = group_messages_into_ranges(
|
|
193
|
+
transcript_token_counts, metadata_str_tokens, token_limit - 50
|
|
194
|
+
)
|
|
195
|
+
for msg_range in ranges:
|
|
196
|
+
if msg_range.include_metadata:
|
|
197
|
+
cur_transcript_str = "\n\n".join(
|
|
198
|
+
transcript_strs[msg_range.start : msg_range.end]
|
|
199
|
+
)
|
|
200
|
+
results.append(
|
|
201
|
+
f"Here is a partial agent run for analysis purposes only:\n{cur_transcript_str}"
|
|
202
|
+
f"{metadata_str}"
|
|
203
|
+
)
|
|
204
|
+
else:
|
|
205
|
+
assert (
|
|
206
|
+
msg_range.end == msg_range.start + 1
|
|
207
|
+
), "Ranges without metadata should be a single message"
|
|
208
|
+
t = self.transcripts[msg_range.start]
|
|
209
|
+
if msg_range.num_tokens < token_limit - 50:
|
|
210
|
+
if use_blocks:
|
|
211
|
+
transcript = f"<transcript>\n{t.to_str_blocks_with_token_limit(token_limit=sys.maxsize)[0]}\n</transcript>"
|
|
212
|
+
else:
|
|
213
|
+
transcript = f"<transcript>\n{t.to_str_with_token_limit(token_limit=sys.maxsize)[0]}\n</transcript>"
|
|
214
|
+
result = (
|
|
215
|
+
f"Here is a partial agent run for analysis purposes only:\n{transcript}"
|
|
216
|
+
)
|
|
217
|
+
results.append(result)
|
|
218
|
+
else:
|
|
219
|
+
if use_blocks:
|
|
220
|
+
transcript_fragments = t.to_str_blocks_with_token_limit(
|
|
221
|
+
token_limit=token_limit - 50,
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
transcript_fragments = t.to_str_with_token_limit(
|
|
225
|
+
token_limit=token_limit - 50,
|
|
226
|
+
)
|
|
227
|
+
for fragment in transcript_fragments:
|
|
228
|
+
result = f"<transcript>\n{fragment}\n</transcript>"
|
|
229
|
+
result = (
|
|
230
|
+
f"Here is a partial agent run for analysis purposes only:\n{result}"
|
|
231
|
+
)
|
|
232
|
+
results.append(result)
|
|
233
|
+
return results
|
|
234
|
+
|
|
235
|
+
def to_text(self, token_limit: int = sys.maxsize) -> list[str]:
|
|
236
|
+
"""
|
|
237
|
+
Represents an agent run as a list of strings, each of which is at most token_limit tokens
|
|
238
|
+
under the GPT-4 tokenization scheme.
|
|
239
|
+
|
|
240
|
+
We'll try to split up long AgentRuns along transcript boundaries and include metadata.
|
|
241
|
+
For very long transcripts, we'll have to split them up further and remove metadata.
|
|
242
|
+
"""
|
|
243
|
+
return self._to_text_impl(token_limit=token_limit, use_blocks=False)
|
|
244
|
+
|
|
245
|
+
def to_text_blocks(self, token_limit: int = sys.maxsize) -> list[str]:
|
|
246
|
+
"""
|
|
247
|
+
Represents an agent run as a list of strings using individual message blocks,
|
|
248
|
+
each of which is at most token_limit tokens under the GPT-4 tokenization scheme.
|
|
249
|
+
|
|
250
|
+
Unlike to_text() which uses action units, this method formats each message
|
|
251
|
+
as an individual block.
|
|
252
|
+
"""
|
|
253
|
+
return self._to_text_impl(token_limit=token_limit, use_blocks=True)
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def text(self) -> str:
|
|
257
|
+
"""Concatenates all transcript texts with double newlines as separators.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
str: A string representation of all transcripts.
|
|
261
|
+
"""
|
|
262
|
+
return self._to_text_impl(token_limit=sys.maxsize, use_blocks=False)[0]
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def text_blocks(self) -> str:
|
|
266
|
+
"""Concatenates all transcript texts using individual blocks format.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
str: A string representation of all transcripts using individual message blocks.
|
|
270
|
+
"""
|
|
271
|
+
return self._to_text_impl(token_limit=sys.maxsize, use_blocks=True)[0]
|
|
272
|
+
|
|
273
|
+
##############################
|
|
274
|
+
# New text rendering methods #
|
|
275
|
+
##############################
|
|
276
|
+
|
|
277
|
+
# Transcript ID -> Transcript
|
|
278
|
+
_transcript_dict: dict[str, Transcript] | None = PrivateAttr(default=None)
|
|
279
|
+
# Transcript Group ID -> Transcript Group
|
|
280
|
+
_transcript_group_dict: dict[str, TranscriptGroup] | None = PrivateAttr(default=None)
|
|
281
|
+
# Canonical tree cache keyed by full_tree flag
|
|
282
|
+
_canonical_tree_cache: dict[bool, dict[str | None, list[tuple[Literal["t", "tg"], str]]]] = (
|
|
283
|
+
PrivateAttr(default_factory=dict)
|
|
284
|
+
)
|
|
285
|
+
# Transcript IDs (depth-first) cache keyed by full_tree flag
|
|
286
|
+
_transcript_ids_ordered_cache: dict[bool, list[str]] = PrivateAttr(default_factory=dict)
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def transcript_dict(self) -> dict[str, Transcript]:
|
|
290
|
+
"""Lazily compute and cache a mapping from transcript ID to Transcript."""
|
|
291
|
+
if self._transcript_dict is None:
|
|
292
|
+
self._transcript_dict = {t.id: t for t in self.transcripts}
|
|
293
|
+
return self._transcript_dict
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def transcript_group_dict(self) -> dict[str, TranscriptGroup]:
|
|
297
|
+
"""Lazily compute and cache a mapping from transcript group ID to TranscriptGroup."""
|
|
298
|
+
if self._transcript_group_dict is None:
|
|
299
|
+
self._transcript_group_dict = {tg.id: tg for tg in self.transcript_groups}
|
|
300
|
+
return self._transcript_group_dict
|
|
301
|
+
|
|
302
|
+
def get_canonical_tree(
|
|
303
|
+
self, full_tree: bool = False
|
|
304
|
+
) -> dict[str | None, list[tuple[Literal["t", "tg"], str]]]:
|
|
305
|
+
"""Compute and cache the canonical, sorted transcript group tree.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
full_tree: If True, include all transcript groups regardless of whether
|
|
309
|
+
they contain transcripts. If False, include only the minimal tree
|
|
310
|
+
that connects relevant groups and transcripts.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Canonical tree mapping parent group id (or "__global_root") to a list of
|
|
314
|
+
children (type, id) tuples sorted by creation time.
|
|
315
|
+
"""
|
|
316
|
+
if (
|
|
317
|
+
full_tree not in self._canonical_tree_cache
|
|
318
|
+
or full_tree not in self._transcript_ids_ordered_cache
|
|
319
|
+
):
|
|
320
|
+
canonical_tree, transcript_idx_map = self._build_canonical_tree(full_tree=full_tree)
|
|
321
|
+
self._canonical_tree_cache[full_tree] = canonical_tree
|
|
322
|
+
self._transcript_ids_ordered_cache[full_tree] = list(transcript_idx_map.keys())
|
|
323
|
+
return self._canonical_tree_cache[full_tree]
|
|
324
|
+
|
|
325
|
+
def get_transcript_ids_ordered(self, full_tree: bool = False) -> list[str]:
|
|
326
|
+
"""Compute and cache the depth-first transcript id ordering.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
full_tree: Whether to compute based on the full tree or the minimal tree.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
List of transcript ids in depth-first order.
|
|
333
|
+
"""
|
|
334
|
+
if (
|
|
335
|
+
full_tree not in self._transcript_ids_ordered_cache
|
|
336
|
+
or full_tree not in self._canonical_tree_cache
|
|
337
|
+
):
|
|
338
|
+
canonical_tree, transcript_idx_map = self._build_canonical_tree(full_tree=full_tree)
|
|
339
|
+
self._canonical_tree_cache[full_tree] = canonical_tree
|
|
340
|
+
self._transcript_ids_ordered_cache[full_tree] = list(transcript_idx_map.keys())
|
|
341
|
+
return self._transcript_ids_ordered_cache[full_tree]
|
|
342
|
+
|
|
343
|
+
def _build_canonical_tree(self, full_tree: bool = False):
|
|
344
|
+
t_dict = self.transcript_dict
|
|
345
|
+
tg_dict = self.transcript_group_dict
|
|
346
|
+
|
|
347
|
+
# Find all transcript groups that have direct transcript children
|
|
348
|
+
# Also keep track of transcripts that are not in a group
|
|
349
|
+
tgs_to_transcripts: dict[str, set[str]] = {}
|
|
350
|
+
for transcript in t_dict.values():
|
|
351
|
+
if transcript.transcript_group_id is None:
|
|
352
|
+
tgs_to_transcripts.setdefault("__global_root", set()).add(transcript.id)
|
|
353
|
+
else:
|
|
354
|
+
tgs_to_transcripts.setdefault(transcript.transcript_group_id, set()).add(
|
|
355
|
+
transcript.id
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# tg_tree maps from parent -> children. A child can be a group or a transcript.
|
|
359
|
+
# A parent must be a group (or None, for transcripts that are not in a group).
|
|
360
|
+
tg_tree: dict[str, set[tuple[Literal["t", "tg"], str]]] = {}
|
|
361
|
+
|
|
362
|
+
if full_tree:
|
|
363
|
+
for tg_id, tg in tg_dict.items():
|
|
364
|
+
tg_tree.setdefault(tg.parent_transcript_group_id or "__global_root", set()).add(
|
|
365
|
+
("tg", tg_id)
|
|
366
|
+
)
|
|
367
|
+
for t_id in tgs_to_transcripts.get(tg_id, []):
|
|
368
|
+
tg_tree.setdefault(tg_id, set()).add(("t", t_id))
|
|
369
|
+
for t_id, t in t_dict.items():
|
|
370
|
+
tg_tree.setdefault(t.transcript_group_id or "__global_root", set()).add(("t", t_id))
|
|
371
|
+
else:
|
|
372
|
+
# Initialize q with "important" tgs
|
|
373
|
+
q, seen = Queue[str](), set[str]()
|
|
374
|
+
for tg_id in tgs_to_transcripts.keys():
|
|
375
|
+
q.put(tg_id)
|
|
376
|
+
seen.add(tg_id)
|
|
377
|
+
|
|
378
|
+
# Do an "upwards BFS" from leaves up to the root. Builds a tree of only relevant nodes.
|
|
379
|
+
while q.qsize() > 0:
|
|
380
|
+
u_id = q.get()
|
|
381
|
+
u = tg_dict.get(u_id) # None if __global_root
|
|
382
|
+
|
|
383
|
+
# Add the transcripts under this tg
|
|
384
|
+
for t_id in tgs_to_transcripts.get(u_id, []):
|
|
385
|
+
tg_tree.setdefault(u_id, set()).add(("t", t_id))
|
|
386
|
+
|
|
387
|
+
# Add an edge from the parent
|
|
388
|
+
if u is not None:
|
|
389
|
+
par_id = u.parent_transcript_group_id or "__global_root"
|
|
390
|
+
# Mark u as a child of par
|
|
391
|
+
tg_tree.setdefault(par_id, set()).add(("tg", u_id))
|
|
392
|
+
# If we haven't investigated the parent before, add to q
|
|
393
|
+
if par_id not in seen:
|
|
394
|
+
q.put(par_id)
|
|
395
|
+
seen.add(par_id)
|
|
396
|
+
|
|
397
|
+
# For each node, sort by created_at timestamp
|
|
398
|
+
|
|
399
|
+
def _cmp(element: tuple[Literal["t", "tg"], str]) -> datetime:
|
|
400
|
+
obj_type, obj_id = element
|
|
401
|
+
if obj_type == "tg":
|
|
402
|
+
return tg_dict[obj_id].created_at or datetime.max
|
|
403
|
+
else:
|
|
404
|
+
return t_dict[obj_id].created_at or datetime.max
|
|
405
|
+
|
|
406
|
+
c_tree: dict[str | None, list[tuple[Literal["t", "tg"], str]]] = {}
|
|
407
|
+
for tg_id in tg_tree:
|
|
408
|
+
children_ids = list(set(tg_tree[tg_id]))
|
|
409
|
+
sorted_children_ids = sorted(children_ids, key=_cmp)
|
|
410
|
+
c_tree[tg_id] = sorted_children_ids
|
|
411
|
+
|
|
412
|
+
# Compute transcript indices as the depth-first traversal index
|
|
413
|
+
transcript_idx_map: dict[str, int] = {}
|
|
414
|
+
|
|
415
|
+
def _assign_transcript_indices(cur_tg_id: str, next_idx: int) -> int:
|
|
416
|
+
children = c_tree.get(cur_tg_id, [])
|
|
417
|
+
for child_type, child_id in children:
|
|
418
|
+
if child_type == "tg":
|
|
419
|
+
next_idx = _assign_transcript_indices(child_id, next_idx)
|
|
420
|
+
else:
|
|
421
|
+
transcript_idx_map[child_id] = next_idx
|
|
422
|
+
next_idx += 1
|
|
423
|
+
return next_idx
|
|
424
|
+
|
|
425
|
+
_assign_transcript_indices("__global_root", 0)
|
|
426
|
+
|
|
427
|
+
return c_tree, transcript_idx_map
|
|
428
|
+
|
|
429
|
+
def to_text_new(self, indent: int = 0, full_tree: bool = False):
|
|
430
|
+
c_tree = self.get_canonical_tree(full_tree=full_tree)
|
|
431
|
+
t_ids_ordered = self.get_transcript_ids_ordered(full_tree=full_tree)
|
|
432
|
+
t_idx_map = {t_id: i for i, t_id in enumerate(t_ids_ordered)}
|
|
433
|
+
t_dict = self.transcript_dict
|
|
434
|
+
tg_dict = self.transcript_group_dict
|
|
435
|
+
|
|
436
|
+
# Traverse the tree and render the string
|
|
437
|
+
def _recurse(tg_id: str) -> str:
|
|
438
|
+
children_ids = c_tree.get(tg_id, [])
|
|
439
|
+
children_texts: list[str] = []
|
|
440
|
+
for child_type, child_id in children_ids:
|
|
441
|
+
if child_type == "tg":
|
|
442
|
+
children_texts.append(_recurse(child_id))
|
|
443
|
+
else:
|
|
444
|
+
cur_text = t_dict[child_id].to_text_new(
|
|
445
|
+
transcript_idx=t_idx_map[child_id],
|
|
446
|
+
indent=indent,
|
|
447
|
+
)
|
|
448
|
+
children_texts.append(cur_text)
|
|
449
|
+
children_text = "\n".join(children_texts)
|
|
450
|
+
|
|
451
|
+
# No wrapper for global root
|
|
452
|
+
if tg_id == "__global_root":
|
|
453
|
+
return children_text
|
|
454
|
+
# Delegate rendering to TranscriptGroup
|
|
455
|
+
else:
|
|
456
|
+
tg = tg_dict[tg_id]
|
|
457
|
+
return tg.to_text_new(children_text=children_text, indent=indent)
|
|
458
|
+
|
|
459
|
+
text = _recurse("__global_root")
|
|
460
|
+
|
|
461
|
+
# Append agent run metadata below the full content
|
|
462
|
+
yaml_text = yaml_dump_metadata(self.metadata)
|
|
463
|
+
if yaml_text is not None:
|
|
464
|
+
if indent > 0:
|
|
465
|
+
yaml_text = textwrap.indent(yaml_text, " " * indent)
|
|
466
|
+
text += f"\n<|agent run metadata|>\n{yaml_text}\n</|agent run metadata|>"
|
|
467
|
+
|
|
468
|
+
return text
|
|
@@ -66,16 +66,13 @@ def get_transcript_text_for_citation(agent_run: AgentRun, citation: Citation) ->
|
|
|
66
66
|
return None
|
|
67
67
|
|
|
68
68
|
try:
|
|
69
|
-
|
|
70
|
-
if citation.transcript_idx >= len(transcript_keys):
|
|
69
|
+
if citation.transcript_idx >= len(agent_run.get_transcript_ids_ordered()):
|
|
71
70
|
return None
|
|
71
|
+
transcript_id = agent_run.get_transcript_ids_ordered()[citation.transcript_idx]
|
|
72
|
+
transcript = agent_run.transcript_dict[transcript_id]
|
|
72
73
|
|
|
73
|
-
transcript_key = transcript_keys[citation.transcript_idx]
|
|
74
|
-
|
|
75
|
-
transcript = agent_run.transcripts[transcript_key]
|
|
76
74
|
if citation.block_idx >= len(transcript.messages):
|
|
77
75
|
return None
|
|
78
|
-
|
|
79
76
|
message = transcript.messages[citation.block_idx]
|
|
80
77
|
|
|
81
78
|
# Use the same formatting function that generates content for LLMs
|