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,477 @@
|
|
|
1
|
+
"""Live activity/step **titling** over a boundary-stamped event stream.
|
|
2
|
+
|
|
3
|
+
The boundary classifier (:mod:`traceforge.boundary`) has already divided the
|
|
4
|
+
session into a two-level activity/step structure by stamping the opening label
|
|
5
|
+
on the event that *opens* each segment (``metadata.boundary``). This module is
|
|
6
|
+
the next stage: it turns those segments into human-readable titles.
|
|
7
|
+
|
|
8
|
+
A faithful title needs the segment's **whole** content, and a segment is only
|
|
9
|
+
complete once its closing boundary fires (i.e. the next segment opens). Rather
|
|
10
|
+
than hold the segment's events back until then -- which would stall live
|
|
11
|
+
emission for the length of an activity -- this stream is a *streaming
|
|
12
|
+
enrichment*: it assigns each segment a stable id the instant it opens (the
|
|
13
|
+
opening event's id), stamps that ``activity_id``/``step_id`` on every event, and
|
|
14
|
+
releases the event **immediately**. When the activity later closes, it is
|
|
15
|
+
distilled (:func:`traceforge.title.context.distilled_context`) and titled with
|
|
16
|
+
the torch-free :class:`traceforge.title.TitleModel`, and the titles are published
|
|
17
|
+
as append-only :class:`traceforge.types.TitleUpdate` records keyed to those ids
|
|
18
|
+
-- never by mutating the already-emitted events. Titling at the activity
|
|
19
|
+
granularity lets the activity title see all its steps while each step title
|
|
20
|
+
still sees its own full content, and keeps step titles distinct from their
|
|
21
|
+
parent activity and siblings via :func:`traceforge.title.hygiene.pick_distinct`.
|
|
22
|
+
|
|
23
|
+
The model is loaded lazily on first close and runs CPU-only with a capped thread
|
|
24
|
+
count, so an inactive session costs nothing and an active one runs the heavy
|
|
25
|
+
model once per *segment*, never per event.
|
|
26
|
+
|
|
27
|
+
Activity/step titles default to that packaged model alone, but are also
|
|
28
|
+
configurable (mirroring session naming): when ``title.activity_titling.strategy``
|
|
29
|
+
is ``api`` *and* a provider key is present, each closed activity's packaged
|
|
30
|
+
titles are additionally queued for an **off-hot-path** LiteLLM refinement
|
|
31
|
+
(:meth:`TitleInferencer.refine_activity`, backed by
|
|
32
|
+
:mod:`traceforge.title.naming`). The pipeline upgrades the activity title and all
|
|
33
|
+
its step titles in one call in a worker thread and emits them as later
|
|
34
|
+
append-only :class:`~traceforge.types.TitleUpdate` records on the *same* segment
|
|
35
|
+
ids. Absent a key, or on any API failure/timeout, the packaged titles stand, so
|
|
36
|
+
the default (``strategy=model``) path is byte-for-byte unchanged and never blocks
|
|
37
|
+
live emission.
|
|
38
|
+
|
|
39
|
+
The same machinery also titles the **session** itself: fed the first substantive
|
|
40
|
+
user message (:meth:`TitleInferencer.request_title`), it emits a ``kind="session"``
|
|
41
|
+
:class:`~traceforge.types.TitleUpdate` keyed by the session id -- the session label,
|
|
42
|
+
live off its opening request. Session naming does **not** use the span model: the
|
|
43
|
+
distilled request head was proven weak at it (~9% coherent on the honest CodePlane
|
|
44
|
+
heldout), so the session title is produced by :mod:`traceforge.title.naming` -- a
|
|
45
|
+
deterministic, zero-cost heuristic over the user's own words by default, with an
|
|
46
|
+
opt-in LiteLLM API tier engaged only when a key is configured.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
|
|
53
|
+
from traceforge.phase.event_rows import event_to_feature_row
|
|
54
|
+
from traceforge.types import EventKind, SessionEvent, TitleUpdate
|
|
55
|
+
|
|
56
|
+
from .context import distilled_context, narration, payload_text
|
|
57
|
+
from .hygiene import best_of, norm_key, pick_distinct
|
|
58
|
+
|
|
59
|
+
_ACTIVITY = "activity-boundary"
|
|
60
|
+
_STEP = "step-boundary"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TitleInferencer:
|
|
64
|
+
"""Loads the torch-free titler once and applies it to live spans.
|
|
65
|
+
|
|
66
|
+
Construct with an explicit ``model``/``model_dir`` or rely on the packaged
|
|
67
|
+
default. The (heavy, optional-dependency) span model loads lazily on the
|
|
68
|
+
first closed segment, so a pipeline with no titled activities never imports
|
|
69
|
+
it. Session naming is served separately by :mod:`traceforge.title.naming`;
|
|
70
|
+
inject a ``session_titler`` callable to override it (e.g. in tests).
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
model=None,
|
|
76
|
+
model_dir=None,
|
|
77
|
+
session_titler=None,
|
|
78
|
+
session_refiner=None,
|
|
79
|
+
activity_refiner=None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self._model = model
|
|
82
|
+
self._model_dir = model_dir
|
|
83
|
+
# Explicit overrides for the session titler (tests inject plain
|
|
84
|
+
# callables). ``session_titler`` is the *immediate* heuristic title;
|
|
85
|
+
# ``session_refiner`` is the optional off-hot-path API upgrade. When
|
|
86
|
+
# neither is given both are built lazily from the global config.
|
|
87
|
+
self._session_titler = session_titler
|
|
88
|
+
self._session_refiner_override = session_refiner
|
|
89
|
+
self._titler_built = False
|
|
90
|
+
self._session_heuristic = None
|
|
91
|
+
self._session_refiner = None
|
|
92
|
+
# Optional off-hot-path activity/step-title API refiner. ``None`` (the
|
|
93
|
+
# default) means the packaged ONNX titles are final. Injected directly in
|
|
94
|
+
# tests; otherwise built lazily from ``title.activity_titling`` on first
|
|
95
|
+
# use, so a pipeline that never refines a span pays nothing.
|
|
96
|
+
self._activity_refiner_override = activity_refiner
|
|
97
|
+
self._activity_titler_built = False
|
|
98
|
+
self._activity_refiner = None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def model(self):
|
|
102
|
+
if self._model is None:
|
|
103
|
+
from .inference import TitleModel
|
|
104
|
+
|
|
105
|
+
self._model = TitleModel.load(self._model_dir, threads=1)
|
|
106
|
+
return self._model
|
|
107
|
+
|
|
108
|
+
def _ensure_session_titler(self) -> None:
|
|
109
|
+
if self._titler_built:
|
|
110
|
+
return
|
|
111
|
+
if self._session_titler is not None or self._session_refiner_override is not None:
|
|
112
|
+
heuristic = self._session_titler
|
|
113
|
+
if heuristic is None:
|
|
114
|
+
from .naming import build_session_titler_split
|
|
115
|
+
|
|
116
|
+
heuristic = build_session_titler_split().heuristic
|
|
117
|
+
self._session_heuristic = heuristic
|
|
118
|
+
self._session_refiner = self._session_refiner_override
|
|
119
|
+
else:
|
|
120
|
+
from .naming import build_session_titler_split
|
|
121
|
+
|
|
122
|
+
split = build_session_titler_split()
|
|
123
|
+
self._session_heuristic = split.heuristic
|
|
124
|
+
self._session_refiner = split.api_refiner
|
|
125
|
+
self._titler_built = True
|
|
126
|
+
|
|
127
|
+
def request_title(self, text: str) -> str:
|
|
128
|
+
"""Immediate, non-blocking session title (the heuristic floor).
|
|
129
|
+
|
|
130
|
+
Returns the free extractive title over the user's own words the instant
|
|
131
|
+
the opening request arrives; it never touches the network, so live event
|
|
132
|
+
emission is never blocked on a title. When ``strategy=api`` is configured
|
|
133
|
+
the abstractive upgrade is served separately by :meth:`refine_title`, run
|
|
134
|
+
by the pipeline off the hot path. The titler is built lazily from the
|
|
135
|
+
global config on first use, so a pipeline that never titles a session
|
|
136
|
+
pays nothing.
|
|
137
|
+
"""
|
|
138
|
+
if not text or not text.strip():
|
|
139
|
+
return ""
|
|
140
|
+
self._ensure_session_titler()
|
|
141
|
+
return self._session_heuristic(text)
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def has_session_refiner(self) -> bool:
|
|
145
|
+
"""Whether an off-hot-path API session-title refiner is configured."""
|
|
146
|
+
self._ensure_session_titler()
|
|
147
|
+
return self._session_refiner is not None
|
|
148
|
+
|
|
149
|
+
def refine_title(self, text: str) -> str:
|
|
150
|
+
"""Abstractive session-title upgrade for the opt-in API tier.
|
|
151
|
+
|
|
152
|
+
Returns the API-refined title, or ``""`` when no refiner is configured or
|
|
153
|
+
the API call fails/times out (the caller then keeps the heuristic). This
|
|
154
|
+
blocks on the network and MUST be run off the hot path (the pipeline runs
|
|
155
|
+
it in a worker thread and emits the result as a later ``TitleUpdate``).
|
|
156
|
+
"""
|
|
157
|
+
if not text or not text.strip():
|
|
158
|
+
return ""
|
|
159
|
+
self._ensure_session_titler()
|
|
160
|
+
if self._session_refiner is None:
|
|
161
|
+
return ""
|
|
162
|
+
return self._session_refiner(text)
|
|
163
|
+
|
|
164
|
+
def _ensure_activity_titler(self) -> None:
|
|
165
|
+
if self._activity_titler_built:
|
|
166
|
+
return
|
|
167
|
+
if self._activity_refiner_override is not None:
|
|
168
|
+
self._activity_refiner = self._activity_refiner_override
|
|
169
|
+
else:
|
|
170
|
+
from .naming import build_activity_refiner
|
|
171
|
+
|
|
172
|
+
self._activity_refiner = build_activity_refiner()
|
|
173
|
+
self._activity_titler_built = True
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def has_activity_refiner(self) -> bool:
|
|
177
|
+
"""Whether an off-hot-path API activity/step-title refiner is configured."""
|
|
178
|
+
self._ensure_activity_titler()
|
|
179
|
+
return self._activity_refiner is not None
|
|
180
|
+
|
|
181
|
+
def refine_activity(self, closed: "_ClosedActivity") -> list["TitleRefinement"]:
|
|
182
|
+
"""Abstractive activity/step-title upgrades for the opt-in API tier.
|
|
183
|
+
|
|
184
|
+
Distils the just-closed activity and each of its steps, asks the
|
|
185
|
+
configured API refiner for better titles in **one** call, and returns a
|
|
186
|
+
per-segment :class:`TitleRefinement` for the activity and every step the
|
|
187
|
+
API usefully covered. Returns ``[]`` when no refiner is configured or the
|
|
188
|
+
API returns nothing usable (missing key, provider error, timeout, or an
|
|
189
|
+
unparseable reply), so the packaged-model titles already emitted stand.
|
|
190
|
+
|
|
191
|
+
This blocks on the network and MUST run off the hot path (the pipeline
|
|
192
|
+
runs it in a worker thread and emits each result as a later
|
|
193
|
+
``TitleUpdate`` on the same segment id). Step titles are kept distinct
|
|
194
|
+
from the (effective) activity title and one another via
|
|
195
|
+
:func:`traceforge.title.hygiene.norm_key`; any API step title that
|
|
196
|
+
collides is dropped so that step keeps its distinct packaged-model title.
|
|
197
|
+
"""
|
|
198
|
+
self._ensure_activity_titler()
|
|
199
|
+
if self._activity_refiner is None:
|
|
200
|
+
return []
|
|
201
|
+
from .naming import ActivitySpan
|
|
202
|
+
|
|
203
|
+
step_contexts = [distilled_context(rows) for _sid, rows in closed.steps]
|
|
204
|
+
activity_rows = [r for _sid, rows in closed.steps for r in rows]
|
|
205
|
+
activity_context = distilled_context(activity_rows)
|
|
206
|
+
# Nothing extractable anywhere -> no useful API call (parity with the
|
|
207
|
+
# packaged path, which emits no updates for a signal-less span).
|
|
208
|
+
if activity_context == "(no signal)" and all(c == "(no signal)" for c in step_contexts):
|
|
209
|
+
return []
|
|
210
|
+
titles = self._activity_refiner(ActivitySpan(activity_context, step_contexts))
|
|
211
|
+
|
|
212
|
+
refinements: list["TitleRefinement"] = []
|
|
213
|
+
used: set = set()
|
|
214
|
+
if titles.activity:
|
|
215
|
+
refinements.append(TitleRefinement(closed.activity_id, "activity", titles.activity))
|
|
216
|
+
effective_activity = titles.activity or closed.activity_title
|
|
217
|
+
if effective_activity:
|
|
218
|
+
used.add(norm_key(effective_activity))
|
|
219
|
+
for (step_id, _rows), step_title in zip(closed.steps, titles.steps):
|
|
220
|
+
if not step_title:
|
|
221
|
+
continue
|
|
222
|
+
key = norm_key(step_title)
|
|
223
|
+
if not key or key in used:
|
|
224
|
+
continue # collides with the activity/a sibling -> keep packaged title
|
|
225
|
+
used.add(key)
|
|
226
|
+
refinements.append(TitleRefinement(step_id, "step", step_title, closed.activity_id))
|
|
227
|
+
return refinements
|
|
228
|
+
|
|
229
|
+
def _title(self, rows: list[dict]) -> str:
|
|
230
|
+
ctx = distilled_context(rows)
|
|
231
|
+
if ctx == "(no signal)":
|
|
232
|
+
return ""
|
|
233
|
+
return best_of(self.model.candidates(ctx))
|
|
234
|
+
|
|
235
|
+
def _title_distinct(self, rows: list[dict], used: set) -> str:
|
|
236
|
+
ctx = distilled_context(rows)
|
|
237
|
+
if ctx == "(no signal)":
|
|
238
|
+
return ""
|
|
239
|
+
return pick_distinct(used, self.model.candidates(ctx))
|
|
240
|
+
|
|
241
|
+
def new_stream(self, session_id: str, source: str = "") -> "SessionTitleStream":
|
|
242
|
+
"""Open a live per-session titling stream."""
|
|
243
|
+
|
|
244
|
+
return SessionTitleStream(self, session_id, source)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass(frozen=True)
|
|
248
|
+
class TitleRefinement:
|
|
249
|
+
"""One off-hot-path title upgrade for a closed activity or one of its steps.
|
|
250
|
+
|
|
251
|
+
Produced by :meth:`TitleInferencer.refine_activity` and turned into a later
|
|
252
|
+
append-only :class:`~traceforge.types.TitleUpdate` (same ``segment_id`` /
|
|
253
|
+
``kind``) by the pipeline, superseding the packaged-model title.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
segment_id: str
|
|
257
|
+
kind: str # "activity" | "step"
|
|
258
|
+
title: str
|
|
259
|
+
parent_id: str | None = None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass
|
|
263
|
+
class _ClosedActivity:
|
|
264
|
+
"""A just-closed activity queued for off-hot-path API title refinement.
|
|
265
|
+
|
|
266
|
+
Carries the segment ids and the raw feature rows (already built on the hot
|
|
267
|
+
path) so the worker thread can distil + call the API without touching the
|
|
268
|
+
live stream. ``activity_title`` is the packaged-model activity title (or
|
|
269
|
+
``None``); it only seeds step-title distinctness when the API leaves the
|
|
270
|
+
activity title unchanged.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
activity_id: str
|
|
274
|
+
activity_title: str | None
|
|
275
|
+
steps: list[tuple[str, list[dict]]] = field(default_factory=list) # (step_id, rows), in order
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class _Step:
|
|
279
|
+
__slots__ = ("step_id", "rows")
|
|
280
|
+
|
|
281
|
+
def __init__(self, step_id: str) -> None:
|
|
282
|
+
self.step_id = step_id
|
|
283
|
+
self.rows: list[dict] = []
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class SessionTitleStream:
|
|
287
|
+
"""Stamps live segment ids, titles the session, and titles each activity.
|
|
288
|
+
|
|
289
|
+
Feed events (already boundary-stamped) in arrival order via :meth:`push`;
|
|
290
|
+
each call returns ``(event, updates)`` where ``event`` is the same event now
|
|
291
|
+
carrying its ``activity_id``/``step_id`` (ready to emit immediately) and
|
|
292
|
+
``updates`` is the list of :class:`TitleUpdate` records for the activity that
|
|
293
|
+
this event just closed (empty while the current activity is still open). The
|
|
294
|
+
first substantive user message also yields a ``kind="session"`` update --
|
|
295
|
+
the session label, emitted live off its opening request. Call :meth:`flush`
|
|
296
|
+
at session end to title the final open activity.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
def __init__(self, inferencer: "TitleInferencer", session_id: str, source: str) -> None:
|
|
300
|
+
self._inf = inferencer
|
|
301
|
+
self._session_id = session_id
|
|
302
|
+
self._source = source
|
|
303
|
+
self._seq = 0
|
|
304
|
+
self._activity_id: str | None = None
|
|
305
|
+
self._steps: list[_Step] = [] # steps of the currently-open activity
|
|
306
|
+
self._session_titled = False # set-once; the session label is its opening intent
|
|
307
|
+
# Text queued for off-hot-path API session-title refinement, set when the
|
|
308
|
+
# heuristic title is emitted and an API refiner is configured. The
|
|
309
|
+
# pipeline pops it via :meth:`take_session_refinement` and refines in a
|
|
310
|
+
# worker thread, so the network never blocks live emission.
|
|
311
|
+
self._pending_refine_text: str | None = None
|
|
312
|
+
# Closed activities queued for off-hot-path API activity/step-title
|
|
313
|
+
# refinement, appended by :meth:`_close_activity` when an activity refiner
|
|
314
|
+
# is configured. The pipeline pops them via
|
|
315
|
+
# :meth:`take_activity_refinements` and refines each in a worker thread,
|
|
316
|
+
# emitting the upgraded titles as later append-only ``TitleUpdate``s.
|
|
317
|
+
self._pending_activity_refinements: list[_ClosedActivity] = []
|
|
318
|
+
|
|
319
|
+
def push(self, event: SessionEvent) -> tuple[SessionEvent, list[TitleUpdate]]:
|
|
320
|
+
"""Ingest one event; stamp its segment ids and emit it now, returning
|
|
321
|
+
any TitleUpdates for the activity it just closed (plus, on the first
|
|
322
|
+
substantive user message, the session-level title)."""
|
|
323
|
+
|
|
324
|
+
row = event_to_feature_row(event, self._seq)
|
|
325
|
+
self._seq += 1
|
|
326
|
+
boundary = event.metadata.boundary if event.metadata is not None else None
|
|
327
|
+
|
|
328
|
+
updates: list[TitleUpdate] = []
|
|
329
|
+
opens_activity = not self._steps or boundary == _ACTIVITY
|
|
330
|
+
if self._steps and boundary == _ACTIVITY:
|
|
331
|
+
updates = self._close_activity()
|
|
332
|
+
|
|
333
|
+
if opens_activity:
|
|
334
|
+
self._activity_id = event.id
|
|
335
|
+
self._steps = [_Step(event.id)]
|
|
336
|
+
elif boundary == _STEP:
|
|
337
|
+
self._steps.append(_Step(event.id))
|
|
338
|
+
|
|
339
|
+
step = self._steps[-1]
|
|
340
|
+
step.rows.append(row)
|
|
341
|
+
stamped = self._stamp(event, self._activity_id, step.step_id)
|
|
342
|
+
return stamped, self._maybe_session_title(event, row) + updates
|
|
343
|
+
|
|
344
|
+
def _maybe_session_title(self, event: SessionEvent, row: dict) -> list[TitleUpdate]:
|
|
345
|
+
"""Title the session from its first *substantive* user message.
|
|
346
|
+
|
|
347
|
+
Substance (R1) reuses the shipped narration hygiene with no new
|
|
348
|
+
threshold: a message is substantive iff it yields >=1 sentence under
|
|
349
|
+
:func:`traceforge.title.context.narration` -- greetings / acks collapse to
|
|
350
|
+
zero, real requests keep one. The **heuristic** title is emitted live and
|
|
351
|
+
non-blocking the instant that message arrives, keyed by
|
|
352
|
+
``segment_id == session_id``. When an API refiner is configured the raw
|
|
353
|
+
request text is queued (see :meth:`take_session_refinement`) so the
|
|
354
|
+
pipeline can upgrade the title off the hot path -- the network never
|
|
355
|
+
blocks live emission. The set-once flag means a mid-session
|
|
356
|
+
SESSION_ENDED/PAUSED (which can recur on resume) never re-triggers or
|
|
357
|
+
tears down the session title; a contentless session simply gets none.
|
|
358
|
+
"""
|
|
359
|
+
if self._session_titled or event.kind != EventKind.MESSAGE_USER:
|
|
360
|
+
return []
|
|
361
|
+
if not narration([row]):
|
|
362
|
+
return []
|
|
363
|
+
text = payload_text(row)
|
|
364
|
+
title = self._inf.request_title(text)
|
|
365
|
+
if not title:
|
|
366
|
+
return []
|
|
367
|
+
self._session_titled = True
|
|
368
|
+
if self._inf.has_session_refiner:
|
|
369
|
+
self._pending_refine_text = text
|
|
370
|
+
return [
|
|
371
|
+
TitleUpdate(
|
|
372
|
+
session_id=self._session_id,
|
|
373
|
+
segment_id=self._session_id,
|
|
374
|
+
kind="session",
|
|
375
|
+
title=title,
|
|
376
|
+
)
|
|
377
|
+
]
|
|
378
|
+
|
|
379
|
+
def take_session_refinement(self) -> str | None:
|
|
380
|
+
"""Pop the request text queued for off-hot-path API title refinement.
|
|
381
|
+
|
|
382
|
+
Returns the text once (then ``None``), or ``None`` if no refinement is
|
|
383
|
+
pending. The pipeline calls this right after :meth:`push` and, when a
|
|
384
|
+
text is returned, refines the session title in a worker thread and emits
|
|
385
|
+
the result as a later session :class:`TitleUpdate`.
|
|
386
|
+
"""
|
|
387
|
+
text = self._pending_refine_text
|
|
388
|
+
self._pending_refine_text = None
|
|
389
|
+
return text
|
|
390
|
+
|
|
391
|
+
def flush(self) -> list[TitleUpdate]:
|
|
392
|
+
"""Title the final open activity (if any) and return its updates."""
|
|
393
|
+
|
|
394
|
+
if not self._steps:
|
|
395
|
+
return []
|
|
396
|
+
return self._close_activity()
|
|
397
|
+
|
|
398
|
+
def _close_activity(self) -> list[TitleUpdate]:
|
|
399
|
+
"""Title the just-closed activity + its steps as append-only updates.
|
|
400
|
+
|
|
401
|
+
The packaged-model titles are computed and returned immediately (the
|
|
402
|
+
pipeline emits them the instant the activity closes). When an API
|
|
403
|
+
activity refiner is configured *and* this activity produced at least one
|
|
404
|
+
packaged title, the closed activity (ids + raw rows) is also queued for
|
|
405
|
+
off-hot-path API refinement via :meth:`take_activity_refinements`; the
|
|
406
|
+
pipeline upgrades the titles in a worker thread and emits them as later
|
|
407
|
+
append-only updates. Queueing is skipped entirely by default
|
|
408
|
+
(``strategy=model``), so the packaged path is unchanged.
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
steps = self._steps
|
|
412
|
+
activity_id = self._activity_id
|
|
413
|
+
self._steps = []
|
|
414
|
+
self._activity_id = None
|
|
415
|
+
|
|
416
|
+
activity_rows = [r for s in steps for r in s.rows]
|
|
417
|
+
activity_title = self._inf._title(activity_rows) or None
|
|
418
|
+
|
|
419
|
+
updates: list[TitleUpdate] = []
|
|
420
|
+
used: set = set()
|
|
421
|
+
if activity_title:
|
|
422
|
+
used.add(norm_key(activity_title))
|
|
423
|
+
updates.append(
|
|
424
|
+
TitleUpdate(
|
|
425
|
+
session_id=self._session_id,
|
|
426
|
+
segment_id=activity_id,
|
|
427
|
+
kind="activity",
|
|
428
|
+
title=activity_title,
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
for step in steps:
|
|
433
|
+
step_title = self._inf._title_distinct(step.rows, used) or None
|
|
434
|
+
if step_title:
|
|
435
|
+
updates.append(
|
|
436
|
+
TitleUpdate(
|
|
437
|
+
session_id=self._session_id,
|
|
438
|
+
segment_id=step.step_id,
|
|
439
|
+
kind="step",
|
|
440
|
+
title=step_title,
|
|
441
|
+
parent_id=activity_id,
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
# Queue this activity for an off-hot-path API upgrade only when a refiner
|
|
446
|
+
# is configured and there is at least one packaged title to upgrade.
|
|
447
|
+
if updates and self._inf.has_activity_refiner:
|
|
448
|
+
self._pending_activity_refinements.append(
|
|
449
|
+
_ClosedActivity(
|
|
450
|
+
activity_id=activity_id,
|
|
451
|
+
activity_title=activity_title,
|
|
452
|
+
steps=[(s.step_id, s.rows) for s in steps],
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
return updates
|
|
456
|
+
|
|
457
|
+
def take_activity_refinements(self) -> list["_ClosedActivity"]:
|
|
458
|
+
"""Pop the closed activities queued for off-hot-path API refinement.
|
|
459
|
+
|
|
460
|
+
Returns them once (then empties the queue). The pipeline calls this right
|
|
461
|
+
after :meth:`push` / :meth:`flush` and, for each returned activity,
|
|
462
|
+
refines its titles in a worker thread (:meth:`TitleInferencer.refine_activity`)
|
|
463
|
+
and emits the upgrades as later append-only :class:`TitleUpdate`s.
|
|
464
|
+
"""
|
|
465
|
+
out = self._pending_activity_refinements
|
|
466
|
+
self._pending_activity_refinements = []
|
|
467
|
+
return out
|
|
468
|
+
|
|
469
|
+
@staticmethod
|
|
470
|
+
def _stamp(event: SessionEvent, activity_id: str | None, step_id: str | None) -> SessionEvent:
|
|
471
|
+
if event.metadata is None:
|
|
472
|
+
return event
|
|
473
|
+
new_md = event.metadata.model_copy(update={"activity_id": activity_id, "step_id": step_id})
|
|
474
|
+
return event.model_copy(update={"metadata": new_md})
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
__all__ = ["TitleInferencer", "SessionTitleStream", "TitleRefinement"]
|