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.
Files changed (164) hide show
  1. atif_analytics/__init__.py +9 -0
  2. atif_analytics/application/__init__.py +3 -0
  3. atif_analytics/application/analyze.py +158 -0
  4. atif_analytics/application/prompts.py +750 -0
  5. atif_analytics/application/use_cases/__init__.py +3 -0
  6. atif_analytics/application/use_cases/_shared.py +264 -0
  7. atif_analytics/application/use_cases/classify.py +430 -0
  8. atif_analytics/application/use_cases/conflicts.py +448 -0
  9. atif_analytics/application/use_cases/friction.py +634 -0
  10. atif_analytics/application/use_cases/perceived.py +451 -0
  11. atif_analytics/domain/__init__.py +3 -0
  12. atif_analytics/domain/authorship.py +166 -0
  13. atif_analytics/domain/config.py +30 -0
  14. atif_analytics/domain/costs.py +50 -0
  15. atif_analytics/domain/friction.py +117 -0
  16. atif_analytics/domain/layout.py +94 -0
  17. atif_analytics/domain/models.py +385 -0
  18. atif_analytics/domain/ports.py +100 -0
  19. atif_analytics/domain/transcript.py +359 -0
  20. atif_analytics/infrastructure/__init__.py +3 -0
  21. atif_analytics/infrastructure/corpus_reader.py +546 -0
  22. atif_analytics/infrastructure/parquet_cache.py +186 -0
  23. atif_analytics/infrastructure/settings.py +114 -0
  24. atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
  25. atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
  26. atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
  27. atif_analytics/py.typed +0 -0
  28. atif_cli/__init__.py +3 -0
  29. atif_cli/__main__.py +8 -0
  30. atif_cli/app.py +2459 -0
  31. atif_cli/converter_adapter.py +151 -0
  32. atif_cli/corpus.py +409 -0
  33. atif_cli/cron.py +311 -0
  34. atif_cli/duck_errors.py +97 -0
  35. atif_cli/embed_lake.py +101 -0
  36. atif_cli/errors.py +89 -0
  37. atif_cli/lake.py +373 -0
  38. atif_cli/lake_sessions.py +270 -0
  39. atif_cli/output.py +297 -0
  40. atif_cli/py.typed +0 -0
  41. atif_converter/__init__.py +3 -0
  42. atif_converter/application/__init__.py +3 -0
  43. atif_converter/application/convert_and_audit.py +294 -0
  44. atif_converter/application/convert_codex.py +210 -0
  45. atif_converter/domain/__init__.py +9 -0
  46. atif_converter/domain/agents.py +40 -0
  47. atif_converter/domain/atif/__init__.py +55 -0
  48. atif_converter/domain/atif/agent.py +37 -0
  49. atif_converter/domain/atif/content.py +161 -0
  50. atif_converter/domain/atif/final_metrics.py +45 -0
  51. atif_converter/domain/atif/metrics.py +49 -0
  52. atif_converter/domain/atif/observation.py +21 -0
  53. atif_converter/domain/atif/observation_result.py +47 -0
  54. atif_converter/domain/atif/step.py +144 -0
  55. atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
  56. atif_converter/domain/atif/tool_call.py +36 -0
  57. atif_converter/domain/atif/trajectory.py +211 -0
  58. atif_converter/domain/atif/trajectory_validator.py +299 -0
  59. atif_converter/domain/blobs.py +510 -0
  60. atif_converter/domain/claude_code_conversion.py +1077 -0
  61. atif_converter/domain/codex_conversion.py +1036 -0
  62. atif_converter/domain/codex_edges.py +192 -0
  63. atif_converter/domain/codex_enrichment.py +419 -0
  64. atif_converter/domain/codex_fidelity.py +124 -0
  65. atif_converter/domain/codex_nested_calls.py +165 -0
  66. atif_converter/domain/edges.py +113 -0
  67. atif_converter/domain/enrichment.py +412 -0
  68. atif_converter/domain/errors.py +60 -0
  69. atif_converter/domain/fidelity.py +171 -0
  70. atif_converter/domain/model_prices.json +8538 -0
  71. atif_converter/domain/pricing.py +790 -0
  72. atif_converter/domain/result_signals.py +774 -0
  73. atif_converter/domain/schema_version.py +90 -0
  74. atif_converter/domain/session_events.py +408 -0
  75. atif_converter/infrastructure/__init__.py +3 -0
  76. atif_converter/infrastructure/census.py +89 -0
  77. atif_converter/infrastructure/claude_code_converter.py +127 -0
  78. atif_converter/infrastructure/codex_adapter.py +132 -0
  79. atif_converter/infrastructure/codex_census.py +100 -0
  80. atif_converter/infrastructure/codex_converter.py +82 -0
  81. atif_converter/infrastructure/harbor_adapter.py +187 -0
  82. atif_converter/infrastructure/raw_records.py +426 -0
  83. atif_converter/infrastructure/source_archive.py +154 -0
  84. atif_converter/py.typed +0 -0
  85. atif_corpus/__init__.py +13 -0
  86. atif_corpus/application/__init__.py +7 -0
  87. atif_corpus/application/materialize.py +1826 -0
  88. atif_corpus/domain/__init__.py +9 -0
  89. atif_corpus/domain/agents.py +33 -0
  90. atif_corpus/domain/generation.py +34 -0
  91. atif_corpus/domain/layout.py +168 -0
  92. atif_corpus/domain/ports.py +175 -0
  93. atif_corpus/domain/session_id.py +68 -0
  94. atif_corpus/domain/sessions.py +228 -0
  95. atif_corpus/domain/slug.py +72 -0
  96. atif_corpus/domain/source_layout.py +143 -0
  97. atif_corpus/domain/watermark.py +76 -0
  98. atif_corpus/infrastructure/__init__.py +7 -0
  99. atif_corpus/infrastructure/atomic.py +266 -0
  100. atif_corpus/infrastructure/compress_artifacts.py +161 -0
  101. atif_corpus/infrastructure/fake_converter.py +174 -0
  102. atif_corpus/infrastructure/scanner.py +316 -0
  103. atif_corpus/infrastructure/settings.py +130 -0
  104. atif_corpus/infrastructure/source_archive.py +202 -0
  105. atif_corpus/py.typed +0 -0
  106. atif_duck/__init__.py +43 -0
  107. atif_duck/domain/__init__.py +32 -0
  108. atif_duck/domain/artifacts.py +66 -0
  109. atif_duck/domain/authorship.py +165 -0
  110. atif_duck/domain/catalog.py +626 -0
  111. atif_duck/domain/columnar.py +149 -0
  112. atif_duck/domain/embedding_guard.py +88 -0
  113. atif_duck/domain/examples.py +265 -0
  114. atif_duck/domain/lake.py +427 -0
  115. atif_duck/domain/lake_sessions.py +97 -0
  116. atif_duck/domain/raw_readers.py +140 -0
  117. atif_duck/domain/session_id.py +69 -0
  118. atif_duck/domain/sql_literal.py +50 -0
  119. atif_duck/infrastructure/__init__.py +34 -0
  120. atif_duck/infrastructure/analytics.py +469 -0
  121. atif_duck/infrastructure/authorship.py +139 -0
  122. atif_duck/infrastructure/columnar.py +641 -0
  123. atif_duck/infrastructure/lake.py +1383 -0
  124. atif_duck/infrastructure/lake_sessions.py +194 -0
  125. atif_duck/infrastructure/lake_settings.py +63 -0
  126. atif_duck/infrastructure/lake_steps.py +225 -0
  127. atif_duck/infrastructure/projections.py +221 -0
  128. atif_duck/infrastructure/registry.py +2194 -0
  129. atif_duck/infrastructure/stored_artifacts.py +115 -0
  130. atif_duck/py.typed +0 -0
  131. atif_embed/__init__.py +12 -0
  132. atif_embed/application/__init__.py +3 -0
  133. atif_embed/application/embed.py +293 -0
  134. atif_embed/application/prune.py +80 -0
  135. atif_embed/domain/__init__.py +3 -0
  136. atif_embed/domain/discovery.py +70 -0
  137. atif_embed/domain/embedding_guard.py +83 -0
  138. atif_embed/domain/errors.py +94 -0
  139. atif_embed/domain/ports.py +223 -0
  140. atif_embed/domain/sql_literal.py +49 -0
  141. atif_embed/domain/text_stamp.py +71 -0
  142. atif_embed/infrastructure/__init__.py +3 -0
  143. atif_embed/infrastructure/cohere_bedrock.py +433 -0
  144. atif_embed/infrastructure/corpus_text_rows.py +273 -0
  145. atif_embed/infrastructure/gemma_local.py +355 -0
  146. atif_embed/infrastructure/lake_text_rows.py +203 -0
  147. atif_embed/infrastructure/lance_store.py +440 -0
  148. atif_embed/infrastructure/providers.py +30 -0
  149. atif_embed/infrastructure/settings.py +154 -0
  150. atif_embed/py.typed +0 -0
  151. atif_models/__init__.py +14 -0
  152. atif_models/domain/__init__.py +9 -0
  153. atif_models/domain/ports.py +143 -0
  154. atif_models/domain/registry.py +151 -0
  155. atif_models/domain/schema.py +109 -0
  156. atif_models/infrastructure/__init__.py +8 -0
  157. atif_models/infrastructure/openai_bedrock.py +314 -0
  158. atif_models/infrastructure/settings.py +85 -0
  159. atif_models/py.typed +0 -0
  160. atif_sql-0.2.0.dist-info/METADATA +272 -0
  161. atif_sql-0.2.0.dist-info/RECORD +164 -0
  162. atif_sql-0.2.0.dist-info/WHEEL +4 -0
  163. atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
  164. 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
+ ]
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Infrastructure adapters: settings, parquet cache, sqlite state, corpus reader."""