traceforge-toolkit 0.1.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 (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,398 @@
1
+ """Title providers: offline floor + opt-in LiteLLM API tier (session and span).
2
+
3
+ This module owns the LiteLLM plumbing, the opt-in key-presence gate, and the
4
+ graceful-fallback contract shared by both configurable title surfaces:
5
+
6
+ * **Session naming.** :func:`build_session_titler_split` reads
7
+ :class:`~traceforge.config.SessionNamingConfig` and returns a
8
+ :class:`SessionTitler` (an immediate :class:`HeuristicProvider` floor + an
9
+ optional :class:`ApiProvider` refiner). The heuristic is a free, offline
10
+ extractive title over the user's own words; the API tier engages only when a
11
+ key is present.
12
+ * **Activity/step titling.** :func:`build_activity_refiner` reads
13
+ :class:`~traceforge.config.ActivityTitlingConfig` and returns an optional
14
+ :class:`ActivityApiProvider` refiner. Here the offline floor is the *packaged
15
+ ONNX span model* (owned by :mod:`traceforge.title.inferencer`, emitted the
16
+ instant an activity closes); the refiner, when configured and keyed, upgrades
17
+ the activity title and all its step titles in one call, off the hot path.
18
+
19
+ In both cases the API tier is **strictly opt-in and never load-bearing**: absent
20
+ a key -- or on any API call that fails, times out, or returns nothing usable --
21
+ the offline floor stands, so a missing key or a flaky network never errors,
22
+ blocks, or empties a title.
23
+
24
+ The API key is never read from config: LiteLLM sources it from the provider's
25
+ conventional env var; ``api.api_key_env`` only overrides *which* var to read.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import logging
32
+ import os
33
+ from dataclasses import dataclass
34
+ from typing import TYPE_CHECKING, Callable
35
+
36
+ from .hygiene import clean_title
37
+ from .heuristics import heuristic_title
38
+
39
+ if TYPE_CHECKING:
40
+ from traceforge.config.models import (
41
+ ActivityTitlingApiConfig,
42
+ ActivityTitlingConfig,
43
+ SessionNamingApiConfig,
44
+ SessionNamingConfig,
45
+ SessionNamingHeuristicConfig,
46
+ TitleApiConfig,
47
+ )
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ #: Kept terse and instruction-tight; the model must return only the title.
52
+ _SYSTEM = (
53
+ "You name a developer's coding session from their first message. "
54
+ "Reply with ONLY a short, specific title in imperative mood, at most {max_words} "
55
+ "words, no trailing punctuation, no quotes. Name the concrete task, not the tone."
56
+ )
57
+
58
+
59
+ class HeuristicProvider:
60
+ """The offline extractive floor. Always available; needs no key or network."""
61
+
62
+ def __init__(self, cfg: "SessionNamingHeuristicConfig") -> None:
63
+ self._method = cfg.method
64
+ self._max_words = cfg.max_words
65
+ self._max_chars = cfg.max_chars
66
+
67
+ def title(self, text: str) -> str:
68
+ return heuristic_title(text, self._method, self._max_words, self._max_chars)
69
+
70
+ __call__ = title
71
+
72
+
73
+ class ApiProvider:
74
+ """Abstractive session titles via LiteLLM (any provider + local runtimes).
75
+
76
+ Returns ``""`` on any failure so the caller can fall back to the heuristic.
77
+ """
78
+
79
+ def __init__(self, cfg: "SessionNamingApiConfig", max_words: int = 8) -> None:
80
+ self._cfg = cfg
81
+ self._max_words = max_words
82
+
83
+ def title(self, text: str) -> str:
84
+ try:
85
+ import litellm
86
+ except ImportError: # pragma: no cover - litellm ships as a base dep
87
+ logger.debug("litellm unavailable; session-naming API tier disabled")
88
+ return ""
89
+ cfg = self._cfg
90
+ system = _SYSTEM.format(max_words=self._max_words)
91
+ kwargs: dict = {
92
+ "model": cfg.model,
93
+ "messages": [
94
+ {"role": "system", "content": system},
95
+ {"role": "user", "content": text},
96
+ ],
97
+ "max_tokens": cfg.max_tokens,
98
+ "timeout": cfg.timeout,
99
+ "temperature": 0.0,
100
+ }
101
+ if cfg.api_base:
102
+ kwargs["api_base"] = cfg.api_base
103
+ if cfg.api_key_env:
104
+ key = os.environ.get(cfg.api_key_env)
105
+ if not key:
106
+ return ""
107
+ kwargs["api_key"] = key
108
+ try:
109
+ resp = litellm.completion(**kwargs)
110
+ raw = (resp.choices[0].message.content or "").strip()
111
+ except Exception as exc: # noqa: BLE001 - any provider/network error -> fallback
112
+ logger.warning("session-naming API call failed (%s); using heuristic", exc)
113
+ return ""
114
+ return clean_title(raw)
115
+
116
+ __call__ = title
117
+
118
+
119
+ class _WithFallback:
120
+ """Try the API tier; fall back to the heuristic on empty/failed output."""
121
+
122
+ def __init__(self, primary: ApiProvider, fallback: HeuristicProvider) -> None:
123
+ self._primary = primary
124
+ self._fallback = fallback
125
+
126
+ def title(self, text: str) -> str:
127
+ out = self._primary.title(text)
128
+ return out if out and out.strip() else self._fallback.title(text)
129
+
130
+ __call__ = title
131
+
132
+
133
+ def _api_key_present(cfg: "TitleApiConfig") -> bool:
134
+ """True iff the API tier can authenticate right now (opt-in gate).
135
+
136
+ Shared by both title surfaces (session naming and activity/step titling),
137
+ since both ``api`` sub-configs derive from :class:`TitleApiConfig`. Honors an
138
+ explicit ``api_key_env`` override; otherwise defers to LiteLLM's
139
+ provider-aware environment validation so any provider's conventional key var
140
+ counts. Absent LiteLLM or on any error, reports not-present (-> offline floor).
141
+ """
142
+ if cfg.api_base and not cfg.api_key_env:
143
+ # Local/self-hosted runtimes (ollama/vllm) often need no key.
144
+ return True
145
+ if cfg.api_key_env:
146
+ return bool(os.environ.get(cfg.api_key_env))
147
+ try:
148
+ from litellm import validate_environment
149
+
150
+ info = validate_environment(cfg.model)
151
+ return bool(info.get("keys_in_environment"))
152
+ except Exception: # noqa: BLE001 - litellm missing or signature drift -> off
153
+ return False
154
+
155
+
156
+ def build_session_titler(cfg: "SessionNamingConfig | None" = None) -> Callable[[str], str]:
157
+ """Build the configured session titler (defaults resolved from global config).
158
+
159
+ The returned callable maps the first substantive user message to a title.
160
+ Falls back to the heuristic whenever the API tier is unconfigured/unusable.
161
+
162
+ This is the *blocking* single-callable form (API tier tried inline, then the
163
+ heuristic). The live pipeline instead uses :func:`build_session_titler_split`
164
+ so it can emit the heuristic immediately and refine via the API off the hot
165
+ path; this form is retained for callers that want one synchronous title.
166
+ """
167
+ split = build_session_titler_split(cfg)
168
+ if split.api_refiner is not None:
169
+ return _WithFallback(split.api_refiner, split.heuristic)
170
+ return split.heuristic
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class SessionTitler:
175
+ """A session titler split into an immediate floor and an optional refiner.
176
+
177
+ * ``heuristic`` -- the free, offline, non-blocking title. Always present; it
178
+ is emitted the instant the opening request arrives so the event stream
179
+ never waits on a title.
180
+ * ``api_refiner`` -- present only when ``strategy=api`` *and* a usable API key
181
+ is configured. It returns an abstractive upgrade of the title (or ``""`` on
182
+ any failure/timeout) and is meant to run **off the hot path**: the pipeline
183
+ runs it in a worker thread and emits its result as a later session
184
+ :class:`~traceforge.types.TitleUpdate`. ``None`` when no usable API tier is
185
+ configured, in which case the heuristic is the final title.
186
+ """
187
+
188
+ heuristic: Callable[[str], str]
189
+ api_refiner: Callable[[str], str] | None
190
+
191
+
192
+ def build_session_titler_split(
193
+ cfg: "SessionNamingConfig | None" = None,
194
+ ) -> SessionTitler:
195
+ """Build the session titler as a non-blocking heuristic + optional refiner.
196
+
197
+ Unlike :func:`build_session_titler`, the API tier (when configured and keyed)
198
+ is returned *separately* as ``api_refiner`` rather than wrapped inline, so the
199
+ pipeline can emit the heuristic title immediately and only later replace it
200
+ with the API result — never blocking live event emission on the network.
201
+ """
202
+ if cfg is None:
203
+ from traceforge.config import get_config
204
+
205
+ cfg = get_config().title.session_naming
206
+
207
+ heuristic = HeuristicProvider(cfg.heuristic)
208
+ if cfg.strategy == "api" and _api_key_present(cfg.api):
209
+ return SessionTitler(heuristic, ApiProvider(cfg.api, cfg.heuristic.max_words))
210
+ if cfg.strategy == "api":
211
+ logger.info(
212
+ "title.session_naming.strategy=api but no API key for model %r found in "
213
+ "the environment; using the offline heuristic.",
214
+ cfg.api.model,
215
+ )
216
+ return SessionTitler(heuristic, None)
217
+
218
+
219
+ # ─── Activity/step (span) title refinement ───────────────────────────────────
220
+
221
+ #: Terse, instruction-tight span prompt. The model sees each segment's distilled
222
+ #: context and must return ONLY a JSON object titling the activity and its steps.
223
+ _SPAN_SYSTEM = (
224
+ "You title one activity in a developer's coding session and each of its "
225
+ "numbered steps, from their distilled contexts. Reply with ONLY a JSON "
226
+ 'object of the form {{"activity": "<title>", "steps": ["<step 1>", '
227
+ '"<step 2>", ...]}} -- exactly one step title per numbered step, in order. '
228
+ "Each title is a short, specific phrase in imperative or gerund mood, at most "
229
+ "{max_words} words, no trailing punctuation, no quotes. Keep every step title "
230
+ "distinct from the activity title and from the other step titles."
231
+ )
232
+
233
+
234
+ @dataclass(frozen=True)
235
+ class ActivitySpan:
236
+ """The distilled context of a closed activity and its steps, fed to the API.
237
+
238
+ ``activity_context`` is the distilled context over the whole activity;
239
+ ``step_contexts`` holds one distilled context per step, in order. Both are
240
+ produced by :func:`traceforge.title.context.distilled_context`, so the API
241
+ tier sees the same source-agnostic slots the packaged model was trained on.
242
+ """
243
+
244
+ activity_context: str
245
+ step_contexts: list[str]
246
+
247
+
248
+ @dataclass(frozen=True)
249
+ class ActivityTitles:
250
+ """The API's proposed titles for an :class:`ActivitySpan`.
251
+
252
+ ``activity`` is the refined activity title (``None`` when the API declined or
253
+ failed); ``steps`` is aligned index-for-index with the span's ``step_contexts``
254
+ -- ``None`` in any slot the API did not usefully cover, so the caller keeps
255
+ that segment's packaged-model title.
256
+ """
257
+
258
+ activity: str | None
259
+ steps: list[str | None]
260
+
261
+
262
+ def _empty_titles(n_steps: int) -> ActivityTitles:
263
+ return ActivityTitles(None, [None] * n_steps)
264
+
265
+
266
+ def _loads_json_object(raw: str):
267
+ """Best-effort parse of a JSON object from a model reply.
268
+
269
+ Tolerates a model that wraps the object in prose or code fences by falling
270
+ back to the outermost ``{...}`` slice. Returns the parsed object or ``None``.
271
+ """
272
+ try:
273
+ return json.loads(raw)
274
+ except (ValueError, TypeError):
275
+ pass
276
+ start = raw.find("{")
277
+ end = raw.rfind("}")
278
+ if start != -1 and end > start:
279
+ try:
280
+ return json.loads(raw[start : end + 1])
281
+ except (ValueError, TypeError):
282
+ return None
283
+ return None
284
+
285
+
286
+ class ActivityApiProvider:
287
+ """Abstractive activity/step titles via LiteLLM (any provider + local runtimes).
288
+
289
+ One :meth:`refine` call per closed activity returns the activity title and all
290
+ of its step titles together, so the model sees the full activity context and
291
+ can keep step titles distinct. Returns :class:`ActivityTitles` with ``None``
292
+ slots on any failure (missing key, provider/network error, timeout, or an
293
+ unparseable reply) so the caller falls back to the packaged-model titles.
294
+ """
295
+
296
+ def __init__(self, cfg: "ActivityTitlingApiConfig", max_words: int = 8) -> None:
297
+ self._cfg = cfg
298
+ self._max_words = max_words
299
+
300
+ @staticmethod
301
+ def _render(span: ActivitySpan) -> str:
302
+ lines = [f"activity: {span.activity_context}"]
303
+ for i, ctx in enumerate(span.step_contexts, start=1):
304
+ lines.append(f"step {i}: {ctx}")
305
+ return "\n".join(lines)
306
+
307
+ def refine(self, span: ActivitySpan) -> ActivityTitles:
308
+ n = len(span.step_contexts)
309
+ try:
310
+ import litellm
311
+ except ImportError: # pragma: no cover - litellm ships as a base dep
312
+ logger.debug("litellm unavailable; activity-titling API tier disabled")
313
+ return _empty_titles(n)
314
+ cfg = self._cfg
315
+ system = _SPAN_SYSTEM.format(max_words=self._max_words)
316
+ kwargs: dict = {
317
+ "model": cfg.model,
318
+ "messages": [
319
+ {"role": "system", "content": system},
320
+ {"role": "user", "content": self._render(span)},
321
+ ],
322
+ "max_tokens": cfg.max_tokens,
323
+ "timeout": cfg.timeout,
324
+ "temperature": 0.0,
325
+ }
326
+ if cfg.api_base:
327
+ kwargs["api_base"] = cfg.api_base
328
+ if cfg.api_key_env:
329
+ key = os.environ.get(cfg.api_key_env)
330
+ if not key:
331
+ return _empty_titles(n)
332
+ kwargs["api_key"] = key
333
+ try:
334
+ resp = litellm.completion(**kwargs)
335
+ raw = (resp.choices[0].message.content or "").strip()
336
+ except Exception as exc: # noqa: BLE001 - any provider/network error -> fallback
337
+ logger.warning("activity-titling API call failed (%s); using packaged model", exc)
338
+ return _empty_titles(n)
339
+ return self._parse(raw, n)
340
+
341
+ @staticmethod
342
+ def _parse(raw: str, n_steps: int) -> ActivityTitles:
343
+ data = _loads_json_object(raw)
344
+ if not isinstance(data, dict):
345
+ return _empty_titles(n_steps)
346
+ act = data.get("activity")
347
+ activity = clean_title(act) if isinstance(act, str) and act.strip() else None
348
+ steps: list[str | None] = [None] * n_steps
349
+ raw_steps = data.get("steps")
350
+ if isinstance(raw_steps, list):
351
+ for i in range(min(n_steps, len(raw_steps))):
352
+ s = raw_steps[i]
353
+ if isinstance(s, str) and s.strip():
354
+ cleaned = clean_title(s)
355
+ steps[i] = cleaned or None
356
+ return ActivityTitles(activity or None, steps)
357
+
358
+ __call__ = refine
359
+
360
+
361
+ def build_activity_refiner(
362
+ cfg: "ActivityTitlingConfig | None" = None,
363
+ ) -> "ActivityApiProvider | None":
364
+ """Build the optional off-hot-path activity/step title refiner.
365
+
366
+ Returns an :class:`ActivityApiProvider` only when ``strategy=api`` *and* a
367
+ usable API key is present in the environment; otherwise ``None``, in which
368
+ case the packaged ONNX titles (emitted the instant an activity closes) are
369
+ final. Mirrors :func:`build_session_titler_split`'s ``api_refiner`` gate --
370
+ the packaged model itself is the always-present floor, owned by the inferencer.
371
+ """
372
+ if cfg is None:
373
+ from traceforge.config import get_config
374
+
375
+ cfg = get_config().title.activity_titling
376
+
377
+ if cfg.strategy == "api" and _api_key_present(cfg.api):
378
+ return ActivityApiProvider(cfg.api)
379
+ if cfg.strategy == "api":
380
+ logger.info(
381
+ "title.activity_titling.strategy=api but no API key for model %r found in "
382
+ "the environment; using the packaged ONNX titler.",
383
+ cfg.api.model,
384
+ )
385
+ return None
386
+
387
+
388
+ __all__ = [
389
+ "HeuristicProvider",
390
+ "ApiProvider",
391
+ "SessionTitler",
392
+ "ActivitySpan",
393
+ "ActivityTitles",
394
+ "ActivityApiProvider",
395
+ "build_session_titler",
396
+ "build_session_titler_split",
397
+ "build_activity_refiner",
398
+ ]
traceforge/trace.py ADDED
@@ -0,0 +1,291 @@
1
+ """The unified pipeline type: EventTrace.
2
+
3
+ An EventTrace is the single object that flows through the entire traceforge pipeline.
4
+ It enters sparse (identity fields only from the adapter), accumulates
5
+ classification fields from the enricher, and assessment fields from the scorer.
6
+ By the time it reaches the gate callback, it is fully populated.
7
+
8
+ All dimension types (EventKind, Mechanism, Effect, etc.) are StrEnums generated
9
+ by datamodel-code-generator from classify/schema.yaml — see _generated.py.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field, replace
15
+ from datetime import datetime
16
+ from enum import StrEnum
17
+ from types import MappingProxyType
18
+ from typing import Any
19
+
20
+ from traceforge._generated import (
21
+ Action,
22
+ Capability,
23
+ Effect,
24
+ EventKind,
25
+ Mechanism,
26
+ Recommendation,
27
+ RiskBand,
28
+ Role,
29
+ Scope,
30
+ Structure,
31
+ )
32
+
33
+ SCHEMA_VERSION = "2"
34
+
35
+ # Sentinel for "field not provided" in mutation helpers
36
+ _UNSET: Any = object()
37
+
38
+ # Empty frozen map constant
39
+ EMPTY_MAP: MappingProxyType = MappingProxyType({})
40
+
41
+
42
+ class TraceStage(StrEnum):
43
+ """Lifecycle stage of an EventTrace in the pipeline."""
44
+
45
+ ADAPTED = "adapted"
46
+ CLASSIFIED = "classified"
47
+ ASSESSED = "assessed"
48
+
49
+
50
+ def _deep_freeze(obj: Any) -> Any:
51
+ """Recursively freeze a nested structure into immutable types."""
52
+ if isinstance(obj, MappingProxyType):
53
+ return obj
54
+ if isinstance(obj, dict):
55
+ return MappingProxyType({k: _deep_freeze(v) for k, v in obj.items()})
56
+ if isinstance(obj, (list, tuple)):
57
+ return tuple(_deep_freeze(v) for v in obj)
58
+ if isinstance(obj, set):
59
+ return frozenset(_deep_freeze(v) for v in obj)
60
+ return obj
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class EventTrace:
65
+ """The atomic unit of traceforge. One per observed event.
66
+
67
+ Lifecycle:
68
+ 1. Adapter creates with identity fields + raw_event
69
+ 2. Enricher fills classification fields (mechanism, effect, etc.)
70
+ 3. Scorer fills assessment fields (risk_score, suggested_action, etc.)
71
+ 4. Gate callback receives the fully-enriched EventTrace
72
+ 5. Sinks persist the final EventTrace
73
+
74
+ All enum fields accept raw strings and coerce to StrEnum members in
75
+ __post_init__. Invalid values raise ValueError immediately.
76
+ """
77
+
78
+ # ─── Identity (adapter fills) ─────────────────────────────────────────────
79
+
80
+ id: str
81
+ kind: EventKind
82
+ session_id: str
83
+ tool_call_id: str
84
+ timestamp: datetime
85
+ source_key: str
86
+ raw_event: dict[str, Any] | MappingProxyType = field(repr=False, compare=False)
87
+ parent_tool_call_id: str | None = None
88
+
89
+ # ─── Tool identity (adapter fills for tool.call.* events) ─────────────────
90
+ # gen_ai.tool.name → tool_name
91
+ # gen_ai.tool.call.arguments → tool_input
92
+ # gen_ai.tool.call.result → tool_result
93
+
94
+ tool_name: str | None = None
95
+ tool_input: MappingProxyType = field(default_factory=lambda: EMPTY_MAP)
96
+ tool_result: str | None = None
97
+ target_resource: str | None = None
98
+
99
+ # ─── Classification (enricher fills) ──────────────────────────────────────
100
+
101
+ mechanism: Mechanism | None = None
102
+ effect: Effect | None = None
103
+ scope: tuple[Scope, ...] = ()
104
+ role: tuple[Role, ...] = ()
105
+ action: tuple[Action, ...] = ()
106
+ capability: tuple[Capability, ...] = ()
107
+ structure: tuple[Structure, ...] = ()
108
+ canonical_tool: str | None = None
109
+
110
+ # ─── Assessment (scorer fills) ────────────────────────────────────────────
111
+
112
+ risk_score: int | None = None
113
+ risk_band: RiskBand | None = None
114
+ suggested_action: Recommendation | None = None
115
+ reason: str | None = None
116
+
117
+ # ─── Lifecycle ────────────────────────────────────────────────────────────
118
+
119
+ stage: TraceStage = TraceStage.ADAPTED
120
+
121
+ # ─── Extensions ───────────────────────────────────────────────────────────
122
+
123
+ attributes: MappingProxyType = field(default_factory=lambda: EMPTY_MAP)
124
+ schema_version: str = SCHEMA_VERSION
125
+
126
+ # ─── Post-init: deep-freeze + coerce strings to StrEnums ──────────────────
127
+
128
+ def __post_init__(self) -> None:
129
+ # Deep-freeze all mapping fields
130
+ raw = self.raw_event
131
+ if isinstance(raw, dict) and not isinstance(raw, MappingProxyType):
132
+ object.__setattr__(self, "raw_event", _deep_freeze(raw))
133
+ elif isinstance(raw, MappingProxyType):
134
+ pass # already frozen
135
+
136
+ ti = self.tool_input
137
+ if isinstance(ti, dict) and not isinstance(ti, MappingProxyType):
138
+ object.__setattr__(self, "tool_input", _deep_freeze(ti))
139
+
140
+ attrs = self.attributes
141
+ if isinstance(attrs, dict) and not isinstance(attrs, MappingProxyType):
142
+ object.__setattr__(self, "attributes", _deep_freeze(attrs))
143
+
144
+ # Coerce scalar enums (StrEnum constructor validates + raises ValueError)
145
+ object.__setattr__(self, "kind", EventKind(self.kind))
146
+ object.__setattr__(self, "stage", TraceStage(self.stage))
147
+ if self.mechanism is not None:
148
+ object.__setattr__(self, "mechanism", Mechanism(self.mechanism))
149
+ if self.effect is not None:
150
+ object.__setattr__(self, "effect", Effect(self.effect))
151
+ if self.risk_band is not None:
152
+ object.__setattr__(self, "risk_band", RiskBand(self.risk_band))
153
+ if self.suggested_action is not None:
154
+ object.__setattr__(self, "suggested_action", Recommendation(self.suggested_action))
155
+
156
+ # Coerce tuple enums
157
+ if self.scope:
158
+ object.__setattr__(self, "scope", tuple(Scope(v) for v in self.scope))
159
+ if self.role:
160
+ object.__setattr__(self, "role", tuple(Role(v) for v in self.role))
161
+ if self.action:
162
+ object.__setattr__(self, "action", tuple(Action(v) for v in self.action))
163
+ if self.capability:
164
+ object.__setattr__(self, "capability", tuple(Capability(v) for v in self.capability))
165
+ if self.structure:
166
+ object.__setattr__(self, "structure", tuple(Structure(v) for v in self.structure))
167
+
168
+ # ─── OTel correlation aliases ─────────────────────────────────────────────
169
+
170
+ @property
171
+ def trace_id(self) -> str:
172
+ """OTel alias: session_id → trace_id."""
173
+ return self.session_id
174
+
175
+ @property
176
+ def span_id(self) -> str:
177
+ """OTel alias: tool_call_id → span_id."""
178
+ return self.tool_call_id
179
+
180
+ @property
181
+ def parent_span_id(self) -> str | None:
182
+ """OTel alias: parent_tool_call_id → parent_span_id."""
183
+ return self.parent_tool_call_id
184
+
185
+ # ─── Lifecycle checks ─────────────────────────────────────────────────────
186
+
187
+ @property
188
+ def classified(self) -> bool:
189
+ """True if enricher has run."""
190
+ return self.stage in (TraceStage.CLASSIFIED, TraceStage.ASSESSED)
191
+
192
+ @property
193
+ def assessed(self) -> bool:
194
+ """True if scorer has run."""
195
+ return self.stage == TraceStage.ASSESSED
196
+
197
+ # ─── Mutation helpers (frozen dataclass — returns new instance) ────────────
198
+
199
+ def with_classification(
200
+ self,
201
+ *,
202
+ mechanism=_UNSET,
203
+ effect=_UNSET,
204
+ scope=_UNSET,
205
+ role=_UNSET,
206
+ action=_UNSET,
207
+ capability=_UNSET,
208
+ structure=_UNSET,
209
+ canonical_tool=_UNSET,
210
+ ) -> EventTrace:
211
+ """Return a new EventTrace with classification fields populated.
212
+
213
+ Sentinel-based: omitted fields preserve existing values.
214
+ """
215
+ kwargs: dict[str, Any] = {}
216
+ if mechanism is not _UNSET:
217
+ kwargs["mechanism"] = mechanism
218
+ if effect is not _UNSET:
219
+ kwargs["effect"] = effect
220
+ if scope is not _UNSET:
221
+ kwargs["scope"] = scope
222
+ if role is not _UNSET:
223
+ kwargs["role"] = role
224
+ if action is not _UNSET:
225
+ kwargs["action"] = action
226
+ if capability is not _UNSET:
227
+ kwargs["capability"] = capability
228
+ if structure is not _UNSET:
229
+ kwargs["structure"] = structure
230
+ if canonical_tool is not _UNSET:
231
+ kwargs["canonical_tool"] = canonical_tool
232
+ return replace(self, stage=TraceStage.CLASSIFIED, **kwargs)
233
+
234
+ def with_assessment(
235
+ self,
236
+ *,
237
+ risk_score=_UNSET,
238
+ risk_band=_UNSET,
239
+ suggested_action=_UNSET,
240
+ reason=_UNSET,
241
+ ) -> EventTrace:
242
+ """Return a new EventTrace with assessment fields populated.
243
+
244
+ Sentinel-based: omitted fields preserve existing values.
245
+ """
246
+ kwargs: dict[str, Any] = {}
247
+ if risk_score is not _UNSET:
248
+ kwargs["risk_score"] = risk_score
249
+ if risk_band is not _UNSET:
250
+ kwargs["risk_band"] = risk_band
251
+ if suggested_action is not _UNSET:
252
+ kwargs["suggested_action"] = suggested_action
253
+ if reason is not _UNSET:
254
+ kwargs["reason"] = reason
255
+ return replace(self, stage=TraceStage.ASSESSED, **kwargs)
256
+
257
+ @classmethod
258
+ def create(
259
+ cls,
260
+ *,
261
+ id: str,
262
+ kind: EventKind | str,
263
+ session_id: str,
264
+ tool_call_id: str,
265
+ timestamp: datetime,
266
+ source_key: str,
267
+ raw_event: dict[str, Any],
268
+ parent_tool_call_id: str | None = None,
269
+ tool_name: str | None = None,
270
+ tool_input: dict[str, Any] | MappingProxyType | None = None,
271
+ tool_result: str | None = None,
272
+ target_resource: str | None = None,
273
+ ) -> EventTrace:
274
+ """Factory for adapter use.
275
+
276
+ __post_init__ handles deep-freeze + enum coercion on all paths.
277
+ """
278
+ return cls(
279
+ id=id,
280
+ kind=kind,
281
+ session_id=session_id,
282
+ tool_call_id=tool_call_id,
283
+ timestamp=timestamp,
284
+ source_key=source_key,
285
+ raw_event=raw_event,
286
+ parent_tool_call_id=parent_tool_call_id,
287
+ tool_name=tool_name,
288
+ tool_input=tool_input or EMPTY_MAP,
289
+ tool_result=tool_result,
290
+ target_resource=target_resource,
291
+ )