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,429 @@
1
+ """Deterministic, zero-cost session-title heuristics (the default floor).
2
+
3
+ Session naming turns the first substantive user message into a session title. It
4
+ deliberately does **not** use a learned model: the distilled request head was
5
+ proven weak (~9% coherent on the honest CodePlane heldout), whereas a heuristic
6
+ over the user's *own words* is coherent by construction. These heuristics are
7
+ therefore *extractive* -- their ceiling is the phrasing already present in the
8
+ message -- so an opt-in LLM API tier (:mod:`traceforge.title.naming`) exists for
9
+ abstractive titles when a key is configured.
10
+
11
+ Four methods, dispatched by :func:`heuristic_title`:
12
+
13
+ * ``clip`` -- strip conversational preamble, take the first sentence, clip
14
+ to a word/char budget.
15
+ * ``imperative`` -- anchor on the leading action verb ("Fix login token refresh").
16
+ * ``keyphrase`` -- a vendored, zero-dependency RAKE picks the single most salient
17
+ phrase; best for long, rambling inputs where the ask is buried past the preamble.
18
+ * ``hybrid`` -- cascade (DEFAULT): lead with a salient identifier when present,
19
+ else the imperative title, else the plain clip.
20
+
21
+ Everything here is pure Python with no third-party dependency.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+
28
+ # ── Preamble the user rarely means as part of the title ──────────────────────
29
+ #: Leading conversational scaffolding, stripped iteratively from the front. Order
30
+ #: is longest-first within a pass so "i would like to" wins over "i".
31
+ _PREAMBLE = (
32
+ "i would like to",
33
+ "i'd like you to",
34
+ "i'd like to",
35
+ "i am trying to",
36
+ "i'm trying to",
37
+ "i need you to",
38
+ "i want you to",
39
+ "i need to",
40
+ "i want to",
41
+ "we need to",
42
+ "we should",
43
+ "could you please",
44
+ "can you please",
45
+ "would you please",
46
+ "could you",
47
+ "can you",
48
+ "would you",
49
+ "will you",
50
+ "please help me",
51
+ "help me",
52
+ "let's",
53
+ "lets",
54
+ "let us",
55
+ "i think we should",
56
+ "i think",
57
+ "i need",
58
+ "i want",
59
+ "hey there",
60
+ "hey",
61
+ "hi there",
62
+ "hello",
63
+ "okay",
64
+ "ok",
65
+ "so",
66
+ "well",
67
+ "just",
68
+ "now",
69
+ "then",
70
+ "also",
71
+ "actually",
72
+ "basically",
73
+ "quick question",
74
+ "quick one",
75
+ "please",
76
+ "pls",
77
+ )
78
+
79
+ #: Common developer imperatives; the leading one anchors an "action" title.
80
+ _IMPERATIVES = frozenset(
81
+ {
82
+ "add",
83
+ "fix",
84
+ "refactor",
85
+ "debug",
86
+ "implement",
87
+ "update",
88
+ "remove",
89
+ "delete",
90
+ "create",
91
+ "write",
92
+ "build",
93
+ "run",
94
+ "test",
95
+ "investigate",
96
+ "change",
97
+ "rename",
98
+ "move",
99
+ "migrate",
100
+ "upgrade",
101
+ "downgrade",
102
+ "install",
103
+ "configure",
104
+ "setup",
105
+ "set",
106
+ "enable",
107
+ "disable",
108
+ "optimize",
109
+ "improve",
110
+ "handle",
111
+ "support",
112
+ "replace",
113
+ "extract",
114
+ "split",
115
+ "merge",
116
+ "revert",
117
+ "review",
118
+ "check",
119
+ "verify",
120
+ "generate",
121
+ "parse",
122
+ "render",
123
+ "deploy",
124
+ "document",
125
+ "clean",
126
+ "wire",
127
+ "integrate",
128
+ "expose",
129
+ "cache",
130
+ "log",
131
+ "validate",
132
+ "sanitize",
133
+ "format",
134
+ "lint",
135
+ "benchmark",
136
+ "profile",
137
+ "mock",
138
+ "stub",
139
+ "patch",
140
+ "port",
141
+ "vendor",
142
+ "bump",
143
+ "pin",
144
+ "rollback",
145
+ "resolve",
146
+ "diagnose",
147
+ "reproduce",
148
+ "trace",
149
+ "audit",
150
+ "refine",
151
+ "tune",
152
+ "tweak",
153
+ "drop",
154
+ "ship",
155
+ "make",
156
+ "get",
157
+ "convert",
158
+ "connect",
159
+ "download",
160
+ "upload",
161
+ "fetch",
162
+ "load",
163
+ "save",
164
+ "store",
165
+ "print",
166
+ "show",
167
+ "list",
168
+ "find",
169
+ "search",
170
+ "count",
171
+ "compute",
172
+ "calculate",
173
+ "sort",
174
+ "filter",
175
+ "map",
176
+ "reduce",
177
+ "group",
178
+ "join",
179
+ "index",
180
+ "hash",
181
+ "encode",
182
+ "decode",
183
+ "encrypt",
184
+ "decrypt",
185
+ "compress",
186
+ "unpack",
187
+ "extend",
188
+ "shrink",
189
+ "scale",
190
+ "restart",
191
+ "stop",
192
+ "start",
193
+ "kill",
194
+ "spawn",
195
+ "throttle",
196
+ "retry",
197
+ "batch",
198
+ "stream",
199
+ }
200
+ )
201
+
202
+ #: A token carrying an identifier signal: a path/dotted-extension, snake_case,
203
+ #: kebab or internal capital, or a ``backticked`` code span.
204
+ _IDENT_RE = re.compile(
205
+ r"`[^`]+`" # `backticked code`
206
+ r"|[\w./\\-]*[\w]\.[A-Za-z]{1,5}\b" # file.ext, pkg.mod.fn
207
+ r"|[A-Za-z]+(?:[_/\\][\w./\\-]+)+" # snake_case / a/path / kebab-ish
208
+ r"|[a-z]+[A-Z]\w*" # camelCase / internalCap
209
+ )
210
+
211
+ _SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+|\n+")
212
+ _WORD = re.compile(r"\S+")
213
+ _LEADING_JUNK = re.compile(r"^[\s,;:.\-–—*>#`\"'()\[\]]+")
214
+ _TRAILING_JUNK = re.compile(r"[\s,;:.\-–—>]+$")
215
+
216
+ #: Weak trailing tokens that make a clipped title look cut off mid-thought.
217
+ _TAIL_STOP = frozenset(
218
+ """the a an to for of and or in on with from into by at as so because whenever
219
+ when while that this but if then than is are was were my your his her its their
220
+ our we they it he she about over under up down out off""".split()
221
+ )
222
+
223
+
224
+ def _trim_tail(words: list[str]) -> list[str]:
225
+ """Drop trailing function words so a clip doesn't end mid-thought."""
226
+ while len(words) > 2 and re.sub(r"[^\w']", "", words[-1]).lower() in _TAIL_STOP:
227
+ words = words[:-1]
228
+ return words
229
+
230
+
231
+ def _first_sentence(text: str) -> str:
232
+ for part in _SENTENCE_SPLIT.split(text.strip()):
233
+ s = part.strip()
234
+ if s:
235
+ return s
236
+ return text.strip()
237
+
238
+
239
+ def _strip_preamble(text: str) -> str:
240
+ """Iteratively drop leading conversational scaffolding (case-insensitive)."""
241
+ s = _LEADING_JUNK.sub("", text.strip())
242
+ changed = True
243
+ while changed and s:
244
+ changed = False
245
+ low = s.lower()
246
+ for p in _PREAMBLE:
247
+ # Match the phrase only at a word boundary at the very start.
248
+ if low == p or low.startswith(p + " ") or low.startswith(p + ","):
249
+ s = _LEADING_JUNK.sub("", s[len(p) :])
250
+ changed = True
251
+ break
252
+ return s
253
+
254
+
255
+ def _cap_first(s: str) -> str:
256
+ """Capitalize the first character without touching the rest, and leave a
257
+ leading identifier (``auth.py``, ``getUser``, ``a/b/c``) untouched."""
258
+ if not s:
259
+ return s
260
+ first = s.split(maxsplit=1)[0]
261
+ if any(c in first for c in "._/\\") or re.search(r"\w[A-Z]", first):
262
+ return s
263
+ return s[:1].upper() + s[1:]
264
+
265
+
266
+ def _clip_words(text: str, max_words: int, max_chars: int) -> str:
267
+ words = _WORD.findall(text)
268
+ clipped = len(words) > max_words
269
+ if clipped:
270
+ words = _trim_tail(words[:max_words])
271
+ out = " ".join(words)
272
+ if len(out) > max_chars:
273
+ # Trim to the last whole word within the char budget.
274
+ cut = out[:max_chars]
275
+ if " " in cut:
276
+ cut = cut[: cut.rfind(" ")]
277
+ out = " ".join(_trim_tail(cut.split()))
278
+ return _TRAILING_JUNK.sub("", out)
279
+
280
+
281
+ def clip(text: str, max_words: int = 8, max_chars: int = 60) -> str:
282
+ """Strip preamble, take the first sentence, clip to the word/char budget."""
283
+ core = _strip_preamble(_first_sentence(text))
284
+ core = _LEADING_JUNK.sub("", core)
285
+ return _cap_first(_clip_words(core, max_words, max_chars))
286
+
287
+
288
+ def imperative(text: str, max_words: int = 8, max_chars: int = 60) -> str | None:
289
+ """Anchor the title on the first leading action verb, or ``None`` if the
290
+ (preamble-stripped) message does not start with a recognized imperative
291
+ within its first few words."""
292
+ core = _strip_preamble(_first_sentence(text))
293
+ words = _WORD.findall(core)
294
+ for i, w in enumerate(words[:4]):
295
+ if re.sub(r"[^\w]", "", w).lower() in _IMPERATIVES:
296
+ phrase = " ".join(words[i:])
297
+ return _cap_first(_clip_words(phrase, max_words, max_chars))
298
+ return None
299
+
300
+
301
+ def _salient_identifier(text: str) -> str | None:
302
+ """The first identifier-shaped token in the message's opening, or ``None``."""
303
+ head = " ".join(_WORD.findall(text)[:16])
304
+ m = _IDENT_RE.search(head)
305
+ if not m:
306
+ return None
307
+ tok = m.group(0).strip("`").strip()
308
+ # Ignore trivial/absurd matches (bare word with a trailing period sentence).
309
+ if len(tok) < 3 or " " in tok:
310
+ return None
311
+ return tok
312
+
313
+
314
+ def hybrid(text: str, max_words: int = 8, max_chars: int = 60) -> str:
315
+ """Cascade: salient identifier lead -> imperative -> plain clip."""
316
+ base = imperative(text, max_words, max_chars) or clip(text, max_words, max_chars)
317
+ ident = _salient_identifier(text)
318
+ if ident and ident.lower() not in base.lower():
319
+ combined = f"{ident}: {base}"
320
+ if len(combined) > max_chars:
321
+ # Keep the identifier; trim the gist to fit.
322
+ room = max_chars - len(ident) - 2
323
+ gist = _TRAILING_JUNK.sub("", base[: max(room, 0)])
324
+ combined = f"{ident}: {gist}".rstrip(": ").strip()
325
+ return _cap_first(combined)
326
+ return base
327
+
328
+
329
+ # ── Vendored zero-dependency RAKE (keyphrase extraction) ─────────────────────
330
+ _RAKE_STOP = frozenset(
331
+ """a an the and or but if then else for to of in on at by with from into over
332
+ under again further is are was were be been being do does did doing have has
333
+ had having i you he she it we they me him her us them my your his its our their
334
+ this that these those as it's i'm can could would should will shall may might
335
+ must not no nor so than too very just about above below up down out off can't
336
+ won't don't please help need want make get set use using used which who whom
337
+ what when where why how all any both each few more most other some such only own
338
+ same me my""".split()
339
+ )
340
+ _RAKE_TOKEN = re.compile(r"[A-Za-z0-9_./\\-]+")
341
+
342
+
343
+ def keyphrase(text: str, max_words: int = 8, max_chars: int = 60) -> str:
344
+ """RAKE: split on stopwords/punctuation into candidate phrases, score words by
345
+ degree/frequency, and return the single highest-scoring phrase (clipped)."""
346
+ core = _strip_preamble(text)
347
+ tokens = _RAKE_TOKEN.findall(core)
348
+ if not tokens:
349
+ return clip(text, max_words, max_chars)
350
+
351
+ # Build candidate phrases: runs of non-stopword tokens.
352
+ phrases: list[list[str]] = []
353
+ cur: list[str] = []
354
+ for tok in tokens:
355
+ if tok.lower() in _RAKE_STOP:
356
+ if cur:
357
+ phrases.append(cur)
358
+ cur = []
359
+ else:
360
+ cur.append(tok)
361
+ if cur:
362
+ phrases.append(cur)
363
+ if not phrases:
364
+ return clip(text, max_words, max_chars)
365
+
366
+ # Word scores: degree (co-occurrence incl. self) / frequency.
367
+ freq: dict[str, int] = {}
368
+ degree: dict[str, int] = {}
369
+ for ph in phrases:
370
+ d = len(ph) - 1
371
+ for w in ph:
372
+ wl = w.lower()
373
+ freq[wl] = freq.get(wl, 0) + 1
374
+ degree[wl] = degree.get(wl, 0) + d
375
+ score = {w: (degree[w] + freq[w]) / freq[w] for w in freq}
376
+
377
+ def phrase_score(ph: list[str]) -> float:
378
+ return sum(score[w.lower()] for w in ph)
379
+
380
+ # Prefer the highest score; break ties toward earlier, longer phrases.
381
+ best = max(
382
+ range(len(phrases)),
383
+ key=lambda i: (phrase_score(phrases[i]), len(phrases[i]), -i),
384
+ )
385
+ out = " ".join(phrases[best])
386
+ return _cap_first(_clip_words(out, max_words, max_chars))
387
+
388
+
389
+ _METHODS = {
390
+ "clip": clip,
391
+ "imperative": lambda t, mw, mc: imperative(t, mw, mc) or clip(t, mw, mc),
392
+ "keyphrase": keyphrase,
393
+ "hybrid": hybrid,
394
+ }
395
+
396
+
397
+ def _enforce_char_cap(out: str, max_chars: int) -> str:
398
+ """Guarantee ``len(out) <= max_chars`` at a word/separator boundary.
399
+
400
+ A final safety net for every method: ``hybrid`` can lead with a salient
401
+ identifier longer than the whole budget, which would otherwise return a
402
+ title over ``max_chars``. Only ever shortens, and prefers a whole-word (then
403
+ path/dotted-separator) boundary so it does not gratuitously split a token.
404
+ """
405
+ if len(out) <= max_chars:
406
+ return out
407
+ cut = out[:max_chars]
408
+ if " " in cut.strip():
409
+ return _TRAILING_JUNK.sub("", cut[: cut.rfind(" ")].rstrip())
410
+ for sep in ("/", ".", "_", "-"):
411
+ if sep in cut[1:]:
412
+ return cut[: cut.rindex(sep, 1)]
413
+ return cut
414
+
415
+
416
+ def heuristic_title(
417
+ text: str,
418
+ method: str = "hybrid",
419
+ max_words: int = 8,
420
+ max_chars: int = 60,
421
+ ) -> str:
422
+ """Dispatch to the named heuristic; empty input yields an empty title."""
423
+ if not text or not text.strip():
424
+ return ""
425
+ fn = _METHODS.get(method, hybrid)
426
+ return _enforce_char_cap(fn(text, max_words, max_chars), max_chars)
427
+
428
+
429
+ __all__ = ["heuristic_title", "clip", "imperative", "keyphrase", "hybrid"]
@@ -0,0 +1,90 @@
1
+ """Decode/render hygiene for generated titles (pure stdlib, no ML deps).
2
+
3
+ The tiny seq2seq titler is clean in-distribution but degenerates in the
4
+ zero-shot regime (unseen trace families, intent-less context): verbatim
5
+ repetition, beams that end mid-subword ("...restarte"), and child titles that
6
+ duplicate their parent. These are decode/render defects, not data defects, so
7
+ they are fixed here post-generation rather than by retraining.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ _WS = re.compile(r"\s+")
15
+ _WORD = re.compile(r"[A-Za-z]+")
16
+ _STOP = {"the", "a", "an", "to", "for", "of", "and", "in", "on", "with", "from"}
17
+
18
+
19
+ def norm_key(s: str) -> frozenset[str]:
20
+ """Plural/stopword-insensitive token-set key for parent/child/sibling de-dup."""
21
+ toks = re.findall(r"[a-z0-9]+", s.lower())
22
+ toks = [t[:-1] if t.endswith("s") and len(t) > 3 else t for t in toks]
23
+ return frozenset(t for t in toks if t not in _STOP)
24
+
25
+
26
+ def collapse_repeats(s: str) -> str:
27
+ """Remove adjacent duplicate tokens, mirrored 'X and X', and whole doubling."""
28
+ w = s.split()
29
+ out: list[str] = []
30
+ for t in w:
31
+ if out and out[-1].lower() == t.lower():
32
+ continue
33
+ out.append(t)
34
+ w = out
35
+ low = [x.lower() for x in w]
36
+ for conn in ("and", "then", "to", "&"):
37
+ if conn in low:
38
+ i = low.index(conn)
39
+ if low[:i] and low[:i] == low[i + 1 :]:
40
+ w = w[:i]
41
+ low = [x.lower() for x in w]
42
+ n = len(w)
43
+ if n >= 2 and n % 2 == 0 and low[: n // 2] == low[n // 2 :]:
44
+ w = w[: n // 2]
45
+ return " ".join(w)
46
+
47
+
48
+ def _degenerate(s: str) -> bool:
49
+ """Beam looks truncated/garbled: a long vowelless alphabetic run."""
50
+ for t in _WORD.findall(s):
51
+ low = t.lower()
52
+ if len(low) >= 5 and not re.search(r"[aeiouy]", low):
53
+ return True
54
+ return False
55
+
56
+
57
+ def clean_title(s: str) -> str:
58
+ s = _WS.sub(" ", str(s)).strip().strip("-:,. ")
59
+ s = collapse_repeats(s)
60
+ if s:
61
+ s = s[0].upper() + s[1:]
62
+ return s
63
+
64
+
65
+ def best_of(cands: list[str]) -> str:
66
+ """Pick the first non-degenerate beam alternate, cleaned."""
67
+ cleaned = [clean_title(c) for c in cands if c and c.strip()]
68
+ for c in cleaned:
69
+ if not _degenerate(c):
70
+ return c
71
+ return cleaned[0] if cleaned else ""
72
+
73
+
74
+ def pick_distinct(used: set[frozenset[str]], cands: list[str]) -> str:
75
+ """First non-degenerate alternate whose token-set is new this session.
76
+
77
+ Used to keep a child/step title distinct from its parent activity title and
78
+ its siblings; falls back to ``best_of`` if all alternates collide.
79
+ """
80
+ cleaned = [clean_title(c) for c in cands if c and c.strip()]
81
+ for c in cleaned:
82
+ if _degenerate(c):
83
+ continue
84
+ k = norm_key(c)
85
+ if k and k not in used:
86
+ used.add(k)
87
+ return c
88
+ chosen = best_of(cands)
89
+ used.add(norm_key(chosen))
90
+ return chosen