atif-sql 0.2.0__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.
- atif_analytics/__init__.py +9 -0
- atif_analytics/application/__init__.py +3 -0
- atif_analytics/application/analyze.py +158 -0
- atif_analytics/application/prompts.py +750 -0
- atif_analytics/application/use_cases/__init__.py +3 -0
- atif_analytics/application/use_cases/_shared.py +264 -0
- atif_analytics/application/use_cases/classify.py +430 -0
- atif_analytics/application/use_cases/conflicts.py +448 -0
- atif_analytics/application/use_cases/friction.py +634 -0
- atif_analytics/application/use_cases/perceived.py +451 -0
- atif_analytics/domain/__init__.py +3 -0
- atif_analytics/domain/authorship.py +166 -0
- atif_analytics/domain/config.py +30 -0
- atif_analytics/domain/costs.py +50 -0
- atif_analytics/domain/friction.py +117 -0
- atif_analytics/domain/layout.py +94 -0
- atif_analytics/domain/models.py +385 -0
- atif_analytics/domain/ports.py +100 -0
- atif_analytics/domain/transcript.py +359 -0
- atif_analytics/infrastructure/__init__.py +3 -0
- atif_analytics/infrastructure/corpus_reader.py +546 -0
- atif_analytics/infrastructure/parquet_cache.py +186 -0
- atif_analytics/infrastructure/settings.py +114 -0
- atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
- atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
- atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
- atif_analytics/py.typed +0 -0
- atif_cli/__init__.py +3 -0
- atif_cli/__main__.py +8 -0
- atif_cli/app.py +2459 -0
- atif_cli/converter_adapter.py +151 -0
- atif_cli/corpus.py +409 -0
- atif_cli/cron.py +311 -0
- atif_cli/duck_errors.py +97 -0
- atif_cli/embed_lake.py +101 -0
- atif_cli/errors.py +89 -0
- atif_cli/lake.py +373 -0
- atif_cli/lake_sessions.py +270 -0
- atif_cli/output.py +297 -0
- atif_cli/py.typed +0 -0
- atif_converter/__init__.py +3 -0
- atif_converter/application/__init__.py +3 -0
- atif_converter/application/convert_and_audit.py +294 -0
- atif_converter/application/convert_codex.py +210 -0
- atif_converter/domain/__init__.py +9 -0
- atif_converter/domain/agents.py +40 -0
- atif_converter/domain/atif/__init__.py +55 -0
- atif_converter/domain/atif/agent.py +37 -0
- atif_converter/domain/atif/content.py +161 -0
- atif_converter/domain/atif/final_metrics.py +45 -0
- atif_converter/domain/atif/metrics.py +49 -0
- atif_converter/domain/atif/observation.py +21 -0
- atif_converter/domain/atif/observation_result.py +47 -0
- atif_converter/domain/atif/step.py +144 -0
- atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
- atif_converter/domain/atif/tool_call.py +36 -0
- atif_converter/domain/atif/trajectory.py +211 -0
- atif_converter/domain/atif/trajectory_validator.py +299 -0
- atif_converter/domain/blobs.py +510 -0
- atif_converter/domain/claude_code_conversion.py +1077 -0
- atif_converter/domain/codex_conversion.py +1036 -0
- atif_converter/domain/codex_edges.py +192 -0
- atif_converter/domain/codex_enrichment.py +419 -0
- atif_converter/domain/codex_fidelity.py +124 -0
- atif_converter/domain/codex_nested_calls.py +165 -0
- atif_converter/domain/edges.py +113 -0
- atif_converter/domain/enrichment.py +412 -0
- atif_converter/domain/errors.py +60 -0
- atif_converter/domain/fidelity.py +171 -0
- atif_converter/domain/model_prices.json +8538 -0
- atif_converter/domain/pricing.py +790 -0
- atif_converter/domain/result_signals.py +774 -0
- atif_converter/domain/schema_version.py +90 -0
- atif_converter/domain/session_events.py +408 -0
- atif_converter/infrastructure/__init__.py +3 -0
- atif_converter/infrastructure/census.py +89 -0
- atif_converter/infrastructure/claude_code_converter.py +127 -0
- atif_converter/infrastructure/codex_adapter.py +132 -0
- atif_converter/infrastructure/codex_census.py +100 -0
- atif_converter/infrastructure/codex_converter.py +82 -0
- atif_converter/infrastructure/harbor_adapter.py +187 -0
- atif_converter/infrastructure/raw_records.py +426 -0
- atif_converter/infrastructure/source_archive.py +154 -0
- atif_converter/py.typed +0 -0
- atif_corpus/__init__.py +13 -0
- atif_corpus/application/__init__.py +7 -0
- atif_corpus/application/materialize.py +1826 -0
- atif_corpus/domain/__init__.py +9 -0
- atif_corpus/domain/agents.py +33 -0
- atif_corpus/domain/generation.py +34 -0
- atif_corpus/domain/layout.py +168 -0
- atif_corpus/domain/ports.py +175 -0
- atif_corpus/domain/session_id.py +68 -0
- atif_corpus/domain/sessions.py +228 -0
- atif_corpus/domain/slug.py +72 -0
- atif_corpus/domain/source_layout.py +143 -0
- atif_corpus/domain/watermark.py +76 -0
- atif_corpus/infrastructure/__init__.py +7 -0
- atif_corpus/infrastructure/atomic.py +266 -0
- atif_corpus/infrastructure/compress_artifacts.py +161 -0
- atif_corpus/infrastructure/fake_converter.py +174 -0
- atif_corpus/infrastructure/scanner.py +316 -0
- atif_corpus/infrastructure/settings.py +130 -0
- atif_corpus/infrastructure/source_archive.py +202 -0
- atif_corpus/py.typed +0 -0
- atif_duck/__init__.py +43 -0
- atif_duck/domain/__init__.py +32 -0
- atif_duck/domain/artifacts.py +66 -0
- atif_duck/domain/authorship.py +165 -0
- atif_duck/domain/catalog.py +626 -0
- atif_duck/domain/columnar.py +149 -0
- atif_duck/domain/embedding_guard.py +88 -0
- atif_duck/domain/examples.py +265 -0
- atif_duck/domain/lake.py +427 -0
- atif_duck/domain/lake_sessions.py +97 -0
- atif_duck/domain/raw_readers.py +140 -0
- atif_duck/domain/session_id.py +69 -0
- atif_duck/domain/sql_literal.py +50 -0
- atif_duck/infrastructure/__init__.py +34 -0
- atif_duck/infrastructure/analytics.py +469 -0
- atif_duck/infrastructure/authorship.py +139 -0
- atif_duck/infrastructure/columnar.py +641 -0
- atif_duck/infrastructure/lake.py +1383 -0
- atif_duck/infrastructure/lake_sessions.py +194 -0
- atif_duck/infrastructure/lake_settings.py +63 -0
- atif_duck/infrastructure/lake_steps.py +225 -0
- atif_duck/infrastructure/projections.py +221 -0
- atif_duck/infrastructure/registry.py +2194 -0
- atif_duck/infrastructure/stored_artifacts.py +115 -0
- atif_duck/py.typed +0 -0
- atif_embed/__init__.py +12 -0
- atif_embed/application/__init__.py +3 -0
- atif_embed/application/embed.py +293 -0
- atif_embed/application/prune.py +80 -0
- atif_embed/domain/__init__.py +3 -0
- atif_embed/domain/discovery.py +70 -0
- atif_embed/domain/embedding_guard.py +83 -0
- atif_embed/domain/errors.py +94 -0
- atif_embed/domain/ports.py +223 -0
- atif_embed/domain/sql_literal.py +49 -0
- atif_embed/domain/text_stamp.py +71 -0
- atif_embed/infrastructure/__init__.py +3 -0
- atif_embed/infrastructure/cohere_bedrock.py +433 -0
- atif_embed/infrastructure/corpus_text_rows.py +273 -0
- atif_embed/infrastructure/gemma_local.py +355 -0
- atif_embed/infrastructure/lake_text_rows.py +203 -0
- atif_embed/infrastructure/lance_store.py +440 -0
- atif_embed/infrastructure/providers.py +30 -0
- atif_embed/infrastructure/settings.py +154 -0
- atif_embed/py.typed +0 -0
- atif_models/__init__.py +14 -0
- atif_models/domain/__init__.py +9 -0
- atif_models/domain/ports.py +143 -0
- atif_models/domain/registry.py +151 -0
- atif_models/domain/schema.py +109 -0
- atif_models/infrastructure/__init__.py +8 -0
- atif_models/infrastructure/openai_bedrock.py +314 -0
- atif_models/infrastructure/settings.py +85 -0
- atif_models/py.typed +0 -0
- atif_sql-0.2.0.dist-info/METADATA +272 -0
- atif_sql-0.2.0.dist-info/RECORD +164 -0
- atif_sql-0.2.0.dist-info/WHEEL +4 -0
- atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
- atif_sql-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""The port the pipelines read session data through.
|
|
4
|
+
|
|
5
|
+
Two adapters satisfy it. :class:`~atif_analytics.infrastructure.corpus_reader.TrajectoryFileSource`
|
|
6
|
+
parses each session directory's ``trajectory.json`` and ``edges.jsonl``, and is
|
|
7
|
+
the default. The lake adapter lives in atif-cli, because atif-analytics may not
|
|
8
|
+
import atif-duck: it reads the same sessions from the DuckLake every corpus is
|
|
9
|
+
loaded into, and falls back to the file adapter per session when the lake does
|
|
10
|
+
not hold a session's current artifacts.
|
|
11
|
+
|
|
12
|
+
Both adapters hand back :class:`~atif_analytics.domain.transcript.StepEvent`
|
|
13
|
+
rows built by :func:`~atif_analytics.domain.transcript.step_event`, so the
|
|
14
|
+
projection rules (role names, the first source uuid, the error flag, the
|
|
15
|
+
timestamp spelling, and the author label from
|
|
16
|
+
:mod:`atif_analytics.domain.authorship`) are written once. What a source may
|
|
17
|
+
choose is how it batches: reading one session from the lake costs about as
|
|
18
|
+
much as reading sixty-four, while parsing a trajectory costs per session.
|
|
19
|
+
|
|
20
|
+
:class:`~atif_analytics.infrastructure.corpus_reader.CorpusReader` wraps a
|
|
21
|
+
source with the window filter, the newest-first order, the bounded memos and
|
|
22
|
+
the transcript renderer; the pipelines only ever see the reader.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import TYPE_CHECKING, Protocol
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Mapping, Sequence
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
|
|
33
|
+
from atif_analytics.domain.transcript import StepEvent
|
|
34
|
+
|
|
35
|
+
#: ``{session_id: (last_record_ts, trajectory_mtime)}``: the two bounds the
|
|
36
|
+
#: checkpoint compares (either one advancing re-admits a session).
|
|
37
|
+
SessionBounds = dict[str, tuple["datetime | None", "datetime | None"]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SessionSource(Protocol):
|
|
41
|
+
"""Where one corpus's session data comes from."""
|
|
42
|
+
|
|
43
|
+
#: A short name for logs and the analyze summary (``files`` or ``lake``).
|
|
44
|
+
name: str
|
|
45
|
+
#: How many sessions one :meth:`load_turns` call should carry. The reader
|
|
46
|
+
#: reads ahead in the newest-first order by this many; 1 turns that off.
|
|
47
|
+
turns_batch_size: int
|
|
48
|
+
#: The same for :meth:`load_steps`, which carries the tool payloads.
|
|
49
|
+
steps_batch_size: int
|
|
50
|
+
#: True when :meth:`load_turns` already returns everything
|
|
51
|
+
#: :meth:`load_steps` does (a trajectory parse yields the payloads anyway),
|
|
52
|
+
#: so the reader keeps one memo and loads each session once.
|
|
53
|
+
turns_are_complete: bool
|
|
54
|
+
|
|
55
|
+
def session_bounds(self) -> SessionBounds:
|
|
56
|
+
"""Every complete session's bounds, in no particular order.
|
|
57
|
+
|
|
58
|
+
Must not read a session's steps: this runs over the whole corpus once
|
|
59
|
+
per stage, including every session the checkpoint then skips.
|
|
60
|
+
"""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
def batchable(self, session_id: str) -> bool:
|
|
64
|
+
"""Whether read-ahead may pull this session into another session's batch.
|
|
65
|
+
|
|
66
|
+
False for a session the source would read on its own anyway (the
|
|
67
|
+
lake source's file fallback parses one trajectory per session, so
|
|
68
|
+
reading one ahead costs a full parse the checkpoint may never need).
|
|
69
|
+
The requested session is always loaded; this only gates the extras.
|
|
70
|
+
"""
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
def load_turns(self, session_ids: Sequence[str]) -> Mapping[str, list[StepEvent]]:
|
|
74
|
+
"""Each session's steps, in order, with the tool payloads optional.
|
|
75
|
+
|
|
76
|
+
Every step is present with its text, flags, uuid, author and
|
|
77
|
+
``has_error_result``; ``tool_calls`` and ``tool_results`` may be left
|
|
78
|
+
empty. The gates (session kind, human-AI pairs, friction candidates
|
|
79
|
+
and stamps) read nothing else. Every requested id is a key of the
|
|
80
|
+
result; an unreadable session maps to an empty list.
|
|
81
|
+
"""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
def load_steps(self, session_ids: Sequence[str]) -> Mapping[str, list[StepEvent]]:
|
|
85
|
+
"""Each session's steps, in order, with every tool call and result.
|
|
86
|
+
|
|
87
|
+
What the transcript renderer reads. Same key rule as :meth:`load_turns`.
|
|
88
|
+
"""
|
|
89
|
+
...
|
|
90
|
+
|
|
91
|
+
def edges_uuids(self, session_id: str) -> set[str] | None:
|
|
92
|
+
"""The session's non-null raw-record uuids, or ``None`` when unknown.
|
|
93
|
+
|
|
94
|
+
``None`` and an empty set are different answers (see
|
|
95
|
+
:meth:`~atif_analytics.infrastructure.corpus_reader.CorpusReader.edges_uuids`).
|
|
96
|
+
"""
|
|
97
|
+
...
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = ["SessionBounds", "SessionSource"]
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Pure transcript rendering over materialized ATIF steps.
|
|
4
|
+
|
|
5
|
+
This is the domain half of the corpus-reader seam. A session source
|
|
6
|
+
(:mod:`atif_analytics.domain.ports`: the per-session files, or the lake)
|
|
7
|
+
turns a session's steps into :class:`StepEvent` rows through
|
|
8
|
+
:func:`step_event`; this module turns them into the byte-shape the prompts
|
|
9
|
+
are tuned on
|
|
10
|
+
(CONTRACT-V2 §Ports & state: transcript text for prompts is steps-based
|
|
11
|
+
rendering under the caps below):
|
|
12
|
+
|
|
13
|
+
* ``[role ts] text`` lines (``[uuid=<edges-uuid> role ts] text`` for the
|
|
14
|
+
conflicts variant — the uuid is the step's FIRST ``source_uuids`` entry,
|
|
15
|
+
the documented primary raw-record key);
|
|
16
|
+
* ``[tool_use:<name> ts] <args preview>`` — 400-char preview;
|
|
17
|
+
* ``[tool_result <call_id> ts] <content preview>`` — 50K-char cap with the
|
|
18
|
+
chars-dropped footer;
|
|
19
|
+
* whole-session cap 800K chars with the truncation notice.
|
|
20
|
+
|
|
21
|
+
Rendering rules the prompts depend on (documented per CONTRACT-V2):
|
|
22
|
+
|
|
23
|
+
* role vocabulary — ATIF ``Step.source`` is ``user``/``agent``; ``agent``
|
|
24
|
+
renders as ``assistant`` so the prompts' "user turns, assistant turns"
|
|
25
|
+
framing keeps matching the transcript. A user-role step a HUMAN did not
|
|
26
|
+
write (:mod:`atif_analytics.domain.authorship`) renders under its author
|
|
27
|
+
label instead — ``stop_hook``, ``task_notification``, ``harness``,
|
|
28
|
+
``audit_prompt`` — with its body clipped to
|
|
29
|
+
:data:`NON_HUMAN_PREVIEW_CHARS`, so the model never reads hook feedback or
|
|
30
|
+
a skill body as the user speaking, and a 20K-char skill body costs 300.
|
|
31
|
+
* sidechain steps are EXCLUDED: the prompts are calibrated on transcripts
|
|
32
|
+
containing no subagent content, and harbor inlines sidechains into the
|
|
33
|
+
flat step list, so they must be filtered back out here.
|
|
34
|
+
* timestamps are spelled by :func:`format_step_ts`: ISO-8601 UTC with
|
|
35
|
+
milliseconds and a ``Z`` (``2026-08-20T10:00:00.000Z``), which is how both
|
|
36
|
+
agents write them, so on a real transcript it is the ATIF string verbatim.
|
|
37
|
+
Both session sources spell it through the one function, because the lake
|
|
38
|
+
keeps a typed timestamp rather than the source text. Not a Python
|
|
39
|
+
``isoformat()`` round-trip (that writes ``+00:00``).
|
|
40
|
+
* empty-text steps contribute no text line — an ATIF step carrying only
|
|
41
|
+
tool calls has ``message == ""`` and has nothing to render.
|
|
42
|
+
|
|
43
|
+
Header forgery: a rendered line's ``[uuid=... role ts]`` prefix is the ONLY
|
|
44
|
+
thing telling the model which turn a body belongs to, and step bodies are
|
|
45
|
+
untrusted — a transcript can contain a pasted transcript. Bodies therefore
|
|
46
|
+
have their own ``[uuid=`` sequences neutralized (:func:`escape_uuid_headers`)
|
|
47
|
+
so a body can never present itself as a turn header the model would attribute
|
|
48
|
+
a real uuid to.
|
|
49
|
+
|
|
50
|
+
Text rendered here is sent to a third-party model provider: session bodies,
|
|
51
|
+
tool arguments, and tool results leave this machine on that path.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import json
|
|
57
|
+
import re
|
|
58
|
+
from dataclasses import dataclass, field
|
|
59
|
+
from datetime import UTC, datetime
|
|
60
|
+
from typing import TYPE_CHECKING
|
|
61
|
+
|
|
62
|
+
from atif_analytics.domain.authorship import SessionKind, kind_of, step_author
|
|
63
|
+
|
|
64
|
+
if TYPE_CHECKING:
|
|
65
|
+
from collections.abc import Iterable
|
|
66
|
+
|
|
67
|
+
#: Default per-``tool_use`` args preview length.
|
|
68
|
+
TOOL_INPUT_PREVIEW_CHARS: int = 400
|
|
69
|
+
|
|
70
|
+
#: The literal a forged turn header would have to open with. Matched
|
|
71
|
+
#: case-insensitively because a model reading the transcript does not
|
|
72
|
+
#: tokenize case-sensitively either.
|
|
73
|
+
_UUID_HEADER_RE = re.compile(r"\[uuid=", re.IGNORECASE)
|
|
74
|
+
|
|
75
|
+
#: What a body's ``[uuid=`` becomes. The opening bracket is what makes a
|
|
76
|
+
#: header, so swapping it for a paren removes the forgery while leaving the
|
|
77
|
+
#: content readable and the length unchanged (the caps arithmetic still
|
|
78
|
+
#: measures the same string).
|
|
79
|
+
UUID_HEADER_ESCAPE: str = "(uuid="
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def escape_uuid_headers(text: str) -> str:
|
|
83
|
+
"""Neutralize ``[uuid=`` sequences in an untrusted body.
|
|
84
|
+
|
|
85
|
+
The renderer's own headers are built AFTER this runs, so only body
|
|
86
|
+
content is rewritten. Without it a body containing
|
|
87
|
+
``[uuid=<other-id> user <ts>] ...`` is byte-identical to a real header,
|
|
88
|
+
and the model can be steered to return a uuid it was never shown —
|
|
89
|
+
which then reaches the parquet as a legitimate-looking turn key.
|
|
90
|
+
"""
|
|
91
|
+
return _UUID_HEADER_RE.sub(UUID_HEADER_ESCAPE, text)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
#: Body budget for a user-role step a human did not write. Enough to show
|
|
95
|
+
#: what the machine said (the first lines of a Stop hook's verdict, the task
|
|
96
|
+
#: a notification reports on) without paying for a whole skill body.
|
|
97
|
+
NON_HUMAN_PREVIEW_CHARS: int = 300
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def tool_input_preview(
|
|
101
|
+
tool_input_json: str | None, max_chars: int = TOOL_INPUT_PREVIEW_CHARS
|
|
102
|
+
) -> str:
|
|
103
|
+
"""Truncate a ``tool_input`` JSON blob to the first ``max_chars``."""
|
|
104
|
+
if not tool_input_json:
|
|
105
|
+
return ""
|
|
106
|
+
s = str(tool_input_json)
|
|
107
|
+
return s if len(s) <= max_chars else s[:max_chars] + "…(truncated)"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def tool_result_preview(content: str | None, max_chars: int) -> str:
|
|
111
|
+
"""Truncate a ``tool_result`` content blob with a "chars dropped" footer."""
|
|
112
|
+
if not content:
|
|
113
|
+
return ""
|
|
114
|
+
s = str(content)
|
|
115
|
+
if len(s) <= max_chars:
|
|
116
|
+
return s
|
|
117
|
+
dropped = len(s) - max_chars
|
|
118
|
+
return s[:max_chars] + "\n…(truncated, " + str(dropped) + " chars dropped)"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(slots=True)
|
|
122
|
+
class StepEvent:
|
|
123
|
+
"""One materialized ATIF step, projected to what the renderer needs.
|
|
124
|
+
|
|
125
|
+
``uuid`` is the step's FIRST ``extra.source_uuids`` entry (the primary
|
|
126
|
+
raw-record uuid per CONTRACT-V2 — the same choice the VSS branch keys
|
|
127
|
+
embeddings on), or ``None`` when the enrichment pass recorded none.
|
|
128
|
+
``tool_calls`` is ``[(function_name, args_json)]``; ``tool_results`` is
|
|
129
|
+
``[(source_call_id, content_str)]``. ``author`` is the
|
|
130
|
+
:func:`~atif_analytics.domain.authorship.step_author` label for a user
|
|
131
|
+
step (``harness`` for a compaction summary) and ``None`` for any other
|
|
132
|
+
role; left unset, it is derived from ``role`` and ``text``.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
ts: str
|
|
136
|
+
role: str # "user" | "assistant" (ATIF source mapped)
|
|
137
|
+
text: str
|
|
138
|
+
uuid: str | None = None
|
|
139
|
+
is_sidechain: bool = False
|
|
140
|
+
is_compact_summary: bool = False
|
|
141
|
+
has_error_result: bool = False
|
|
142
|
+
tool_calls: list[tuple[str, str]] = field(default_factory=list)
|
|
143
|
+
tool_results: list[tuple[str, str]] = field(default_factory=list)
|
|
144
|
+
author: str | None = None
|
|
145
|
+
|
|
146
|
+
def __post_init__(self) -> None:
|
|
147
|
+
"""Derive ``author`` for a user step the caller did not label."""
|
|
148
|
+
if self.author is None and self.role == "user":
|
|
149
|
+
self.author = "harness" if self.is_compact_summary else step_author("user", self.text)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def format_step_ts(ts: datetime | None) -> str:
|
|
153
|
+
"""Spell a step timestamp the way the transcript renders it (``...T10:00:00.000Z``).
|
|
154
|
+
|
|
155
|
+
UTC, milliseconds unless the value carries sub-millisecond precision (then
|
|
156
|
+
microseconds, so nothing is dropped), and a ``Z``. An aware value is
|
|
157
|
+
converted to UTC first; a naive one is taken as UTC, which is how the lake
|
|
158
|
+
stores it. ``None`` spells as the empty string. The two sources agree only
|
|
159
|
+
on ``Z`` stamps, which is what both converters write: the lake's timestamp
|
|
160
|
+
cast drops any other offset, while the file path converts it.
|
|
161
|
+
"""
|
|
162
|
+
if ts is None:
|
|
163
|
+
return ""
|
|
164
|
+
if ts.tzinfo is not None:
|
|
165
|
+
ts = ts.astimezone(UTC).replace(tzinfo=None)
|
|
166
|
+
micros = ts.microsecond
|
|
167
|
+
fraction = f"{micros // 1000:03d}" if micros % 1000 == 0 else f"{micros:06d}"
|
|
168
|
+
return f"{ts:%Y-%m-%dT%H:%M:%S}.{fraction}Z"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def step_event(
|
|
172
|
+
*,
|
|
173
|
+
ts: datetime | None,
|
|
174
|
+
source: str | None,
|
|
175
|
+
text: str | None,
|
|
176
|
+
source_uuids: object,
|
|
177
|
+
is_sidechain: bool,
|
|
178
|
+
is_compact_summary: bool,
|
|
179
|
+
tool_calls: Iterable[tuple[str | None, object]] = (),
|
|
180
|
+
tool_results: Iterable[tuple[str | None, object, bool]] = (),
|
|
181
|
+
has_error_result: bool = False,
|
|
182
|
+
) -> StepEvent:
|
|
183
|
+
"""Project one ATIF step's values into a :class:`StepEvent`: the rules both sources share.
|
|
184
|
+
|
|
185
|
+
``tool_calls`` holds ``(function_name, arguments)`` and ``tool_results``
|
|
186
|
+
``(source_call_id, content, is_error)``, with ``arguments`` and
|
|
187
|
+
``content`` as parsed JSON values. The rules:
|
|
188
|
+
|
|
189
|
+
* ATIF ``source`` ``agent`` renders as ``assistant``; an absent source as
|
|
190
|
+
``unknown``.
|
|
191
|
+
* the uuid is the FIRST ``source_uuids`` entry when it is a string.
|
|
192
|
+
* arguments render as JSON (non-ASCII kept); absent arguments as ``""``.
|
|
193
|
+
* a string result renders as itself, anything else as JSON, and an absent
|
|
194
|
+
one as ``null`` (what ``json.dumps(None)`` writes).
|
|
195
|
+
* the step carries an error when any of its results says so, or when the
|
|
196
|
+
caller says so without handing the results over (``has_error_result``).
|
|
197
|
+
* the author label is derived in :class:`StepEvent` from the role and text.
|
|
198
|
+
"""
|
|
199
|
+
uuids = source_uuids if isinstance(source_uuids, list) else []
|
|
200
|
+
uuid = uuids[0] if uuids and isinstance(uuids[0], str) else None
|
|
201
|
+
raw_source = source or ""
|
|
202
|
+
calls = [
|
|
203
|
+
(
|
|
204
|
+
str(name or ""),
|
|
205
|
+
json.dumps(args, ensure_ascii=False, default=str) if args is not None else "",
|
|
206
|
+
)
|
|
207
|
+
for name, args in tool_calls
|
|
208
|
+
]
|
|
209
|
+
results: list[tuple[str, str]] = []
|
|
210
|
+
has_error = has_error_result
|
|
211
|
+
for call_id, content, is_error in tool_results:
|
|
212
|
+
content_str = content if isinstance(content, str) else json.dumps(content, default=str)
|
|
213
|
+
results.append((str(call_id or ""), content_str or ""))
|
|
214
|
+
has_error = has_error or is_error
|
|
215
|
+
return StepEvent(
|
|
216
|
+
ts=format_step_ts(ts),
|
|
217
|
+
role="assistant" if raw_source == "agent" else (raw_source or "unknown"),
|
|
218
|
+
text=text or "",
|
|
219
|
+
uuid=uuid,
|
|
220
|
+
is_sidechain=is_sidechain,
|
|
221
|
+
is_compact_summary=is_compact_summary,
|
|
222
|
+
has_error_result=has_error,
|
|
223
|
+
tool_calls=calls,
|
|
224
|
+
tool_results=results,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def is_human_turn(step: StepEvent) -> bool:
|
|
229
|
+
"""A main-chain user step a human wrote: what every reader counts as the user."""
|
|
230
|
+
return step.role == "user" and not step.is_sidechain and step.author == "human"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def session_kind(steps: list[StepEvent]) -> SessionKind:
|
|
234
|
+
"""``interactive`` | ``one_shot_job`` | ``turn_audit`` for one session.
|
|
235
|
+
|
|
236
|
+
Same rule as atif-duck's ``session_outcomes.kind``: the first main-chain
|
|
237
|
+
user step's author, then the count of human turns.
|
|
238
|
+
"""
|
|
239
|
+
return kind_of(s.author or "human" for s in steps if s.role == "user" and not s.is_sidechain)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def main_chain(steps: list[StepEvent]) -> list[StepEvent]:
|
|
243
|
+
"""The non-sidechain steps, in materialized order."""
|
|
244
|
+
return [s for s in steps if not s.is_sidechain]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def render_session_text(
|
|
248
|
+
steps: list[StepEvent],
|
|
249
|
+
*,
|
|
250
|
+
total_max_chars: int = 800_000,
|
|
251
|
+
tool_result_max_chars: int = 50_000,
|
|
252
|
+
include_uuids: bool = False,
|
|
253
|
+
) -> str:
|
|
254
|
+
"""Render one session's steps as a single newline-separated transcript.
|
|
255
|
+
|
|
256
|
+
Per step (main chain only, order preserved) the text line renders
|
|
257
|
+
first, then that step's ``tool_use`` lines, then its ``tool_result``
|
|
258
|
+
lines. That text → tool_use → tool_result precedence is the order the
|
|
259
|
+
prompts are calibrated on.
|
|
260
|
+
|
|
261
|
+
When ``include_uuids`` is True, each text line's header carries the
|
|
262
|
+
step's uuid as ``[uuid=<id> role ts]`` so a classifier can copy the
|
|
263
|
+
turn's natural key verbatim (the conflicts pipeline needs this). Steps
|
|
264
|
+
without a uuid keep the plain header even under ``include_uuids`` —
|
|
265
|
+
an absent key must not become an empty ``uuid=`` attribute the model
|
|
266
|
+
could echo.
|
|
267
|
+
|
|
268
|
+
Every body (text, tool arguments, tool results) is run through
|
|
269
|
+
:func:`escape_uuid_headers` first, so only THIS function writes a real
|
|
270
|
+
``[uuid=`` header and a body cannot forge one.
|
|
271
|
+
|
|
272
|
+
A user step a human did not write renders under its ``author`` label
|
|
273
|
+
with its body clipped to :data:`NON_HUMAN_PREVIEW_CHARS`.
|
|
274
|
+
"""
|
|
275
|
+
chain = main_chain(steps)
|
|
276
|
+
events_total = sum(
|
|
277
|
+
(1 if s.text else 0) + len(s.tool_calls) + len(s.tool_results) for s in chain
|
|
278
|
+
)
|
|
279
|
+
lines: list[str] = []
|
|
280
|
+
running = 0
|
|
281
|
+
truncated = False
|
|
282
|
+
|
|
283
|
+
for step in chain:
|
|
284
|
+
candidates: list[str] = []
|
|
285
|
+
if step.text:
|
|
286
|
+
role = step.role
|
|
287
|
+
text = step.text
|
|
288
|
+
if role == "user" and step.author not in (None, "human"):
|
|
289
|
+
role = step.author or role
|
|
290
|
+
text = tool_result_preview(text, NON_HUMAN_PREVIEW_CHARS)
|
|
291
|
+
body = escape_uuid_headers(text)
|
|
292
|
+
if include_uuids and step.uuid:
|
|
293
|
+
candidates.append(f"[uuid={step.uuid} {role} {step.ts}] {body}")
|
|
294
|
+
else:
|
|
295
|
+
candidates.append(f"[{role} {step.ts}] {body}")
|
|
296
|
+
for name, args_json in step.tool_calls:
|
|
297
|
+
preview = escape_uuid_headers(tool_input_preview(args_json))
|
|
298
|
+
candidates.append(f"[tool_use:{name or 'tool'} {step.ts}] {preview}")
|
|
299
|
+
for call_id, content in step.tool_results:
|
|
300
|
+
preview = escape_uuid_headers(tool_result_preview(content, tool_result_max_chars))
|
|
301
|
+
candidates.append(f"[tool_result {call_id or '?'} {step.ts}] {preview}")
|
|
302
|
+
for line in candidates:
|
|
303
|
+
if running + len(line) + 1 > total_max_chars:
|
|
304
|
+
lines.append(
|
|
305
|
+
f"…(session truncated at {total_max_chars} chars, {events_total} events total)"
|
|
306
|
+
)
|
|
307
|
+
truncated = True
|
|
308
|
+
break
|
|
309
|
+
lines.append(line)
|
|
310
|
+
running += len(line) + 1
|
|
311
|
+
if truncated:
|
|
312
|
+
break
|
|
313
|
+
|
|
314
|
+
return "\n".join(lines)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def human_ai_pair_count(steps: list[StepEvent]) -> int:
|
|
318
|
+
"""Count completed human→AI text exchanges (the perceived-error gate).
|
|
319
|
+
|
|
320
|
+
Mirrors LangSmith's Perceived Error eligibility rule ("at least two
|
|
321
|
+
human-AI message pairs"): one pair = a HUMAN turn (:func:`is_human_turn`)
|
|
322
|
+
followed later by a non-empty main-chain assistant text turn. A user-role
|
|
323
|
+
step a human did not write (hook feedback, notifications, harness text,
|
|
324
|
+
compaction summaries) is not the human responding, so it neither opens
|
|
325
|
+
nor closes a pair, and consecutive human turns collapse into the same
|
|
326
|
+
pending pair — only a human turn that actually received an assistant
|
|
327
|
+
reply completes one.
|
|
328
|
+
"""
|
|
329
|
+
pairs = 0
|
|
330
|
+
awaiting_reply = False
|
|
331
|
+
for s in steps:
|
|
332
|
+
if s.is_sidechain or s.is_compact_summary or not s.text:
|
|
333
|
+
continue
|
|
334
|
+
if s.role == "user":
|
|
335
|
+
if not is_human_turn(s):
|
|
336
|
+
continue
|
|
337
|
+
awaiting_reply = True
|
|
338
|
+
elif awaiting_reply:
|
|
339
|
+
pairs += 1
|
|
340
|
+
awaiting_reply = False
|
|
341
|
+
return pairs
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
__all__ = [
|
|
345
|
+
"NON_HUMAN_PREVIEW_CHARS",
|
|
346
|
+
"TOOL_INPUT_PREVIEW_CHARS",
|
|
347
|
+
"UUID_HEADER_ESCAPE",
|
|
348
|
+
"StepEvent",
|
|
349
|
+
"escape_uuid_headers",
|
|
350
|
+
"format_step_ts",
|
|
351
|
+
"human_ai_pair_count",
|
|
352
|
+
"is_human_turn",
|
|
353
|
+
"main_chain",
|
|
354
|
+
"render_session_text",
|
|
355
|
+
"session_kind",
|
|
356
|
+
"step_event",
|
|
357
|
+
"tool_input_preview",
|
|
358
|
+
"tool_result_preview",
|
|
359
|
+
]
|