docent-python 0.1.18a0__py3-none-any.whl → 0.1.20a0__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.
- docent/data_models/__init__.py +2 -0
- docent/data_models/agent_run.py +5 -5
- docent/data_models/chat/__init__.py +6 -1
- docent/data_models/citation.py +103 -22
- docent/data_models/judge.py +16 -0
- docent/data_models/metadata_util.py +16 -0
- docent/data_models/remove_invalid_citation_ranges.py +23 -10
- docent/data_models/transcript.py +18 -16
- docent/data_models/util.py +170 -0
- docent/sdk/agent_run_writer.py +18 -5
- docent/sdk/client.py +109 -22
- docent/trace.py +54 -49
- docent/trace_2.py +1842 -0
- {docent_python-0.1.18a0.dist-info → docent_python-0.1.20a0.dist-info}/METADATA +5 -5
- {docent_python-0.1.18a0.dist-info → docent_python-0.1.20a0.dist-info}/RECORD +17 -15
- docent/data_models/metadata.py +0 -229
- docent/data_models/yaml_util.py +0 -12
- {docent_python-0.1.18a0.dist-info → docent_python-0.1.20a0.dist-info}/WHEEL +0 -0
- {docent_python-0.1.18a0.dist-info → docent_python-0.1.20a0.dist-info}/licenses/LICENSE.md +0 -0
docent/data_models/__init__.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
from docent.data_models.agent_run import AgentRun
|
|
2
2
|
from docent.data_models.citation import Citation
|
|
3
|
+
from docent.data_models.judge import JudgeRunLabel
|
|
3
4
|
from docent.data_models.regex import RegexSnippet
|
|
4
5
|
from docent.data_models.transcript import Transcript, TranscriptGroup
|
|
5
6
|
|
|
6
7
|
__all__ = [
|
|
7
8
|
"AgentRun",
|
|
8
9
|
"Citation",
|
|
10
|
+
"JudgeRunLabel",
|
|
9
11
|
"RegexSnippet",
|
|
10
12
|
"Transcript",
|
|
11
13
|
"TranscriptGroup",
|
docent/data_models/agent_run.py
CHANGED
|
@@ -17,8 +17,8 @@ from pydantic_core import to_jsonable_python
|
|
|
17
17
|
|
|
18
18
|
from docent._log_util import get_logger
|
|
19
19
|
from docent.data_models._tiktoken_util import get_token_count, group_messages_into_ranges
|
|
20
|
+
from docent.data_models.metadata_util import dump_metadata
|
|
20
21
|
from docent.data_models.transcript import Transcript, TranscriptGroup
|
|
21
|
-
from docent.data_models.yaml_util import yaml_dump_metadata
|
|
22
22
|
|
|
23
23
|
logger = get_logger(__name__)
|
|
24
24
|
|
|
@@ -446,10 +446,10 @@ class AgentRun(BaseModel):
|
|
|
446
446
|
text = _recurse("__global_root")
|
|
447
447
|
|
|
448
448
|
# Append agent run metadata below the full content
|
|
449
|
-
|
|
450
|
-
if
|
|
449
|
+
metadata_text = dump_metadata(self.metadata)
|
|
450
|
+
if metadata_text is not None:
|
|
451
451
|
if indent > 0:
|
|
452
|
-
|
|
453
|
-
text += f"\n<|agent run metadata|>\n{
|
|
452
|
+
metadata_text = textwrap.indent(metadata_text, " " * indent)
|
|
453
|
+
text += f"\n<|agent run metadata|>\n{metadata_text}\n</|agent run metadata|>"
|
|
454
454
|
|
|
455
455
|
return text
|
|
@@ -7,7 +7,12 @@ from docent.data_models.chat.message import (
|
|
|
7
7
|
UserMessage,
|
|
8
8
|
parse_chat_message,
|
|
9
9
|
)
|
|
10
|
-
from docent.data_models.chat.tool import
|
|
10
|
+
from docent.data_models.chat.tool import (
|
|
11
|
+
ToolCall,
|
|
12
|
+
ToolCallContent,
|
|
13
|
+
ToolInfo,
|
|
14
|
+
ToolParams,
|
|
15
|
+
)
|
|
11
16
|
|
|
12
17
|
__all__ = [
|
|
13
18
|
"ChatMessage",
|
docent/data_models/citation.py
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import re
|
|
2
|
+
from dataclasses import dataclass
|
|
2
3
|
|
|
3
4
|
from pydantic import BaseModel
|
|
4
5
|
|
|
5
6
|
|
|
7
|
+
@dataclass
|
|
8
|
+
class ParsedCitation:
|
|
9
|
+
"""Represents a parsed citation before conversion to full Citation object."""
|
|
10
|
+
|
|
11
|
+
transcript_idx: int | None
|
|
12
|
+
block_idx: int | None
|
|
13
|
+
metadata_key: str | None = None
|
|
14
|
+
start_pattern: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
6
17
|
class Citation(BaseModel):
|
|
7
18
|
start_idx: int
|
|
8
19
|
end_idx: int
|
|
9
20
|
agent_run_idx: int | None = None
|
|
10
21
|
transcript_idx: int | None = None
|
|
11
|
-
block_idx: int
|
|
22
|
+
block_idx: int | None = None
|
|
12
23
|
action_unit_idx: int | None = None
|
|
24
|
+
metadata_key: str | None = None
|
|
13
25
|
start_pattern: str | None = None
|
|
14
26
|
|
|
15
27
|
|
|
@@ -17,6 +29,9 @@ RANGE_BEGIN = "<RANGE>"
|
|
|
17
29
|
RANGE_END = "</RANGE>"
|
|
18
30
|
|
|
19
31
|
_SINGLE_RE = re.compile(r"T(\d+)B(\d+)")
|
|
32
|
+
_METADATA_RE = re.compile(r"^M\.([^:]+)$") # [M.key]
|
|
33
|
+
_TRANSCRIPT_METADATA_RE = re.compile(r"^T(\d+)M\.([^:]+)$") # [T0M.key]
|
|
34
|
+
_MESSAGE_METADATA_RE = re.compile(r"^T(\d+)B(\d+)M\.([^:]+)$") # [T0B1M.key]
|
|
20
35
|
_RANGE_CONTENT_RE = re.compile(r":\s*" + re.escape(RANGE_BEGIN) + r".*?" + re.escape(RANGE_END))
|
|
21
36
|
|
|
22
37
|
|
|
@@ -70,41 +85,93 @@ def scan_brackets(text: str) -> list[tuple[int, int, str]]:
|
|
|
70
85
|
return matches
|
|
71
86
|
|
|
72
87
|
|
|
73
|
-
def parse_single_citation(part: str) ->
|
|
88
|
+
def parse_single_citation(part: str) -> ParsedCitation | None:
|
|
74
89
|
"""
|
|
75
90
|
Parse a single citation token inside a bracket and return its components.
|
|
76
91
|
|
|
77
|
-
Returns
|
|
92
|
+
Returns ParsedCitation or None if invalid.
|
|
93
|
+
For metadata citations, transcript_idx may be None (for agent run metadata).
|
|
94
|
+
Supports optional text range for all valid citation kinds.
|
|
78
95
|
"""
|
|
79
96
|
token = part.strip()
|
|
80
97
|
if not token:
|
|
81
98
|
return None
|
|
82
99
|
|
|
100
|
+
# Extract optional range part
|
|
101
|
+
start_pattern: str | None = None
|
|
102
|
+
citation_part = token
|
|
83
103
|
if ":" in token:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
104
|
+
left, right = token.split(":", 1)
|
|
105
|
+
citation_part = left.strip()
|
|
106
|
+
start_pattern = _extract_range_pattern(right)
|
|
107
|
+
|
|
108
|
+
# Try matches in order of specificity
|
|
109
|
+
# 1) Message metadata [T0B0M.key]
|
|
110
|
+
m = _MESSAGE_METADATA_RE.match(citation_part)
|
|
111
|
+
if m:
|
|
112
|
+
transcript_idx = int(m.group(1))
|
|
113
|
+
block_idx = int(m.group(2))
|
|
114
|
+
metadata_key = m.group(3)
|
|
115
|
+
# Disallow nested keys like status.code per instruction
|
|
116
|
+
if "." in metadata_key:
|
|
87
117
|
return None
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
118
|
+
return ParsedCitation(
|
|
119
|
+
transcript_idx=transcript_idx,
|
|
120
|
+
block_idx=block_idx,
|
|
121
|
+
metadata_key=metadata_key,
|
|
122
|
+
start_pattern=start_pattern,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# 2) Transcript metadata [T0M.key]
|
|
126
|
+
m = _TRANSCRIPT_METADATA_RE.match(citation_part)
|
|
127
|
+
if m:
|
|
128
|
+
transcript_idx = int(m.group(1))
|
|
129
|
+
metadata_key = m.group(2)
|
|
130
|
+
if "." in metadata_key:
|
|
95
131
|
return None
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
132
|
+
return ParsedCitation(
|
|
133
|
+
transcript_idx=transcript_idx,
|
|
134
|
+
block_idx=None,
|
|
135
|
+
metadata_key=metadata_key,
|
|
136
|
+
start_pattern=start_pattern,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# 3) Agent run metadata [M.key]
|
|
140
|
+
m = _METADATA_RE.match(citation_part)
|
|
141
|
+
if m:
|
|
142
|
+
metadata_key = m.group(1)
|
|
143
|
+
if "." in metadata_key:
|
|
144
|
+
return None
|
|
145
|
+
return ParsedCitation(
|
|
146
|
+
transcript_idx=None,
|
|
147
|
+
block_idx=None,
|
|
148
|
+
metadata_key=metadata_key,
|
|
149
|
+
start_pattern=start_pattern,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# 4) Regular transcript block [T0B0]
|
|
153
|
+
m = _SINGLE_RE.match(citation_part)
|
|
154
|
+
if m:
|
|
155
|
+
transcript_idx = int(m.group(1))
|
|
156
|
+
block_idx = int(m.group(2))
|
|
157
|
+
return ParsedCitation(
|
|
158
|
+
transcript_idx=transcript_idx, block_idx=block_idx, start_pattern=start_pattern
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return None
|
|
99
162
|
|
|
100
163
|
|
|
101
164
|
def parse_citations(text: str) -> tuple[str, list[Citation]]:
|
|
102
165
|
"""
|
|
103
|
-
Parse citations from text in the format described by
|
|
166
|
+
Parse citations from text in the format described by TEXT_RANGE_CITE_INSTRUCTION.
|
|
104
167
|
|
|
105
168
|
Supported formats:
|
|
106
169
|
- Single block: [T<key>B<idx>]
|
|
107
170
|
- Text range with start pattern: [T<key>B<idx>:<RANGE>start_pattern</RANGE>]
|
|
171
|
+
- Agent run metadata: [M.key]
|
|
172
|
+
- Transcript metadata: [T<key>M.key]
|
|
173
|
+
- Message metadata: [T<key>B<idx>M.key]
|
|
174
|
+
- Message metadata with text range: [T<key>B<idx>M.key:<RANGE>start_pattern</RANGE>]
|
|
108
175
|
|
|
109
176
|
Args:
|
|
110
177
|
text: The text to parse citations from
|
|
@@ -127,8 +194,21 @@ def parse_citations(text: str) -> tuple[str, list[Citation]]:
|
|
|
127
194
|
# Parse a single citation token inside the bracket
|
|
128
195
|
parsed = parse_single_citation(bracket_content)
|
|
129
196
|
if parsed:
|
|
130
|
-
|
|
131
|
-
|
|
197
|
+
# Create appropriate replacement text based on citation type
|
|
198
|
+
if parsed.metadata_key:
|
|
199
|
+
if parsed.transcript_idx is None:
|
|
200
|
+
# Agent run metadata [M.key]
|
|
201
|
+
replacement = "run metadata"
|
|
202
|
+
elif parsed.block_idx is None:
|
|
203
|
+
# Transcript metadata [T0M.key]
|
|
204
|
+
replacement = f"T{parsed.transcript_idx}"
|
|
205
|
+
else:
|
|
206
|
+
# Message metadata [T0B1M.key]
|
|
207
|
+
replacement = f"T{parsed.transcript_idx}B{parsed.block_idx}"
|
|
208
|
+
else:
|
|
209
|
+
# Regular transcript block [T0B1]
|
|
210
|
+
replacement = f"T{parsed.transcript_idx}B{parsed.block_idx}"
|
|
211
|
+
|
|
132
212
|
# Current absolute start position for this replacement in the cleaned text
|
|
133
213
|
start_idx = len(cleaned_text)
|
|
134
214
|
end_idx = start_idx + len(replacement)
|
|
@@ -137,10 +217,11 @@ def parse_citations(text: str) -> tuple[str, list[Citation]]:
|
|
|
137
217
|
start_idx=start_idx,
|
|
138
218
|
end_idx=end_idx,
|
|
139
219
|
agent_run_idx=None,
|
|
140
|
-
transcript_idx=transcript_idx,
|
|
141
|
-
block_idx=block_idx,
|
|
220
|
+
transcript_idx=parsed.transcript_idx,
|
|
221
|
+
block_idx=parsed.block_idx,
|
|
142
222
|
action_unit_idx=None,
|
|
143
|
-
|
|
223
|
+
metadata_key=parsed.metadata_key,
|
|
224
|
+
start_pattern=parsed.start_pattern,
|
|
144
225
|
)
|
|
145
226
|
)
|
|
146
227
|
cleaned_text += replacement
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Judge-related data models shared across Docent components."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JudgeRunLabel(BaseModel):
|
|
10
|
+
id: str = Field(default_factory=lambda: str(uuid4()))
|
|
11
|
+
agent_run_id: str
|
|
12
|
+
rubric_id: str
|
|
13
|
+
label: dict[str, Any]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = ["JudgeRunLabel"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic_core import to_jsonable_python
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def dump_metadata(metadata: dict[str, Any]) -> str | None:
|
|
8
|
+
"""
|
|
9
|
+
Dump metadata to a JSON string.
|
|
10
|
+
We used to use YAML to save tokens, but JSON makes it easier to find cited ranges on the frontend because the frontend uses JSON.
|
|
11
|
+
"""
|
|
12
|
+
if not metadata:
|
|
13
|
+
return None
|
|
14
|
+
metadata_obj = to_jsonable_python(metadata)
|
|
15
|
+
text = json.dumps(metadata_obj, indent=2)
|
|
16
|
+
return text.strip()
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import re
|
|
2
3
|
|
|
3
4
|
from docent.data_models.agent_run import AgentRun
|
|
@@ -52,7 +53,7 @@ def find_citation_matches_in_text(text: str, start_pattern: str) -> list[tuple[i
|
|
|
52
53
|
|
|
53
54
|
def get_transcript_text_for_citation(agent_run: AgentRun, citation: Citation) -> str | None:
|
|
54
55
|
"""
|
|
55
|
-
Get the text content of a specific transcript block from an AgentRun,
|
|
56
|
+
Get the text content of a specific transcript block (or transcript/run metadata) from an AgentRun,
|
|
56
57
|
using the same formatting as shown to LLMs via format_chat_message.
|
|
57
58
|
|
|
58
59
|
Args:
|
|
@@ -62,19 +63,28 @@ def get_transcript_text_for_citation(agent_run: AgentRun, citation: Citation) ->
|
|
|
62
63
|
Returns:
|
|
63
64
|
Text content of the specified block (including tool calls), or None if not found
|
|
64
65
|
"""
|
|
65
|
-
if citation.transcript_idx is None:
|
|
66
|
-
return None
|
|
67
|
-
|
|
68
66
|
try:
|
|
69
|
-
if citation.transcript_idx
|
|
67
|
+
if citation.transcript_idx is None:
|
|
68
|
+
# At the run level, can only cite metadata
|
|
69
|
+
if citation.metadata_key is not None:
|
|
70
|
+
return json.dumps(agent_run.metadata.get(citation.metadata_key))
|
|
70
71
|
return None
|
|
72
|
+
|
|
71
73
|
transcript_id = agent_run.get_transcript_ids_ordered()[citation.transcript_idx]
|
|
72
74
|
transcript = agent_run.transcript_dict[transcript_id]
|
|
73
75
|
|
|
74
|
-
if citation.block_idx
|
|
76
|
+
if citation.block_idx is None:
|
|
77
|
+
# At the transcript level, can only cite metadata
|
|
78
|
+
if citation.metadata_key is not None:
|
|
79
|
+
return json.dumps(transcript.metadata.get(citation.metadata_key))
|
|
75
80
|
return None
|
|
81
|
+
|
|
76
82
|
message = transcript.messages[citation.block_idx]
|
|
77
83
|
|
|
84
|
+
# At the message level, can cite metadata or content
|
|
85
|
+
if citation.metadata_key is not None:
|
|
86
|
+
return json.dumps(message.metadata.get(citation.metadata_key))
|
|
87
|
+
|
|
78
88
|
# Use the same formatting function that generates content for LLMs
|
|
79
89
|
# This ensures consistent formatting between citation validation and LLM serialization
|
|
80
90
|
return format_chat_message(
|
|
@@ -99,6 +109,9 @@ def validate_citation_text_range(agent_run: AgentRun, citation: Citation) -> boo
|
|
|
99
109
|
if not citation.start_pattern:
|
|
100
110
|
# Nothing to validate
|
|
101
111
|
return True
|
|
112
|
+
if citation.metadata_key is not None:
|
|
113
|
+
# We don't need to remove invalid metadata citation ranges
|
|
114
|
+
return True
|
|
102
115
|
|
|
103
116
|
text = get_transcript_text_for_citation(agent_run, citation)
|
|
104
117
|
if text is None:
|
|
@@ -130,16 +143,16 @@ def remove_invalid_citation_ranges(text: str, agent_run: AgentRun) -> str:
|
|
|
130
143
|
# Parse this bracket content to get citation info
|
|
131
144
|
parsed = parse_single_citation(bracket_content)
|
|
132
145
|
if parsed:
|
|
133
|
-
transcript_idx, block_idx, start_pattern = parsed
|
|
134
146
|
# The citation spans from start to end in the original text
|
|
135
147
|
citation = Citation(
|
|
136
148
|
start_idx=start,
|
|
137
149
|
end_idx=end,
|
|
138
150
|
agent_run_idx=None,
|
|
139
|
-
transcript_idx=transcript_idx,
|
|
140
|
-
block_idx=block_idx,
|
|
151
|
+
transcript_idx=parsed.transcript_idx,
|
|
152
|
+
block_idx=parsed.block_idx,
|
|
141
153
|
action_unit_idx=None,
|
|
142
|
-
|
|
154
|
+
metadata_key=parsed.metadata_key,
|
|
155
|
+
start_pattern=parsed.start_pattern,
|
|
143
156
|
)
|
|
144
157
|
citations.append(citation)
|
|
145
158
|
|
docent/data_models/transcript.py
CHANGED
|
@@ -15,7 +15,7 @@ from docent.data_models._tiktoken_util import (
|
|
|
15
15
|
)
|
|
16
16
|
from docent.data_models.chat import AssistantMessage, ChatMessage, ContentReasoning
|
|
17
17
|
from docent.data_models.citation import RANGE_BEGIN, RANGE_END
|
|
18
|
-
from docent.data_models.
|
|
18
|
+
from docent.data_models.metadata_util import dump_metadata
|
|
19
19
|
|
|
20
20
|
# Template for formatting individual transcript blocks
|
|
21
21
|
TRANSCRIPT_BLOCK_TEMPLATE = """
|
|
@@ -29,6 +29,12 @@ TEXT_RANGE_CITE_INSTRUCTION = f"""Anytime you quote the transcript, or refer to
|
|
|
29
29
|
|
|
30
30
|
A citation may include a specific range of text within a block. Use {RANGE_BEGIN} and {RANGE_END} to mark the specific range of text. Add it after the block ID separated by a colon. For example, to cite the part of transcript 0, block 1, where the agent says "I understand the task", write [T0B1:{RANGE_BEGIN}I understand the task{RANGE_END}]. Citations must follow this exact format. The markers {RANGE_BEGIN} and {RANGE_END} must be used ONLY inside the brackets of a citation.
|
|
31
31
|
|
|
32
|
+
- You may cite a top-level key in the agent run metadata like this: [M.task_description].
|
|
33
|
+
- You may cite a top-level key in transcript metadata. For example, for transcript 0: [T0M.start_time].
|
|
34
|
+
- You may cite a top-level key in message metadata for a block. For example, for transcript 0, block 1: [T0B1M.status].
|
|
35
|
+
- You may not cite nested keys. For example, [T0B1M.status.code] is invalid.
|
|
36
|
+
- Within a top-level metadata key you may cite a range of text that appears in the value. For example, [T0B1M.status:{RANGE_BEGIN}"running":false{RANGE_END}].
|
|
37
|
+
|
|
32
38
|
Important notes:
|
|
33
39
|
- You must include the full content of the text range {RANGE_BEGIN} and {RANGE_END}, EXACTLY as it appears in the transcript, word-for-word, including any markers or punctuation that appear in the middle of the text.
|
|
34
40
|
- Citations must be as specific as possible. This means you should usually cite a specific text range within a block.
|
|
@@ -73,9 +79,9 @@ def format_chat_message(
|
|
|
73
79
|
cur_content += f"\n<tool call>\n{tool_call.function}({args})\n</tool call>"
|
|
74
80
|
|
|
75
81
|
if message.metadata:
|
|
76
|
-
|
|
77
|
-
if
|
|
78
|
-
cur_content += f"\n<|message metadata|>\n{
|
|
82
|
+
metadata_text = dump_metadata(message.metadata)
|
|
83
|
+
if metadata_text is not None:
|
|
84
|
+
cur_content += f"\n<|message metadata|>\n{metadata_text}\n</|message metadata|>"
|
|
79
85
|
|
|
80
86
|
return TRANSCRIPT_BLOCK_TEMPLATE.format(
|
|
81
87
|
index_label=index_label, role=message.role, content=cur_content
|
|
@@ -127,13 +133,11 @@ class TranscriptGroup(BaseModel):
|
|
|
127
133
|
str: XML-like wrapped text including the group's metadata.
|
|
128
134
|
"""
|
|
129
135
|
# Prepare YAML metadata
|
|
130
|
-
|
|
131
|
-
if
|
|
136
|
+
metadata_text = dump_metadata(self.metadata)
|
|
137
|
+
if metadata_text is not None:
|
|
132
138
|
if indent > 0:
|
|
133
|
-
|
|
134
|
-
inner =
|
|
135
|
-
f"{children_text}\n<|{self.name} metadata|>\n{yaml_text}\n</|{self.name} metadata|>"
|
|
136
|
-
)
|
|
139
|
+
metadata_text = textwrap.indent(metadata_text, " " * indent)
|
|
140
|
+
inner = f"{children_text}\n<|{self.name} metadata|>\n{metadata_text}\n</|{self.name} metadata|>"
|
|
137
141
|
else:
|
|
138
142
|
inner = children_text
|
|
139
143
|
|
|
@@ -447,13 +451,11 @@ class Transcript(BaseModel):
|
|
|
447
451
|
content_str = f"<|T{transcript_idx} blocks|>\n{blocks_str}\n</|T{transcript_idx} blocks|>"
|
|
448
452
|
|
|
449
453
|
# Gather metadata and add to content
|
|
450
|
-
|
|
451
|
-
if
|
|
454
|
+
metadata_text = dump_metadata(self.metadata)
|
|
455
|
+
if metadata_text is not None:
|
|
452
456
|
if indent > 0:
|
|
453
|
-
|
|
454
|
-
content_str +=
|
|
455
|
-
f"\n<|T{transcript_idx} metadata|>\n{yaml_text}\n</|T{transcript_idx} metadata|>"
|
|
456
|
-
)
|
|
457
|
+
metadata_text = textwrap.indent(metadata_text, " " * indent)
|
|
458
|
+
content_str += f"\n<|T{transcript_idx} metadata|>\n{metadata_text}\n</|T{transcript_idx} metadata|>"
|
|
457
459
|
|
|
458
460
|
# Format content and return
|
|
459
461
|
if indent > 0:
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Iterable, List, TypeVar
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from docent.data_models.agent_run import AgentRun
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T", bound=BaseModel)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _deep_copy_model(model: T) -> T:
|
|
14
|
+
"""Create a deep copy of a Pydantic v2 model.
|
|
15
|
+
|
|
16
|
+
Using `model_copy(deep=True)` ensures nested models are fully copied and
|
|
17
|
+
mutations do not affect the original instance.
|
|
18
|
+
"""
|
|
19
|
+
return model.model_copy(deep=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def clone_agent_run_with_random_ids(agent_run: AgentRun) -> AgentRun:
|
|
23
|
+
"""Clone an `AgentRun`, randomizing all IDs and fixing internal references.
|
|
24
|
+
|
|
25
|
+
The following transformations are performed on the cloned instance:
|
|
26
|
+
- Assign a new `AgentRun.id`.
|
|
27
|
+
- Assign new `Transcript.id` values and update any references to them (none today).
|
|
28
|
+
- Assign new `TranscriptGroup.id` values.
|
|
29
|
+
- Update `Transcript.transcript_group_id` to the new group IDs where applicable.
|
|
30
|
+
- Update `TranscriptGroup.agent_run_id` to the new `AgentRun.id`.
|
|
31
|
+
- Update `TranscriptGroup.parent_transcript_group_id` to the new group IDs where applicable.
|
|
32
|
+
|
|
33
|
+
Notes:
|
|
34
|
+
- If a `parent_transcript_group_id` or `transcript_group_id` references a group id that
|
|
35
|
+
is not present in the cloned run, the reference is left unchanged (mirrors importer behavior).
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
agent_run: The source `AgentRun` to clone.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A new, independent `AgentRun` instance with randomized identifiers and consistent references.
|
|
42
|
+
"""
|
|
43
|
+
# Validate source integrity before cloning
|
|
44
|
+
# - No duplicate transcript or group IDs
|
|
45
|
+
# - All transcript.group references exist if set
|
|
46
|
+
# - All group.parent references exist if set
|
|
47
|
+
# - All group.agent_run_id match the source run id
|
|
48
|
+
src_transcript_ids = [str(t.id) for t in agent_run.transcripts]
|
|
49
|
+
if len(src_transcript_ids) != len(set(src_transcript_ids)):
|
|
50
|
+
raise ValueError("Duplicate transcript ids detected in source AgentRun")
|
|
51
|
+
|
|
52
|
+
src_group_ids = [str(g.id) for g in agent_run.transcript_groups]
|
|
53
|
+
if len(src_group_ids) != len(set(src_group_ids)):
|
|
54
|
+
raise ValueError("Duplicate transcript group ids detected in source AgentRun")
|
|
55
|
+
|
|
56
|
+
src_group_id_set = set(src_group_ids)
|
|
57
|
+
for t in agent_run.transcripts:
|
|
58
|
+
if t.transcript_group_id is not None and str(t.transcript_group_id) not in src_group_id_set:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"Transcript {t.id} references missing transcript_group_id {t.transcript_group_id}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
for g in agent_run.transcript_groups:
|
|
64
|
+
if (
|
|
65
|
+
g.parent_transcript_group_id is not None
|
|
66
|
+
and str(g.parent_transcript_group_id) not in src_group_id_set
|
|
67
|
+
):
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"TranscriptGroup {g.id} references missing parent_transcript_group_id {g.parent_transcript_group_id}"
|
|
70
|
+
)
|
|
71
|
+
if str(g.agent_run_id) != str(agent_run.id):
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"TranscriptGroup {g.id} has agent_run_id {g.agent_run_id} which does not match AgentRun.id {agent_run.id}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Deep copy first so we never mutate the caller's instance
|
|
77
|
+
new_run = _deep_copy_model(agent_run)
|
|
78
|
+
|
|
79
|
+
# 1) Randomize AgentRun ID
|
|
80
|
+
new_agent_run_id = str(uuid4())
|
|
81
|
+
old_to_new_transcript_id: Dict[str, str] = {}
|
|
82
|
+
old_to_new_group_id: Dict[str, str] = {}
|
|
83
|
+
|
|
84
|
+
# 2) Pre-compute new IDs for transcripts and transcript groups without mutating yet
|
|
85
|
+
for transcript in new_run.transcripts:
|
|
86
|
+
old_to_new_transcript_id[str(transcript.id)] = str(uuid4())
|
|
87
|
+
|
|
88
|
+
for group in new_run.transcript_groups:
|
|
89
|
+
old_to_new_group_id[str(group.id)] = str(uuid4())
|
|
90
|
+
|
|
91
|
+
# 3) Mutate transcript groups: set new id, set agent_run_id, remap parents
|
|
92
|
+
for group in new_run.transcript_groups:
|
|
93
|
+
old_group_id = str(group.id)
|
|
94
|
+
|
|
95
|
+
# Assign new group id
|
|
96
|
+
group.id = old_to_new_group_id.get(old_group_id, str(uuid4()))
|
|
97
|
+
|
|
98
|
+
# Ensure group points to the new agent run id
|
|
99
|
+
group.agent_run_id = new_agent_run_id
|
|
100
|
+
|
|
101
|
+
# Remap parent id; raise if unknown
|
|
102
|
+
if group.parent_transcript_group_id is not None:
|
|
103
|
+
old_parent_id = str(group.parent_transcript_group_id)
|
|
104
|
+
if old_parent_id not in old_to_new_group_id:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"TranscriptGroup {old_group_id} parent_transcript_group_id {old_parent_id} not found in this AgentRun"
|
|
107
|
+
)
|
|
108
|
+
group.parent_transcript_group_id = old_to_new_group_id[old_parent_id]
|
|
109
|
+
|
|
110
|
+
# 4) Mutate transcripts: set new id, remap transcript_group_id
|
|
111
|
+
for transcript in new_run.transcripts:
|
|
112
|
+
old_transcript_id = str(transcript.id)
|
|
113
|
+
|
|
114
|
+
# Assign new transcript id
|
|
115
|
+
transcript.id = old_to_new_transcript_id.get(old_transcript_id, str(uuid4()))
|
|
116
|
+
|
|
117
|
+
# Remap group reference; raise if unknown
|
|
118
|
+
if transcript.transcript_group_id is not None:
|
|
119
|
+
old_group_id_ref = str(transcript.transcript_group_id)
|
|
120
|
+
if old_group_id_ref not in old_to_new_group_id:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Transcript {old_transcript_id} references transcript_group_id {old_group_id_ref} not found in this AgentRun"
|
|
123
|
+
)
|
|
124
|
+
transcript.transcript_group_id = old_to_new_group_id[old_group_id_ref]
|
|
125
|
+
|
|
126
|
+
# 5) Finally set the new run id
|
|
127
|
+
new_run.id = new_agent_run_id
|
|
128
|
+
|
|
129
|
+
# Post-validate integrity on the cloned run
|
|
130
|
+
new_group_ids = [str(g.id) for g in new_run.transcript_groups]
|
|
131
|
+
if len(new_group_ids) != len(set(new_group_ids)):
|
|
132
|
+
raise ValueError("Duplicate transcript group ids detected after cloning")
|
|
133
|
+
new_group_id_set = set(new_group_ids)
|
|
134
|
+
|
|
135
|
+
new_transcript_ids = [str(t.id) for t in new_run.transcripts]
|
|
136
|
+
if len(new_transcript_ids) != len(set(new_transcript_ids)):
|
|
137
|
+
raise ValueError("Duplicate transcript ids detected after cloning")
|
|
138
|
+
|
|
139
|
+
for t in new_run.transcripts:
|
|
140
|
+
if t.transcript_group_id is not None and str(t.transcript_group_id) not in new_group_id_set:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"Transcript {t.id} references missing transcript_group_id {t.transcript_group_id} after cloning"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
for g in new_run.transcript_groups:
|
|
146
|
+
if (
|
|
147
|
+
g.parent_transcript_group_id is not None
|
|
148
|
+
and str(g.parent_transcript_group_id) not in new_group_id_set
|
|
149
|
+
):
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"TranscriptGroup {g.id} references missing parent_transcript_group_id {g.parent_transcript_group_id} after cloning"
|
|
152
|
+
)
|
|
153
|
+
if str(g.agent_run_id) != str(new_run.id):
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"TranscriptGroup {g.id} has agent_run_id {g.agent_run_id} which does not match cloned AgentRun.id {new_run.id}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return new_run
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def clone_agent_runs_with_random_ids(agent_runs: Iterable[AgentRun]) -> List[AgentRun]:
|
|
162
|
+
"""Clone a sequence of `AgentRun` objects with randomized IDs.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
agent_runs: Iterable of `AgentRun` instances to clone.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
A list of cloned `AgentRun` instances with fresh IDs and consistent references.
|
|
169
|
+
"""
|
|
170
|
+
return [clone_agent_run_with_random_ids(ar) for ar in agent_runs]
|
docent/sdk/agent_run_writer.py
CHANGED
|
@@ -4,11 +4,12 @@ import queue
|
|
|
4
4
|
import signal
|
|
5
5
|
import threading
|
|
6
6
|
import time
|
|
7
|
-
from typing import Any, Callable, Coroutine, Optional
|
|
7
|
+
from typing import Any, AsyncGenerator, Callable, Coroutine, Optional
|
|
8
8
|
|
|
9
9
|
import anyio
|
|
10
10
|
import backoff
|
|
11
11
|
import httpx
|
|
12
|
+
import orjson
|
|
12
13
|
from backoff.types import Details
|
|
13
14
|
|
|
14
15
|
from docent._log_util.logger import get_logger
|
|
@@ -38,6 +39,15 @@ def _print_backoff_message(e: Details):
|
|
|
38
39
|
)
|
|
39
40
|
|
|
40
41
|
|
|
42
|
+
async def _generate_payload_chunks(runs: list[AgentRun]) -> AsyncGenerator[bytes, None]:
|
|
43
|
+
yield b'{"agent_runs": ['
|
|
44
|
+
for i, ar in enumerate(runs):
|
|
45
|
+
if i > 0:
|
|
46
|
+
yield b","
|
|
47
|
+
yield orjson.dumps(ar.model_dump(mode="json"))
|
|
48
|
+
yield b"]}"
|
|
49
|
+
|
|
50
|
+
|
|
41
51
|
class AgentRunWriter:
|
|
42
52
|
"""Background thread for logging agent runs.
|
|
43
53
|
|
|
@@ -175,7 +185,7 @@ class AgentRunWriter:
|
|
|
175
185
|
logger.info("Cancelling pending tasks...")
|
|
176
186
|
self._cancel_event.set()
|
|
177
187
|
n_pending = self._queue.qsize()
|
|
178
|
-
logger.info(f"Cancelled ~{n_pending} pending
|
|
188
|
+
logger.info(f"Cancelled ~{n_pending} pending runs")
|
|
179
189
|
|
|
180
190
|
# Give a brief moment to exit
|
|
181
191
|
logger.info("Waiting for thread to exit...")
|
|
@@ -194,8 +204,11 @@ class AgentRunWriter:
|
|
|
194
204
|
on_backoff=_print_backoff_message,
|
|
195
205
|
)
|
|
196
206
|
async def _post_batch(batch: list[AgentRun]) -> None:
|
|
197
|
-
|
|
198
|
-
|
|
207
|
+
resp = await client.post(
|
|
208
|
+
self._endpoint,
|
|
209
|
+
content=_generate_payload_chunks(batch),
|
|
210
|
+
timeout=self._request_timeout,
|
|
211
|
+
)
|
|
199
212
|
resp.raise_for_status()
|
|
200
213
|
|
|
201
214
|
return _post_batch
|
|
@@ -246,7 +259,7 @@ def init(
|
|
|
246
259
|
web_url: str = "https://docent.transluce.org",
|
|
247
260
|
api_key: str | None = None,
|
|
248
261
|
# Writer arguments
|
|
249
|
-
num_workers: int =
|
|
262
|
+
num_workers: int = 4,
|
|
250
263
|
queue_maxsize: int = 20_000,
|
|
251
264
|
request_timeout: float = 30.0,
|
|
252
265
|
flush_interval: float = 1.0,
|