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.
- traceforge/__init__.py +72 -0
- traceforge/__main__.py +5 -0
- traceforge/_generated.py +254 -0
- traceforge/adapters/__init__.py +12 -0
- traceforge/adapters/base.py +82 -0
- traceforge/adapters/genai_otel.py +164 -0
- traceforge/adapters/mapped_json.py +297 -0
- traceforge/adapters/otel.py +220 -0
- traceforge/boundary/__init__.py +46 -0
- traceforge/boundary/data/boundary-model.joblib +0 -0
- traceforge/boundary/decode.py +87 -0
- traceforge/boundary/features.py +137 -0
- traceforge/boundary/inference.py +267 -0
- traceforge/boundary/inferencer.py +146 -0
- traceforge/classify/__init__.py +95 -0
- traceforge/classify/cmd.py +109 -0
- traceforge/classify/coding.py +213 -0
- traceforge/classify/config.py +554 -0
- traceforge/classify/core.py +266 -0
- traceforge/classify/data/binary_info.yaml +358 -0
- traceforge/classify/data/canonical_tools.yaml +251 -0
- traceforge/classify/data/effect_overrides.yaml +480 -0
- traceforge/classify/data/mcp_profiles.yaml +711 -0
- traceforge/classify/data/recommendation_rules.yaml +111 -0
- traceforge/classify/data/risk.yaml +534 -0
- traceforge/classify/data/shell_defaults.yaml +95 -0
- traceforge/classify/data/shell_rules.yaml +1192 -0
- traceforge/classify/data/tool_classifications.yaml +215 -0
- traceforge/classify/data/verb_inference.yaml +218 -0
- traceforge/classify/mcp.py +181 -0
- traceforge/classify/phases.py +75 -0
- traceforge/classify/powershell.py +93 -0
- traceforge/classify/registry.py +138 -0
- traceforge/classify/risk.py +553 -0
- traceforge/classify/rules.py +215 -0
- traceforge/classify/schema.yaml +282 -0
- traceforge/classify/shell.py +503 -0
- traceforge/classify/tools.py +91 -0
- traceforge/classify/workflow.py +32 -0
- traceforge/cli/__init__.py +42 -0
- traceforge/cli/config_cmd.py +130 -0
- traceforge/cli/detect.py +46 -0
- traceforge/cli/download_cmd.py +74 -0
- traceforge/cli/factory.py +60 -0
- traceforge/cli/gate_cmd.py +30 -0
- traceforge/cli/init_cmd.py +86 -0
- traceforge/cli/replay.py +130 -0
- traceforge/cli/runner.py +181 -0
- traceforge/cli/score.py +217 -0
- traceforge/cli/status.py +65 -0
- traceforge/cli/watch.py +297 -0
- traceforge/config/__init__.py +80 -0
- traceforge/config/defaults.py +149 -0
- traceforge/config/loader.py +239 -0
- traceforge/config/mappings.py +104 -0
- traceforge/config/models.py +579 -0
- traceforge/enricher.py +576 -0
- traceforge/formatting/__init__.py +12 -0
- traceforge/formatting/budget.py +92 -0
- traceforge/formatting/density.py +154 -0
- traceforge/gate/__init__.py +17 -0
- traceforge/gate/client.py +145 -0
- traceforge/gate/external.py +305 -0
- traceforge/gate/registry.py +108 -0
- traceforge/gate/server.py +174 -0
- traceforge/gates/__init__.py +8 -0
- traceforge/gates/pii.py +352 -0
- traceforge/gates/pii_patterns.yaml +228 -0
- traceforge/governance/__init__.py +121 -0
- traceforge/governance/assessor.py +441 -0
- traceforge/governance/budget.py +59 -0
- traceforge/governance/canonical.py +56 -0
- traceforge/governance/codec.py +414 -0
- traceforge/governance/context.py +249 -0
- traceforge/governance/drift.py +196 -0
- traceforge/governance/emitter.py +234 -0
- traceforge/governance/envelope.py +138 -0
- traceforge/governance/ifc.py +364 -0
- traceforge/governance/integrity.py +162 -0
- traceforge/governance/labeler.py +250 -0
- traceforge/governance/mcp_drift.py +328 -0
- traceforge/governance/monitor.py +539 -0
- traceforge/governance/observer.py +276 -0
- traceforge/governance/persistence.py +401 -0
- traceforge/governance/phase1.py +77 -0
- traceforge/governance/pii.py +276 -0
- traceforge/governance/pipeline.py +840 -0
- traceforge/governance/registry.py +110 -0
- traceforge/governance/results.py +183 -0
- traceforge/governance/risk_wrapper.py +113 -0
- traceforge/governance/rules.py +368 -0
- traceforge/governance/scorer.py +262 -0
- traceforge/governance/shield.py +318 -0
- traceforge/governance/state.py +466 -0
- traceforge/governance/types.py +176 -0
- traceforge/mappings/__init__.py +1 -0
- traceforge/mappings/aider.yaml +216 -0
- traceforge/mappings/aider_markdown.yaml +113 -0
- traceforge/mappings/amazonq.yaml +56 -0
- traceforge/mappings/antigravity.yaml +88 -0
- traceforge/mappings/claude.yaml +93 -0
- traceforge/mappings/cline.yaml +286 -0
- traceforge/mappings/codex.yaml +158 -0
- traceforge/mappings/continue_dev.yaml +57 -0
- traceforge/mappings/copilot.yaml +266 -0
- traceforge/mappings/copilot_markdown.yaml +72 -0
- traceforge/mappings/copilot_vscode.yaml +181 -0
- traceforge/mappings/crewai.yaml +592 -0
- traceforge/mappings/goose.yaml +128 -0
- traceforge/mappings/langgraph.yaml +165 -0
- traceforge/mappings/maf.yaml +90 -0
- traceforge/mappings/maf_transcript.yaml +99 -0
- traceforge/mappings/openai_agents.yaml +144 -0
- traceforge/mappings/opencode.yaml +473 -0
- traceforge/mappings/openhands.yaml +320 -0
- traceforge/mappings/pydantic_ai.yaml +183 -0
- traceforge/mappings/smolagents.yaml +89 -0
- traceforge/mappings/sweagent.yaml +82 -0
- traceforge/migrations/__init__.py +1 -0
- traceforge/migrations/env.py +60 -0
- traceforge/migrations/models.py +145 -0
- traceforge/migrations/runner.py +44 -0
- traceforge/migrations/script.py.mako +25 -0
- traceforge/migrations/versions/0001_initial.py +176 -0
- traceforge/migrations/versions/__init__.py +4 -0
- traceforge/models.py +29 -0
- traceforge/parsers/__init__.py +21 -0
- traceforge/parsers/aider.py +416 -0
- traceforge/parsers/base.py +226 -0
- traceforge/parsers/copilot.py +314 -0
- traceforge/phase/__init__.py +50 -0
- traceforge/phase/data/phase-model.joblib +0 -0
- traceforge/phase/data/potion-base-8M/README.md +99 -0
- traceforge/phase/data/potion-base-8M/config.json +13 -0
- traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
- traceforge/phase/data/potion-base-8M/modules.json +14 -0
- traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
- traceforge/phase/event_rows.py +107 -0
- traceforge/phase/features.py +468 -0
- traceforge/phase/inference.py +279 -0
- traceforge/phase/inferencer.py +171 -0
- traceforge/phase/segmentation.py +258 -0
- traceforge/pipeline.py +891 -0
- traceforge/preprocessors/__init__.py +34 -0
- traceforge/preprocessors/amazonq.py +224 -0
- traceforge/preprocessors/antigravity.py +116 -0
- traceforge/preprocessors/claude.py +95 -0
- traceforge/preprocessors/cline.py +36 -0
- traceforge/preprocessors/codex.py +311 -0
- traceforge/preprocessors/continue_dev.py +119 -0
- traceforge/preprocessors/copilot_vscode.py +171 -0
- traceforge/preprocessors/goose.py +156 -0
- traceforge/preprocessors/maf_transcript.py +84 -0
- traceforge/preprocessors/openai_agents.py +86 -0
- traceforge/preprocessors/opencode.py +85 -0
- traceforge/preprocessors/openhands.py +36 -0
- traceforge/preprocessors/pydantic_ai.py +62 -0
- traceforge/preprocessors/registry.py +24 -0
- traceforge/preprocessors/smolagents.py +90 -0
- traceforge/py.typed +0 -0
- traceforge/sdk/__init__.py +59 -0
- traceforge/sdk/gate_policy.py +63 -0
- traceforge/sdk/gate_types.py +140 -0
- traceforge/sdk/pipeline.py +265 -0
- traceforge/sdk/verdict.py +81 -0
- traceforge/sinks/__init__.py +23 -0
- traceforge/sinks/base.py +95 -0
- traceforge/sinks/callback.py +132 -0
- traceforge/sinks/console.py +112 -0
- traceforge/sinks/factory.py +94 -0
- traceforge/sinks/jsonl.py +125 -0
- traceforge/sinks/otel_exporter.py +212 -0
- traceforge/sinks/parquet.py +260 -0
- traceforge/sinks/s3.py +206 -0
- traceforge/sinks/sqlite_output.py +234 -0
- traceforge/sinks/webhook.py +136 -0
- traceforge/sources/__init__.py +18 -0
- traceforge/sources/auto_detect.py +173 -0
- traceforge/sources/base.py +45 -0
- traceforge/sources/file_poll.py +136 -0
- traceforge/sources/file_watch.py +221 -0
- traceforge/sources/http_poll.py +141 -0
- traceforge/sources/replay.py +63 -0
- traceforge/sources/sqlite.py +187 -0
- traceforge/sources/sse.py +198 -0
- traceforge/telemetry/__init__.py +192 -0
- traceforge/title/__init__.py +23 -0
- traceforge/title/_resolve.py +79 -0
- traceforge/title/context.py +295 -0
- traceforge/title/data/boilerplate_files.json +14 -0
- traceforge/title/heuristics.py +429 -0
- traceforge/title/hygiene.py +90 -0
- traceforge/title/inference.py +314 -0
- traceforge/title/inferencer.py +477 -0
- traceforge/title/naming.py +398 -0
- traceforge/trace.py +291 -0
- traceforge/tracking/__init__.py +30 -0
- traceforge/tracking/models.py +115 -0
- traceforge/tracking/phase_tracker.py +288 -0
- traceforge/types.py +315 -0
- traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
- traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
- traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
- traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Data-driven adapter for file-watch frameworks.
|
|
2
|
+
|
|
3
|
+
Parses JSON events using a declarative YAML mapping config. Each framework
|
|
4
|
+
gets a YAML file describing how to extract event type, timestamp, session ID,
|
|
5
|
+
and payload fields — no custom Python code needed per framework.
|
|
6
|
+
|
|
7
|
+
Frameworks with non-flat event schemas use a preprocessor that normalizes
|
|
8
|
+
raw dicts into a flat {type_field: value, ...} shape before YAML mapping.
|
|
9
|
+
Preprocessors live in the traceforge.preprocessors package.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import uuid
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
from pydantic import Field
|
|
21
|
+
|
|
22
|
+
from traceforge.adapters.base import JsonLineAdapter
|
|
23
|
+
from traceforge.models import StrictModel
|
|
24
|
+
from traceforge.preprocessors import get_preprocessor
|
|
25
|
+
from traceforge.types import EventKind, EventMetadata, IngestionMode, SessionEvent, ToolMotivation
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ─── Dot-path access ─────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_path(obj: Any, path: str) -> Any:
|
|
34
|
+
"""Resolve a dot-separated path against a nested dict/list.
|
|
35
|
+
|
|
36
|
+
Supports: "foo.bar", "foo.0.bar" (integer index), "foo" (top-level).
|
|
37
|
+
Returns None if any segment is missing.
|
|
38
|
+
"""
|
|
39
|
+
current = obj
|
|
40
|
+
for segment in path.split("."):
|
|
41
|
+
if current is None:
|
|
42
|
+
return None
|
|
43
|
+
if isinstance(current, dict):
|
|
44
|
+
current = current.get(segment)
|
|
45
|
+
elif isinstance(current, (list, tuple)):
|
|
46
|
+
try:
|
|
47
|
+
current = current[int(segment)]
|
|
48
|
+
except (ValueError, IndexError):
|
|
49
|
+
return None
|
|
50
|
+
else:
|
|
51
|
+
return None
|
|
52
|
+
return current
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ─── Mapping Config ──────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class EventMapping(StrictModel):
|
|
59
|
+
"""Mapping for a single raw event type → canonical kind + payload extraction."""
|
|
60
|
+
|
|
61
|
+
kind: str # canonical EventKind string
|
|
62
|
+
payload: dict[str, str] = Field(default_factory=dict) # field_name → dot-path
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SpanMapping(StrictModel):
|
|
66
|
+
"""Mapping for an OTel span name → canonical kind + attribute extraction."""
|
|
67
|
+
|
|
68
|
+
kind: str # canonical EventKind string
|
|
69
|
+
attributes: dict[str, str] = Field(default_factory=dict) # payload_field → otel_attr_key
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class MotivationSource(StrictModel):
|
|
73
|
+
"""A single source of motivation text from the event stream."""
|
|
74
|
+
|
|
75
|
+
events: list[str] # raw event type keys that carry this motivation
|
|
76
|
+
field: str = "content" # payload field (after mapping) containing the text
|
|
77
|
+
role: Literal["intent", "reasoning"] = "intent" # which slot this fills
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MotivationConfig(StrictModel):
|
|
81
|
+
"""Declarative motivation tracking configuration for a framework."""
|
|
82
|
+
|
|
83
|
+
sources: list[MotivationSource] = Field(default_factory=list)
|
|
84
|
+
targets: list[str] = Field(default_factory=lambda: ["tool.call.started", "tool.call.completed"])
|
|
85
|
+
source_window: int = Field(default=10, ge=1) # max source_event_ids to keep (rolling window)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class FrameworkMapping(StrictModel):
|
|
89
|
+
"""Declarative mapping config for a framework's JSON events."""
|
|
90
|
+
|
|
91
|
+
framework: str # e.g. "crewai", "openhands"
|
|
92
|
+
framework_version: str # version of the framework's event format this mapping targets
|
|
93
|
+
ingestion_mode: IngestionMode # must be explicit in YAML
|
|
94
|
+
type_field: str = "type" # dot-path to event type in raw JSON
|
|
95
|
+
timestamp_field: str | None = None # dot-path to timestamp
|
|
96
|
+
default_kind: str = EventKind.RAW # kind for unmapped event types
|
|
97
|
+
preprocessor: str | None = None # registered preprocessor name (optional)
|
|
98
|
+
events: dict[str, EventMapping] = Field(default_factory=dict) # raw_type → mapping
|
|
99
|
+
spans: dict[str, SpanMapping] = Field(default_factory=dict) # otel_span_name → mapping
|
|
100
|
+
|
|
101
|
+
# Motivation tracking config
|
|
102
|
+
motivation: MotivationConfig | None = None
|
|
103
|
+
|
|
104
|
+
def get_motivation_config(self) -> MotivationConfig:
|
|
105
|
+
"""Return the effective MotivationConfig."""
|
|
106
|
+
if self.motivation is not None:
|
|
107
|
+
return self.motivation
|
|
108
|
+
return MotivationConfig()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ─── Adapter ─────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class MappedJsonAdapter(JsonLineAdapter):
|
|
115
|
+
"""Generic adapter driven by a FrameworkMapping config.
|
|
116
|
+
|
|
117
|
+
Parses JSON lines using declarative dot-path extraction. Adding support
|
|
118
|
+
for a new framework requires only a YAML mapping file, not Python code.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(self, mapping: FrameworkMapping, session_id: str):
|
|
122
|
+
self._mapping = mapping
|
|
123
|
+
self._session_id = session_id
|
|
124
|
+
self._motivation_config = mapping.get_motivation_config()
|
|
125
|
+
# Build lookup: raw_type → list of (field, role) for quick matching
|
|
126
|
+
self._motivation_lookup: dict[str, list[tuple[str, str]]] = {}
|
|
127
|
+
for source in self._motivation_config.sources:
|
|
128
|
+
for event_type in source.events:
|
|
129
|
+
self._motivation_lookup.setdefault(event_type, []).append(
|
|
130
|
+
(source.field, source.role)
|
|
131
|
+
)
|
|
132
|
+
self._target_kinds = frozenset(self._motivation_config.targets)
|
|
133
|
+
# Accumulated motivation state
|
|
134
|
+
self._latest_intent: str | None = None
|
|
135
|
+
self._latest_reasoning: str | None = None
|
|
136
|
+
self._source_event_ids: list[str] = []
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def framework(self) -> str:
|
|
140
|
+
return self._mapping.framework
|
|
141
|
+
|
|
142
|
+
def parse_dict(self, obj: dict[str, Any]) -> Iterator[SessionEvent]:
|
|
143
|
+
"""Extract event type, timestamp, session, payload from JSON dict.
|
|
144
|
+
|
|
145
|
+
If a preprocessor is registered for this framework, the raw dict is
|
|
146
|
+
first normalized into one or more flat dicts before mapping.
|
|
147
|
+
"""
|
|
148
|
+
# Apply preprocessor if configured
|
|
149
|
+
preprocessor_name = self._mapping.preprocessor
|
|
150
|
+
if preprocessor_name:
|
|
151
|
+
preprocessor = get_preprocessor(preprocessor_name)
|
|
152
|
+
if preprocessor:
|
|
153
|
+
normalized_dicts = preprocessor(obj)
|
|
154
|
+
else:
|
|
155
|
+
normalized_dicts = [obj]
|
|
156
|
+
else:
|
|
157
|
+
normalized_dicts = [obj]
|
|
158
|
+
|
|
159
|
+
for norm_obj in normalized_dicts:
|
|
160
|
+
yield from self._map_single(norm_obj)
|
|
161
|
+
|
|
162
|
+
def _map_single(self, obj: dict[str, Any]) -> Iterator[SessionEvent]:
|
|
163
|
+
"""Map a single normalized dict to a SessionEvent."""
|
|
164
|
+
# Extract event type
|
|
165
|
+
raw_type = _resolve_path(obj, self._mapping.type_field)
|
|
166
|
+
if raw_type is None:
|
|
167
|
+
raw_type = "unknown"
|
|
168
|
+
raw_type = str(raw_type)
|
|
169
|
+
|
|
170
|
+
# Resolve mapping
|
|
171
|
+
event_mapping = self._mapping.events.get(raw_type)
|
|
172
|
+
kind = event_mapping.kind if event_mapping else self._mapping.default_kind
|
|
173
|
+
if event_mapping is None and not kind:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
# Extract timestamp
|
|
177
|
+
timestamp = datetime.now(timezone.utc)
|
|
178
|
+
if self._mapping.timestamp_field:
|
|
179
|
+
ts_raw = _resolve_path(obj, self._mapping.timestamp_field)
|
|
180
|
+
if ts_raw is not None:
|
|
181
|
+
timestamp = self._parse_timestamp(ts_raw)
|
|
182
|
+
|
|
183
|
+
# Extract payload
|
|
184
|
+
payload: dict[str, Any] = {}
|
|
185
|
+
if event_mapping and event_mapping.payload:
|
|
186
|
+
for field_name, dot_path in event_mapping.payload.items():
|
|
187
|
+
if dot_path.startswith("literal:"):
|
|
188
|
+
# Explicit literal value (not a path resolution)
|
|
189
|
+
value = dot_path[len("literal:") :]
|
|
190
|
+
else:
|
|
191
|
+
value = _resolve_path(obj, dot_path)
|
|
192
|
+
if value is not None:
|
|
193
|
+
payload[field_name] = value
|
|
194
|
+
|
|
195
|
+
# For unmapped or RAW events, note the original type
|
|
196
|
+
if kind == EventKind.RAW or not event_mapping:
|
|
197
|
+
payload["original_type"] = raw_type
|
|
198
|
+
|
|
199
|
+
# Generate event ID upfront (needed for source_event_ids tracking)
|
|
200
|
+
event_id = str(uuid.uuid4())
|
|
201
|
+
|
|
202
|
+
# Track motivation from designated source events
|
|
203
|
+
if raw_type in self._motivation_lookup:
|
|
204
|
+
seen_this_event = False
|
|
205
|
+
for field, role in self._motivation_lookup[raw_type]:
|
|
206
|
+
text = self._extract_text(payload, field)
|
|
207
|
+
if role == "intent":
|
|
208
|
+
self._latest_intent = text
|
|
209
|
+
else:
|
|
210
|
+
self._latest_reasoning = text
|
|
211
|
+
# Record source event ID once per raw event (not per role)
|
|
212
|
+
if not seen_this_event:
|
|
213
|
+
self._source_event_ids.append(event_id)
|
|
214
|
+
seen_this_event = True
|
|
215
|
+
# Enforce rolling window
|
|
216
|
+
if len(self._source_event_ids) > self._motivation_config.source_window:
|
|
217
|
+
self._source_event_ids = self._source_event_ids[
|
|
218
|
+
-self._motivation_config.source_window :
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
# Build motivation for target events (None if no content in either slot)
|
|
222
|
+
motivation: ToolMotivation | None = None
|
|
223
|
+
if kind in self._target_kinds and (self._latest_intent or self._latest_reasoning):
|
|
224
|
+
motivation = ToolMotivation(
|
|
225
|
+
intent=self._latest_intent,
|
|
226
|
+
reasoning=self._latest_reasoning,
|
|
227
|
+
source_event_ids=tuple(self._source_event_ids),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
metadata = EventMetadata(
|
|
231
|
+
source_framework=self._mapping.framework,
|
|
232
|
+
ingestion_mode=self._mapping.ingestion_mode,
|
|
233
|
+
raw_kind=raw_type,
|
|
234
|
+
motivation=motivation,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
yield SessionEvent(
|
|
238
|
+
id=event_id,
|
|
239
|
+
kind=kind,
|
|
240
|
+
session_id=self._session_id,
|
|
241
|
+
timestamp=timestamp,
|
|
242
|
+
payload=payload,
|
|
243
|
+
metadata=metadata,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _extract_text(payload: dict[str, Any], field: str) -> str | None:
|
|
248
|
+
"""Extract text from a payload field, handling list-type content."""
|
|
249
|
+
value = payload.get(field)
|
|
250
|
+
if value is None:
|
|
251
|
+
return None
|
|
252
|
+
# List-type content (e.g. Claude content blocks): join text items
|
|
253
|
+
if isinstance(value, list):
|
|
254
|
+
text_parts = [str(item) for item in value if item]
|
|
255
|
+
value = "\n".join(text_parts)
|
|
256
|
+
else:
|
|
257
|
+
value = str(value)
|
|
258
|
+
# Empty string → treat as None
|
|
259
|
+
if not value.strip():
|
|
260
|
+
return None
|
|
261
|
+
return value
|
|
262
|
+
|
|
263
|
+
@staticmethod
|
|
264
|
+
def _parse_timestamp(value: Any) -> datetime:
|
|
265
|
+
"""Best-effort timestamp parsing."""
|
|
266
|
+
if isinstance(value, datetime):
|
|
267
|
+
return value
|
|
268
|
+
if isinstance(value, (int, float)):
|
|
269
|
+
try:
|
|
270
|
+
# Heuristic: >1e15 = nanoseconds, >1e12 = milliseconds
|
|
271
|
+
if value > 1e15:
|
|
272
|
+
value = value / 1_000_000_000
|
|
273
|
+
elif value > 1e12:
|
|
274
|
+
value = value / 1_000
|
|
275
|
+
return datetime.fromtimestamp(value, tz=timezone.utc)
|
|
276
|
+
except (OSError, OverflowError, ValueError):
|
|
277
|
+
return datetime.now(timezone.utc)
|
|
278
|
+
if isinstance(value, str):
|
|
279
|
+
# Try ISO format
|
|
280
|
+
try:
|
|
281
|
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
282
|
+
if dt.tzinfo is None:
|
|
283
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
284
|
+
return dt
|
|
285
|
+
except ValueError:
|
|
286
|
+
pass
|
|
287
|
+
return datetime.now(timezone.utc)
|
|
288
|
+
|
|
289
|
+
@classmethod
|
|
290
|
+
def from_yaml(cls, yaml_path: str, session_id: str) -> "MappedJsonAdapter":
|
|
291
|
+
"""Load a MappedJsonAdapter from a YAML mapping file."""
|
|
292
|
+
import yaml
|
|
293
|
+
|
|
294
|
+
with open(yaml_path) as f:
|
|
295
|
+
data = yaml.safe_load(f)
|
|
296
|
+
mapping = FrameworkMapping.model_validate(data)
|
|
297
|
+
return cls(mapping=mapping, session_id=session_id)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Adapter for Microsoft 365 Agents SDK (MAF) OTel spans.
|
|
2
|
+
|
|
3
|
+
The Microsoft 365 Agents SDK emits OpenTelemetry spans, not JSON lines.
|
|
4
|
+
This adapter ingests exported OTLP span data (as JSON dicts) and maps
|
|
5
|
+
MAF span names to canonical traceforge event kinds.
|
|
6
|
+
|
|
7
|
+
Span-to-kind mapping and attribute extraction rules are loaded from
|
|
8
|
+
``mappings/maf.yaml`` — no hardcoded dispatch tables.
|
|
9
|
+
|
|
10
|
+
Ingestion mode is always "stream" — spans are received from an OTel
|
|
11
|
+
exporter (e.g., InMemorySpanExporter or OTLP collector).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from collections.abc import Iterator
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
from traceforge.adapters.base import Adapter
|
|
25
|
+
from traceforge.types import EventKind, EventMetadata, IngestionMode, SessionEvent
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# ─── Load span mapping from YAML ─────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
_MAPPINGS_DIR = Path(__file__).resolve().parent.parent / "mappings"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_maf_mapping() -> tuple[dict[str, str], dict[str, dict[str, str]]]:
|
|
35
|
+
"""Load span kind map and attribute extractors from maf.yaml."""
|
|
36
|
+
yaml_path = _MAPPINGS_DIR / "maf.yaml"
|
|
37
|
+
with open(yaml_path) as f:
|
|
38
|
+
data = yaml.safe_load(f)
|
|
39
|
+
|
|
40
|
+
span_kind_map: dict[str, str] = {}
|
|
41
|
+
attribute_extractors: dict[str, dict[str, str]] = {}
|
|
42
|
+
|
|
43
|
+
for span_name, config in data.get("spans", {}).items():
|
|
44
|
+
span_kind_map[span_name] = config["kind"]
|
|
45
|
+
attrs = config.get("attributes")
|
|
46
|
+
if attrs:
|
|
47
|
+
attribute_extractors[span_name] = attrs
|
|
48
|
+
|
|
49
|
+
return span_kind_map, attribute_extractors
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_SPAN_KIND_MAP, _ATTRIBUTE_EXTRACTORS = _load_maf_mapping()
|
|
53
|
+
|
|
54
|
+
# OTel status codes
|
|
55
|
+
_STATUS_OK = 1
|
|
56
|
+
_STATUS_ERROR = 2
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class OtelSpanAdapter(Adapter):
|
|
60
|
+
"""Ingests OTel span dicts (exported from MAF) into canonical SessionEvents.
|
|
61
|
+
|
|
62
|
+
Accepts spans as dicts with standard OTel JSON export fields:
|
|
63
|
+
- name: span name
|
|
64
|
+
- start_time_unix_nano / end_time_unix_nano: timestamps
|
|
65
|
+
- status: {status_code: int}
|
|
66
|
+
- attributes: [{key, value}] or dict
|
|
67
|
+
- resource: {attributes: ...}
|
|
68
|
+
|
|
69
|
+
Works with both OTLP JSON export format and simplified dict format.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
SOURCE_FRAMEWORK = "maf"
|
|
73
|
+
|
|
74
|
+
def __init__(self, ingestion_mode: IngestionMode, session_id: str) -> None:
|
|
75
|
+
self._session_id = session_id
|
|
76
|
+
self._ingestion_mode = ingestion_mode
|
|
77
|
+
|
|
78
|
+
def parse_span(self, span: dict[str, Any]) -> Iterator[SessionEvent]:
|
|
79
|
+
"""Convert a single OTel span dict to SessionEvent(s).
|
|
80
|
+
|
|
81
|
+
Handles both snake_case (simplified) and camelCase (OTLP JSON) keys.
|
|
82
|
+
Never raises — logs and skips malformed spans.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
yield from self._parse_span_inner(span)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
logger.debug("OtelSpanAdapter: skipping malformed span: %s", exc)
|
|
88
|
+
|
|
89
|
+
def _parse_span_inner(self, span: dict[str, Any]) -> Iterator[SessionEvent]:
|
|
90
|
+
span_name = span.get("name", "")
|
|
91
|
+
if not span_name:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
kind = _SPAN_KIND_MAP.get(span_name, EventKind.RAW)
|
|
95
|
+
attributes = _normalize_attributes(span.get("attributes", {}))
|
|
96
|
+
status = span.get("status", {})
|
|
97
|
+
status_code = (
|
|
98
|
+
status.get("status_code", _STATUS_OK) if isinstance(status, dict) else _STATUS_OK
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Timestamps — support both snake_case and camelCase keys, coerce strings
|
|
102
|
+
start_ns = (
|
|
103
|
+
span.get("start_time_unix_nano")
|
|
104
|
+
or span.get("startTimeUnixNano")
|
|
105
|
+
or span.get("start_time")
|
|
106
|
+
)
|
|
107
|
+
end_ns = (
|
|
108
|
+
span.get("end_time_unix_nano") or span.get("endTimeUnixNano") or span.get("end_time")
|
|
109
|
+
)
|
|
110
|
+
if start_ns is not None:
|
|
111
|
+
start_ns = int(start_ns)
|
|
112
|
+
if end_ns is not None:
|
|
113
|
+
end_ns = int(end_ns)
|
|
114
|
+
|
|
115
|
+
start_time = _ns_to_datetime(start_ns) if start_ns else datetime.now(timezone.utc)
|
|
116
|
+
end_time = _ns_to_datetime(end_ns) if end_ns else start_time
|
|
117
|
+
|
|
118
|
+
duration_ms: float | None = None
|
|
119
|
+
if start_ns and end_ns:
|
|
120
|
+
duration_ms = max(0.0, (end_ns - start_ns) / 1_000_000)
|
|
121
|
+
|
|
122
|
+
# Determine if this is an error
|
|
123
|
+
is_error = status_code == _STATUS_ERROR
|
|
124
|
+
if is_error:
|
|
125
|
+
kind = EventKind.ERROR
|
|
126
|
+
|
|
127
|
+
# Build payload from span attributes
|
|
128
|
+
payload = self._build_payload(span_name, attributes, duration_ms, is_error, status)
|
|
129
|
+
|
|
130
|
+
metadata = EventMetadata(
|
|
131
|
+
source_framework=self.SOURCE_FRAMEWORK,
|
|
132
|
+
ingestion_mode=self._ingestion_mode,
|
|
133
|
+
raw_kind=span_name,
|
|
134
|
+
duration_ms=duration_ms,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
yield SessionEvent(
|
|
138
|
+
kind=kind,
|
|
139
|
+
session_id=self._session_id,
|
|
140
|
+
timestamp=end_time,
|
|
141
|
+
payload=payload,
|
|
142
|
+
metadata=metadata,
|
|
143
|
+
raw_event=span,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def parse(self, raw: str) -> Iterator[SessionEvent]:
|
|
147
|
+
"""Parse a JSON string containing an OTel span."""
|
|
148
|
+
import json
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
span = json.loads(raw)
|
|
152
|
+
except (json.JSONDecodeError, TypeError) as exc:
|
|
153
|
+
logger.debug("OtelSpanAdapter: JSON parse failed: %s", exc)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
if isinstance(span, dict):
|
|
157
|
+
yield from self.parse_span(span)
|
|
158
|
+
elif isinstance(span, list):
|
|
159
|
+
for item in span:
|
|
160
|
+
if isinstance(item, dict):
|
|
161
|
+
yield from self.parse_span(item)
|
|
162
|
+
|
|
163
|
+
def _build_payload(
|
|
164
|
+
self,
|
|
165
|
+
span_name: str,
|
|
166
|
+
attributes: dict[str, Any],
|
|
167
|
+
duration_ms: float | None,
|
|
168
|
+
is_error: bool,
|
|
169
|
+
status: dict[str, Any],
|
|
170
|
+
) -> dict[str, Any]:
|
|
171
|
+
"""Build event payload from OTel span attributes."""
|
|
172
|
+
payload: dict[str, Any] = {}
|
|
173
|
+
|
|
174
|
+
# Extract known MAF attributes
|
|
175
|
+
attr_map = _ATTRIBUTE_EXTRACTORS.get(span_name)
|
|
176
|
+
if attr_map:
|
|
177
|
+
for payload_key, attr_key in attr_map.items():
|
|
178
|
+
val = attributes.get(attr_key)
|
|
179
|
+
if val is not None:
|
|
180
|
+
payload[payload_key] = val
|
|
181
|
+
|
|
182
|
+
if is_error:
|
|
183
|
+
payload["message"] = status.get("message", "Unknown error")
|
|
184
|
+
|
|
185
|
+
if duration_ms is not None:
|
|
186
|
+
payload["duration_ms"] = duration_ms
|
|
187
|
+
|
|
188
|
+
# For unmapped spans, always include the span name
|
|
189
|
+
if span_name not in _SPAN_KIND_MAP:
|
|
190
|
+
payload["original_type"] = span_name
|
|
191
|
+
|
|
192
|
+
return payload
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _ns_to_datetime(ns: int | float) -> datetime:
|
|
199
|
+
"""Convert nanosecond unix timestamp to datetime."""
|
|
200
|
+
return datetime.fromtimestamp(ns / 1_000_000_000, tz=timezone.utc)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _normalize_attributes(attrs: Any) -> dict[str, Any]:
|
|
204
|
+
"""Normalize OTel attributes from either list-of-dicts or flat dict format."""
|
|
205
|
+
if isinstance(attrs, dict):
|
|
206
|
+
return attrs
|
|
207
|
+
if isinstance(attrs, list):
|
|
208
|
+
result: dict[str, Any] = {}
|
|
209
|
+
for item in attrs:
|
|
210
|
+
if isinstance(item, dict) and "key" in item:
|
|
211
|
+
val = item.get("value", {})
|
|
212
|
+
if isinstance(val, dict):
|
|
213
|
+
# OTel proto format: value is {stringValue: x} or {intValue: x}
|
|
214
|
+
for v in val.values():
|
|
215
|
+
result[item["key"]] = v
|
|
216
|
+
break
|
|
217
|
+
else:
|
|
218
|
+
result[item["key"]] = val
|
|
219
|
+
return result
|
|
220
|
+
return {}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Per-gap activity/step boundary classifier (production-loadable).
|
|
2
|
+
|
|
3
|
+
Mirrors :mod:`traceforge.phase` but for the *segmentation* task: each gap between
|
|
4
|
+
two consecutive events is labelled ``noise`` / ``activity-boundary`` /
|
|
5
|
+
``step-boundary``. The featuriser is shared with the phase classifier
|
|
6
|
+
(``traceforge.phase.features``) so there is no train/serve skew, and every
|
|
7
|
+
feature is **causal** — a gap after event ``t`` is decided once ``t+1`` has
|
|
8
|
+
arrived, using only trailing-window segmentation state.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .decode import DecodeParams, StreamingBoundaryDecoder, decode_scores
|
|
14
|
+
from .features import GapExample, build_gap_example, featurize_session_gaps
|
|
15
|
+
from .inference import (
|
|
16
|
+
BOUNDARY_CLASSES,
|
|
17
|
+
DEFAULT_FEATURE_SET,
|
|
18
|
+
BoundaryModel,
|
|
19
|
+
decode_examples,
|
|
20
|
+
fit_boundary_model,
|
|
21
|
+
load,
|
|
22
|
+
predict_examples,
|
|
23
|
+
predict_session,
|
|
24
|
+
save,
|
|
25
|
+
)
|
|
26
|
+
from .inferencer import BoundaryInferencer, SessionBoundaryStream
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"BOUNDARY_CLASSES",
|
|
30
|
+
"DEFAULT_FEATURE_SET",
|
|
31
|
+
"BoundaryInferencer",
|
|
32
|
+
"BoundaryModel",
|
|
33
|
+
"DecodeParams",
|
|
34
|
+
"GapExample",
|
|
35
|
+
"SessionBoundaryStream",
|
|
36
|
+
"StreamingBoundaryDecoder",
|
|
37
|
+
"build_gap_example",
|
|
38
|
+
"decode_examples",
|
|
39
|
+
"decode_scores",
|
|
40
|
+
"featurize_session_gaps",
|
|
41
|
+
"fit_boundary_model",
|
|
42
|
+
"load",
|
|
43
|
+
"predict_examples",
|
|
44
|
+
"predict_session",
|
|
45
|
+
"save",
|
|
46
|
+
]
|
|
Binary file
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Causal, streamable decoding of per-gap boundary scores into a session TOC.
|
|
2
|
+
|
|
3
|
+
The classifier (:mod:`traceforge.boundary.inference`) emits per-gap class scores.
|
|
4
|
+
Taking ``argmax`` over them floods false positives (boundaries are 1-4% of gaps,
|
|
5
|
+
and the balanced estimator over-predicts the minority classes) and clusters
|
|
6
|
+
several predictions around each true boundary. This module turns scores into
|
|
7
|
+
labels with two data-derived levers, both applied **causally** so the same code
|
|
8
|
+
runs live as events stream:
|
|
9
|
+
|
|
10
|
+
* per-class **threshold** ``t_c`` (learned as the F1-optimal point of the
|
|
11
|
+
precision-recall curve at persist time) instead of ``argmax``; and
|
|
12
|
+
* a per-class **refractory min-gap** ``g_c`` (learned from the gold spacing
|
|
13
|
+
distribution) that suppresses a fresh boundary of class ``c`` within ``g_c``
|
|
14
|
+
events of the previously emitted one — the streamable analogue of non-max
|
|
15
|
+
suppression.
|
|
16
|
+
|
|
17
|
+
The decoder is O(1) per gap and holds only one integer per class (the index of
|
|
18
|
+
the last emitted boundary), so it adds no measurable CPU/memory footprint over
|
|
19
|
+
the existing per-gap inference.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
#: Coarser-first priority: at a gap that clears multiple thresholds, the coarser
|
|
29
|
+
#: class claims it (and its refractory decides emit-vs-noise).
|
|
30
|
+
DEFAULT_PRIORITY: tuple[str, ...] = ("activity-boundary", "step-boundary")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class DecodeParams:
|
|
35
|
+
"""Learned decode levers, persisted in the model bundle.
|
|
36
|
+
|
|
37
|
+
``thresholds`` and ``min_gaps`` are keyed by non-``noise`` class. A class
|
|
38
|
+
absent from ``thresholds`` falls back to never firing on threshold; a class
|
|
39
|
+
absent from ``min_gaps`` uses a refractory of 1 (no suppression).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
thresholds: dict[str, float] = field(default_factory=dict)
|
|
43
|
+
min_gaps: dict[str, int] = field(default_factory=dict)
|
|
44
|
+
priority: tuple[str, ...] = DEFAULT_PRIORITY
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class StreamingBoundaryDecoder:
|
|
48
|
+
"""Stateful causal decoder. Feed per-gap scores in seq order via :meth:`push`.
|
|
49
|
+
|
|
50
|
+
Holds only ``i`` (the current gap index) and the last-emitted index per
|
|
51
|
+
class, so it is safe to run indefinitely on a live event stream.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, params: DecodeParams) -> None:
|
|
55
|
+
self._p = params
|
|
56
|
+
self._i = -1
|
|
57
|
+
self._last: dict[str, int] = {c: -(10**9) for c in params.priority}
|
|
58
|
+
|
|
59
|
+
def push(self, scores: dict[str, float]) -> str:
|
|
60
|
+
"""Return the decoded label for the next gap given its class scores."""
|
|
61
|
+
self._i += 1
|
|
62
|
+
for c in self._p.priority:
|
|
63
|
+
t = self._p.thresholds.get(c)
|
|
64
|
+
if t is None or scores.get(c, 0.0) < t:
|
|
65
|
+
continue
|
|
66
|
+
# This class claims the gap; the refractory decides emit vs. noise so
|
|
67
|
+
# a suppressed coarser boundary is never relabelled as a finer one.
|
|
68
|
+
if self._i - self._last[c] >= self._p.min_gaps.get(c, 1):
|
|
69
|
+
self._last[c] = self._i
|
|
70
|
+
return c
|
|
71
|
+
return "noise"
|
|
72
|
+
return "noise"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def decode_scores(params: DecodeParams, classes: tuple[str, ...], scores: np.ndarray) -> list[str]:
|
|
76
|
+
"""Decode a session's per-gap score matrix (``n_gaps x n_classes``, aligned
|
|
77
|
+
to ``classes``) into labels, causally and in order."""
|
|
78
|
+
dec = StreamingBoundaryDecoder(params)
|
|
79
|
+
idx = {c: i for i, c in enumerate(classes)}
|
|
80
|
+
cols = {c: idx[c] for c in params.priority if c in idx}
|
|
81
|
+
out: list[str] = []
|
|
82
|
+
for row in scores:
|
|
83
|
+
out.append(dec.push({c: float(row[j]) for c, j in cols.items()}))
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = ["DEFAULT_PRIORITY", "DecodeParams", "StreamingBoundaryDecoder", "decode_scores"]
|