atif-sql 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. atif_analytics/__init__.py +9 -0
  2. atif_analytics/application/__init__.py +3 -0
  3. atif_analytics/application/analyze.py +158 -0
  4. atif_analytics/application/prompts.py +750 -0
  5. atif_analytics/application/use_cases/__init__.py +3 -0
  6. atif_analytics/application/use_cases/_shared.py +264 -0
  7. atif_analytics/application/use_cases/classify.py +430 -0
  8. atif_analytics/application/use_cases/conflicts.py +448 -0
  9. atif_analytics/application/use_cases/friction.py +634 -0
  10. atif_analytics/application/use_cases/perceived.py +451 -0
  11. atif_analytics/domain/__init__.py +3 -0
  12. atif_analytics/domain/authorship.py +166 -0
  13. atif_analytics/domain/config.py +30 -0
  14. atif_analytics/domain/costs.py +50 -0
  15. atif_analytics/domain/friction.py +117 -0
  16. atif_analytics/domain/layout.py +94 -0
  17. atif_analytics/domain/models.py +385 -0
  18. atif_analytics/domain/ports.py +100 -0
  19. atif_analytics/domain/transcript.py +359 -0
  20. atif_analytics/infrastructure/__init__.py +3 -0
  21. atif_analytics/infrastructure/corpus_reader.py +546 -0
  22. atif_analytics/infrastructure/parquet_cache.py +186 -0
  23. atif_analytics/infrastructure/settings.py +114 -0
  24. atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
  25. atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
  26. atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
  27. atif_analytics/py.typed +0 -0
  28. atif_cli/__init__.py +3 -0
  29. atif_cli/__main__.py +8 -0
  30. atif_cli/app.py +2459 -0
  31. atif_cli/converter_adapter.py +151 -0
  32. atif_cli/corpus.py +409 -0
  33. atif_cli/cron.py +311 -0
  34. atif_cli/duck_errors.py +97 -0
  35. atif_cli/embed_lake.py +101 -0
  36. atif_cli/errors.py +89 -0
  37. atif_cli/lake.py +373 -0
  38. atif_cli/lake_sessions.py +270 -0
  39. atif_cli/output.py +297 -0
  40. atif_cli/py.typed +0 -0
  41. atif_converter/__init__.py +3 -0
  42. atif_converter/application/__init__.py +3 -0
  43. atif_converter/application/convert_and_audit.py +294 -0
  44. atif_converter/application/convert_codex.py +210 -0
  45. atif_converter/domain/__init__.py +9 -0
  46. atif_converter/domain/agents.py +40 -0
  47. atif_converter/domain/atif/__init__.py +55 -0
  48. atif_converter/domain/atif/agent.py +37 -0
  49. atif_converter/domain/atif/content.py +161 -0
  50. atif_converter/domain/atif/final_metrics.py +45 -0
  51. atif_converter/domain/atif/metrics.py +49 -0
  52. atif_converter/domain/atif/observation.py +21 -0
  53. atif_converter/domain/atif/observation_result.py +47 -0
  54. atif_converter/domain/atif/step.py +144 -0
  55. atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
  56. atif_converter/domain/atif/tool_call.py +36 -0
  57. atif_converter/domain/atif/trajectory.py +211 -0
  58. atif_converter/domain/atif/trajectory_validator.py +299 -0
  59. atif_converter/domain/blobs.py +510 -0
  60. atif_converter/domain/claude_code_conversion.py +1077 -0
  61. atif_converter/domain/codex_conversion.py +1036 -0
  62. atif_converter/domain/codex_edges.py +192 -0
  63. atif_converter/domain/codex_enrichment.py +419 -0
  64. atif_converter/domain/codex_fidelity.py +124 -0
  65. atif_converter/domain/codex_nested_calls.py +165 -0
  66. atif_converter/domain/edges.py +113 -0
  67. atif_converter/domain/enrichment.py +412 -0
  68. atif_converter/domain/errors.py +60 -0
  69. atif_converter/domain/fidelity.py +171 -0
  70. atif_converter/domain/model_prices.json +8538 -0
  71. atif_converter/domain/pricing.py +790 -0
  72. atif_converter/domain/result_signals.py +774 -0
  73. atif_converter/domain/schema_version.py +90 -0
  74. atif_converter/domain/session_events.py +408 -0
  75. atif_converter/infrastructure/__init__.py +3 -0
  76. atif_converter/infrastructure/census.py +89 -0
  77. atif_converter/infrastructure/claude_code_converter.py +127 -0
  78. atif_converter/infrastructure/codex_adapter.py +132 -0
  79. atif_converter/infrastructure/codex_census.py +100 -0
  80. atif_converter/infrastructure/codex_converter.py +82 -0
  81. atif_converter/infrastructure/harbor_adapter.py +187 -0
  82. atif_converter/infrastructure/raw_records.py +426 -0
  83. atif_converter/infrastructure/source_archive.py +154 -0
  84. atif_converter/py.typed +0 -0
  85. atif_corpus/__init__.py +13 -0
  86. atif_corpus/application/__init__.py +7 -0
  87. atif_corpus/application/materialize.py +1826 -0
  88. atif_corpus/domain/__init__.py +9 -0
  89. atif_corpus/domain/agents.py +33 -0
  90. atif_corpus/domain/generation.py +34 -0
  91. atif_corpus/domain/layout.py +168 -0
  92. atif_corpus/domain/ports.py +175 -0
  93. atif_corpus/domain/session_id.py +68 -0
  94. atif_corpus/domain/sessions.py +228 -0
  95. atif_corpus/domain/slug.py +72 -0
  96. atif_corpus/domain/source_layout.py +143 -0
  97. atif_corpus/domain/watermark.py +76 -0
  98. atif_corpus/infrastructure/__init__.py +7 -0
  99. atif_corpus/infrastructure/atomic.py +266 -0
  100. atif_corpus/infrastructure/compress_artifacts.py +161 -0
  101. atif_corpus/infrastructure/fake_converter.py +174 -0
  102. atif_corpus/infrastructure/scanner.py +316 -0
  103. atif_corpus/infrastructure/settings.py +130 -0
  104. atif_corpus/infrastructure/source_archive.py +202 -0
  105. atif_corpus/py.typed +0 -0
  106. atif_duck/__init__.py +43 -0
  107. atif_duck/domain/__init__.py +32 -0
  108. atif_duck/domain/artifacts.py +66 -0
  109. atif_duck/domain/authorship.py +165 -0
  110. atif_duck/domain/catalog.py +626 -0
  111. atif_duck/domain/columnar.py +149 -0
  112. atif_duck/domain/embedding_guard.py +88 -0
  113. atif_duck/domain/examples.py +265 -0
  114. atif_duck/domain/lake.py +427 -0
  115. atif_duck/domain/lake_sessions.py +97 -0
  116. atif_duck/domain/raw_readers.py +140 -0
  117. atif_duck/domain/session_id.py +69 -0
  118. atif_duck/domain/sql_literal.py +50 -0
  119. atif_duck/infrastructure/__init__.py +34 -0
  120. atif_duck/infrastructure/analytics.py +469 -0
  121. atif_duck/infrastructure/authorship.py +139 -0
  122. atif_duck/infrastructure/columnar.py +641 -0
  123. atif_duck/infrastructure/lake.py +1383 -0
  124. atif_duck/infrastructure/lake_sessions.py +194 -0
  125. atif_duck/infrastructure/lake_settings.py +63 -0
  126. atif_duck/infrastructure/lake_steps.py +225 -0
  127. atif_duck/infrastructure/projections.py +221 -0
  128. atif_duck/infrastructure/registry.py +2194 -0
  129. atif_duck/infrastructure/stored_artifacts.py +115 -0
  130. atif_duck/py.typed +0 -0
  131. atif_embed/__init__.py +12 -0
  132. atif_embed/application/__init__.py +3 -0
  133. atif_embed/application/embed.py +293 -0
  134. atif_embed/application/prune.py +80 -0
  135. atif_embed/domain/__init__.py +3 -0
  136. atif_embed/domain/discovery.py +70 -0
  137. atif_embed/domain/embedding_guard.py +83 -0
  138. atif_embed/domain/errors.py +94 -0
  139. atif_embed/domain/ports.py +223 -0
  140. atif_embed/domain/sql_literal.py +49 -0
  141. atif_embed/domain/text_stamp.py +71 -0
  142. atif_embed/infrastructure/__init__.py +3 -0
  143. atif_embed/infrastructure/cohere_bedrock.py +433 -0
  144. atif_embed/infrastructure/corpus_text_rows.py +273 -0
  145. atif_embed/infrastructure/gemma_local.py +355 -0
  146. atif_embed/infrastructure/lake_text_rows.py +203 -0
  147. atif_embed/infrastructure/lance_store.py +440 -0
  148. atif_embed/infrastructure/providers.py +30 -0
  149. atif_embed/infrastructure/settings.py +154 -0
  150. atif_embed/py.typed +0 -0
  151. atif_models/__init__.py +14 -0
  152. atif_models/domain/__init__.py +9 -0
  153. atif_models/domain/ports.py +143 -0
  154. atif_models/domain/registry.py +151 -0
  155. atif_models/domain/schema.py +109 -0
  156. atif_models/infrastructure/__init__.py +8 -0
  157. atif_models/infrastructure/openai_bedrock.py +314 -0
  158. atif_models/infrastructure/settings.py +85 -0
  159. atif_models/py.typed +0 -0
  160. atif_sql-0.2.0.dist-info/METADATA +272 -0
  161. atif_sql-0.2.0.dist-info/RECORD +164 -0
  162. atif_sql-0.2.0.dist-info/WHEEL +4 -0
  163. atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
  164. atif_sql-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,634 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Detect user-friction signals in short user-role messages.
4
+
5
+ A three-tier pipeline over the materialized corpus:
6
+
7
+ 1. Pre-filter to HUMAN turns (:func:`~atif_analytics.domain.transcript.is_human_turn`:
8
+ main-chain user steps whose author is ``human``) below
9
+ ``settings.friction_max_chars`` (default 300). Stop hook feedback, task
10
+ notifications, retry nudges, interrupt markers and screenshot metadata
11
+ are never candidates. Before this filter they drew most of the rule-1
12
+ ``unmet_expectation`` stamps on a busy corpus (the same retry nudge or
13
+ ``[Image: original ...]`` line "repeated" within ten turns).
14
+ 2. Regex fast-path (:mod:`atif_analytics.domain.friction`) for strong
15
+ unambiguous patterns — confidence 0.9, skips the LLM.
16
+ 3. Deterministic stamp tier — three rules over steps + error tool_results:
17
+
18
+ * Rule 1 — repeated human message body within 10 human turns →
19
+ ``unmet_expectation`` @ 0.85 (re-asking means the first answer fell
20
+ short).
21
+ * Rule 2 — short imperative (≤30 chars, first token ∈ {stop, redo,
22
+ revert, rollback, undo, restart}) → ``correction`` @ 0.9.
23
+ * Rule 3 — trailing ``?`` on the user step immediately after a step
24
+ carrying an error tool_result → ``confusion`` @ 0.85. The error flag
25
+ comes from ``observation.results[].extra.tool_result_metadata.is_error``
26
+ (harbor preserves the raw flag only there).
27
+
28
+ 4. Everything else goes to the LLM per message.
29
+
30
+ Outputs one row per analysed user message:
31
+ ``{uuid, session_id, ts, text_snippet, label, rationale, source,
32
+ confidence, classified_at}``.
33
+
34
+ ``source`` ∈ {'regex','sql','llm','refused'} — the schema value is the
35
+ row's PROVENANCE tier, not the engine that computed it. ``'sql'`` marks the
36
+ three deterministic stamp rules; those rules run in Python here, over the
37
+ same data the SQL versions read. Downstream atif-duck views and macros bind
38
+ to these literal values, so the label is fixed by that contract.
39
+
40
+ Short user-message text is sent to a third-party model provider on tier 4:
41
+ those message bodies leave this machine.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import asyncio
47
+ import re
48
+ import time
49
+ from datetime import UTC, datetime
50
+ from typing import TYPE_CHECKING, Any
51
+
52
+ import polars as pl
53
+ from loguru import logger
54
+
55
+ from atif_analytics.application.prompts import USER_FRICTION_SYSTEM_PROMPT
56
+ from atif_analytics.application.use_cases._shared import (
57
+ RunBudget,
58
+ build_cache,
59
+ build_checkpoint,
60
+ build_provider,
61
+ build_retry_queue,
62
+ gather_under_budget,
63
+ pipeline_usage,
64
+ write_chunk_size,
65
+ )
66
+ from atif_analytics.domain.costs import estimate_cost_tokens, tokens_for_chars
67
+ from atif_analytics.domain.friction import regex_fast_path
68
+ from atif_analytics.domain.models import UserFrictionSignal
69
+ from atif_analytics.domain.transcript import is_human_turn
70
+ from atif_analytics.infrastructure.corpus_reader import CorpusReader, parse_ts
71
+ from atif_analytics.infrastructure.sqlite_state import checkpointer
72
+
73
+ if TYPE_CHECKING:
74
+ from datetime import datetime as _dt
75
+
76
+ from atif_analytics.domain.transcript import StepEvent
77
+ from atif_analytics.infrastructure.parquet_cache import ParquetCache
78
+ from atif_analytics.infrastructure.settings import AnalyticsSettings
79
+ from atif_analytics.infrastructure.sqlite_state.checkpointer import SqliteCheckpoint
80
+ from atif_analytics.infrastructure.sqlite_state.retry_queue import SqliteRetryQueue
81
+ from atif_models.domain.ports import LlmStructuredProvider
82
+ from atif_models.domain.registry import ModelSpec
83
+
84
+ _SCHEMA: dict[str, Any] = {
85
+ "uuid": pl.Utf8,
86
+ "session_id": pl.Utf8,
87
+ "ts": pl.Datetime("us", "UTC"),
88
+ "text_snippet": pl.Utf8,
89
+ "label": pl.Utf8,
90
+ "rationale": pl.Utf8,
91
+ # Provenance TIER, not the engine: 'sql' is the deterministic stamp tier,
92
+ # whose rules run in Python here. atif-duck views and macros bind to
93
+ # these literal values, so they are contract, not description.
94
+ "source": pl.Utf8, # 'regex' | 'sql' | 'llm' | 'refused'
95
+ "confidence": pl.Float32,
96
+ "classified_at": pl.Datetime("us", "UTC"),
97
+ }
98
+
99
+ #: Rule 2's imperative first-token set.
100
+ _IMPERATIVE_TOKENS: frozenset[str] = frozenset(
101
+ {"stop", "redo", "revert", "rollback", "undo", "restart"}
102
+ )
103
+
104
+ _WS_RE = re.compile(r"\s+")
105
+
106
+ #: Character ceiling for the "short imperative revert" rule. A correction like
107
+ #: "no, undo that" is terse by nature; past this length the message is carrying
108
+ #: new instructions rather than reverting the last turn, and the imperative
109
+ #: first word stops being evidence of friction.
110
+ _SHORT_REVERT_MAX_CHARS = 30
111
+
112
+ #: Dry-run OUTPUT budget per LLM message (~60 tokens of structured output).
113
+ #: Input is MEASURED at plan time as system prompt + template + ~250 chars
114
+ #: of message (the candidate cutoff is 300): the envelope, not the message,
115
+ #: is most of the per-call bill, so estimating from the message alone
116
+ #: understates it by more than an order of magnitude.
117
+ _AVG_OUT_TOKENS = 60
118
+ _AVG_MESSAGE_CHARS = 250
119
+
120
+ _USER_PROMPT_TEMPLATE = """\
121
+ Classify the following SHORT USER MESSAGE from a Claude Code coding session.
122
+
123
+ You're looking for FRICTION SIGNALS — cues that the human is impatient,
124
+ confused, interrupting the agent, correcting it, or asking for something
125
+ the agent should have provided proactively but didn't.
126
+
127
+ Examples of NON-obvious friction:
128
+ - "screenshot?" → unmet_expectation (agent should have shared a screenshot)
129
+ - "tests?" → unmet_expectation (agent didn't run tests)
130
+ - "link?" → unmet_expectation (agent referenced a resource without linking)
131
+ - "why?" / "why did you do that?" → confusion
132
+ - "wait" / "actually..." → interruption
133
+ - "no not that" / "nope" → correction
134
+ - "are you there?" / "you alive?" → status_ping
135
+
136
+ The MAJORITY of short messages are ordinary task instructions and should
137
+ get label=none. Only flag a friction signal when the cue is clear.
138
+
139
+ USER MESSAGE:
140
+ ```
141
+ {text}
142
+ ```
143
+ """
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Candidates + deterministic stamp tier
148
+ # ---------------------------------------------------------------------------
149
+
150
+
151
+ def candidate_messages(
152
+ steps: list[StepEvent],
153
+ *,
154
+ max_chars: int,
155
+ ) -> list[tuple[str, str, _dt | None, str]]:
156
+ """The friction candidates for one session: ``(uuid, sid-slot, ts, text)``.
157
+
158
+ Human turns (:func:`is_human_turn`), 1..``max_chars`` chars, uuid present
159
+ (the row key). The session id slot is filled by the caller.
160
+ """
161
+ out: list[tuple[str, str, _dt | None, str]] = []
162
+ for step in steps:
163
+ if not is_human_turn(step) or not step.uuid:
164
+ continue
165
+ text = step.text
166
+ if not text or len(text) > max_chars:
167
+ continue
168
+ out.append((step.uuid, "", parse_ts(step.ts), text))
169
+ return out
170
+
171
+
172
+ def deterministic_stamps(
173
+ steps: list[StepEvent],
174
+ candidate_uuids: set[str],
175
+ ) -> dict[str, tuple[str, float, str]]:
176
+ """Run the three deterministic stamp rules over one session's steps.
177
+
178
+ Returns ``{uuid: (label, confidence, 'sql')}``. When two rules match
179
+ the same uuid the higher confidence wins; ties keep the first stamp
180
+ Rules apply in the fixed order 1 → 2 → 3, so a message matching two
181
+ rules always gets the same label.
182
+ """
183
+ out: dict[str, tuple[str, float, str]] = {}
184
+ main = [s for s in steps if not s.is_sidechain]
185
+
186
+ def _stamp(uuid: str, label: str, conf: float) -> None:
187
+ existing = out.get(uuid)
188
+ if existing is None or conf > existing[1]:
189
+ out[uuid] = (label, conf, "sql")
190
+
191
+ # Rule 1 — repeated human message body within 10 human turns. Only human
192
+ # turns are compared: a retry nudge or a screenshot's metadata line
193
+ # repeats by construction and says nothing about the user.
194
+ user_turns = [s for s in main if is_human_turn(s) and s.text and s.uuid]
195
+ norms = [_WS_RE.sub(" ", s.text).lower() for s in user_turns]
196
+ for i, step in enumerate(user_turns):
197
+ if step.uuid not in candidate_uuids:
198
+ continue
199
+ lo = max(0, i - 10)
200
+ if any(norms[j] == norms[i] for j in range(lo, i)):
201
+ _stamp(step.uuid or "", "unmet_expectation", 0.85)
202
+
203
+ # Rule 2 — short imperative reverts.
204
+ for step in user_turns:
205
+ if step.uuid not in candidate_uuids:
206
+ continue
207
+ text = step.text
208
+ if len(text) > _SHORT_REVERT_MAX_CHARS:
209
+ continue
210
+ first = text.strip().split(" ", 1)[0].rstrip(".,!?").lower()
211
+ if first in _IMPERATIVE_TOKENS:
212
+ _stamp(step.uuid or "", "correction", 0.9)
213
+
214
+ # Rule 3 — trailing '?' on the user step immediately after an error
215
+ # tool_result (no event in between — the step right before it on the
216
+ # main chain carries the error flag).
217
+ for i, step in enumerate(main):
218
+ if (
219
+ step.role == "user"
220
+ and step.uuid
221
+ and step.uuid in candidate_uuids
222
+ and step.text.rstrip().endswith("?")
223
+ and i > 0
224
+ and main[i - 1].has_error_result
225
+ ):
226
+ _stamp(step.uuid, "confusion", 0.85)
227
+
228
+ return out
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # Pipeline
233
+ # ---------------------------------------------------------------------------
234
+
235
+
236
+ async def _friction_async(
237
+ settings: AnalyticsSettings,
238
+ *,
239
+ reader: CorpusReader,
240
+ provider: LlmStructuredProvider,
241
+ since_days: int | None,
242
+ limit: int | None,
243
+ cache: ParquetCache,
244
+ checkpoint: SqliteCheckpoint,
245
+ retry: SqliteRetryQueue,
246
+ budget: RunBudget | None,
247
+ ) -> int:
248
+ """Async body behind :func:`detect_user_friction`."""
249
+ already: set[str] = set()
250
+ done_df = cache.read_all(columns=["uuid"])
251
+ if done_df is not None and done_df.height > 0:
252
+ already = set(done_df["uuid"].to_list())
253
+
254
+ bounds = reader.session_bounds(since_days=since_days, limit=limit)
255
+ unchanged_pending, skipped_sessions = checkpointer.filter_unchanged(
256
+ ((sid, lt, mt) for sid, (lt, mt) in bounds.items()),
257
+ pipeline="user_friction",
258
+ checkpoint_db_path=checkpoint.db_path,
259
+ )
260
+ active_sessions: set[str] = set(unchanged_pending)
261
+
262
+ retry_uuids = set(retry.drain(pipeline="user_friction"))
263
+ if retry_uuids:
264
+ logger.info("user_friction: draining {} retry-queue entries", len(retry_uuids))
265
+ already -= retry_uuids
266
+
267
+ # Retry queue is the single re-admission gate for failed units (see
268
+ # classify): a uuid with a live entry (backoff pending or attempts
269
+ # exhausted) is treated as done for THIS run — drain re-admits it when due.
270
+ blocked = retry.blocked_units(pipeline="user_friction")
271
+ if blocked:
272
+ logger.info(
273
+ "user_friction: skipping {} uuids with live retry entries (backoff/attempts cap)",
274
+ len(blocked),
275
+ )
276
+ already |= blocked
277
+
278
+ # Candidates + per-session deterministic stamps. bounds is newest-first,
279
+ # so the session ceiling keeps the freshest sessions (budget guard) —
280
+ # only the LLM tier costs money, but capping at the candidate boundary
281
+ # keeps the cap semantics uniform across pipelines.
282
+ #
283
+ # ``limit`` is applied at SESSION granularity, like every other stage.
284
+ # Truncating a session's candidate list mid-session instead would leave
285
+ # the cut-away messages in no tier and no retry entry while the session
286
+ # still got checkpointed on its RETAINED rows — filter_unchanged would
287
+ # then skip it forever and those messages would never be classified.
288
+ max_sessions = settings.llm_max_sessions_per_run
289
+ if limit is not None:
290
+ max_sessions = min(max_sessions, int(limit))
291
+ sessions_admitted = 0
292
+ capped_out = 0
293
+ candidates: list[tuple[str, str, _dt | None, str]] = []
294
+ stamps: dict[str, tuple[str, float, str]] = {}
295
+ zero_yield: list[str] = []
296
+ for sid in bounds:
297
+ # A retry uuid may live in a checkpoint-skipped session, so skipped
298
+ # sessions are only walked while the retry set is non-empty.
299
+ if sid not in active_sessions and not retry_uuids:
300
+ continue
301
+ if sessions_admitted >= max_sessions:
302
+ capped_out += 1
303
+ continue
304
+ steps = reader.load_steps(sid)
305
+ session_candidates = [
306
+ (uuid, sid, ts, text)
307
+ for uuid, _, ts, text in candidate_messages(
308
+ steps, max_chars=settings.friction_max_chars
309
+ )
310
+ if uuid not in already
311
+ ]
312
+ if sid not in active_sessions:
313
+ # Only retry-queue uuids re-admit rows from a skipped session.
314
+ session_candidates = [c for c in session_candidates if c[0] in retry_uuids]
315
+ if not session_candidates:
316
+ if sid in active_sessions:
317
+ # No candidates means no rows and no LLM call, so without a
318
+ # checkpoint this session pays a full load_steps + candidate
319
+ # walk on every tick forever (a session whose user turns are
320
+ # all longer than friction_max_chars never yields one).
321
+ zero_yield.append(sid)
322
+ continue
323
+ sessions_admitted += 1
324
+ candidates.extend(session_candidates)
325
+ stamps.update(deterministic_stamps(steps, {c[0] for c in session_candidates}))
326
+
327
+ if capped_out:
328
+ logger.warning(
329
+ "user_friction: capped at {} sessions (llm_max_sessions_per_run/limit); {} deferred",
330
+ max_sessions,
331
+ capped_out,
332
+ )
333
+ if zero_yield:
334
+ checkpoint.mark_completed(
335
+ pipeline="user_friction",
336
+ rows=[(sid, *bounds.get(sid, (None, None))) for sid in zero_yield],
337
+ )
338
+ logger.info(
339
+ "user_friction: checkpointed {} sessions with zero candidates as skipped",
340
+ len(zero_yield),
341
+ )
342
+ if skipped_sessions:
343
+ logger.info("user_friction: skipped {} sessions via checkpoint", skipped_sessions)
344
+ logger.info("user_friction: {} candidate user messages", len(candidates))
345
+
346
+ if not candidates:
347
+ logger.info("user_friction: nothing pending")
348
+ return 0
349
+
350
+ # Tier 1: regex fast-path; tier 2: deterministic stamps; rest → LLM.
351
+ now = datetime.now(UTC)
352
+ fast_rows: list[dict[str, Any]] = []
353
+ llm_pending: list[tuple[str, str, _dt | None, str]] = []
354
+ for uuid, session_id, ts, text in candidates:
355
+ hit = regex_fast_path(text or "")
356
+ if hit is not None:
357
+ label, conf = hit
358
+ fast_rows.append(
359
+ {
360
+ "uuid": uuid,
361
+ "session_id": session_id,
362
+ "ts": ts,
363
+ "text_snippet": (text or "")[:200],
364
+ "label": label,
365
+ "rationale": "regex match",
366
+ "source": "regex",
367
+ "confidence": conf,
368
+ "classified_at": now,
369
+ }
370
+ )
371
+ continue
372
+ stamp = stamps.get(uuid)
373
+ if stamp is not None:
374
+ label, conf, source = stamp
375
+ fast_rows.append(
376
+ {
377
+ "uuid": uuid,
378
+ "session_id": session_id,
379
+ "ts": ts,
380
+ "text_snippet": (text or "")[:200],
381
+ "label": label,
382
+ "rationale": "sql stamp",
383
+ "source": source,
384
+ "confidence": conf,
385
+ "classified_at": now,
386
+ }
387
+ )
388
+ continue
389
+ llm_pending.append((uuid, session_id, ts, text))
390
+
391
+ logger.info(
392
+ "user_friction: {} regex, {} sql-stamped, {} pending LLM",
393
+ sum(1 for r in fast_rows if r["source"] == "regex"),
394
+ sum(1 for r in fast_rows if r["source"] == "sql"),
395
+ len(llm_pending),
396
+ )
397
+
398
+ if fast_rows:
399
+ cache.write_part(pl.DataFrame(fast_rows, schema=_SCHEMA))
400
+
401
+ processed_sessions: set[str] = {r["session_id"] for r in fast_rows}
402
+
403
+ if not llm_pending:
404
+ if processed_sessions:
405
+ checkpoint.mark_completed(
406
+ pipeline="user_friction",
407
+ rows=[(sid, *bounds.get(sid, (None, None))) for sid in processed_sessions],
408
+ )
409
+ logger.info("user_friction: wrote {} total rows (fast tiers only)", len(fast_rows))
410
+ return len(fast_rows)
411
+
412
+ # Tier 3: LLM path, one call per message.
413
+ chunk_size = write_chunk_size(settings.batch_size)
414
+ written = len(fast_rows)
415
+
416
+ aborted_sessions: set[str] = set()
417
+ i = 0
418
+ chunk_index = 0
419
+ total_chunks = (len(llm_pending) + chunk_size - 1) // chunk_size
420
+ while i < len(llm_pending):
421
+ chunk_index += 1
422
+ t0 = time.monotonic()
423
+ # Budget-checked sub-batches; ``i`` advances by what was actually
424
+ # SENT so a mid-chunk stop leaves the remainder unstamped.
425
+ chunk, results = await gather_under_budget(
426
+ llm_pending[i : i + chunk_size],
427
+ make_coro=lambda unit: provider.classify_structured(
428
+ system=USER_FRICTION_SYSTEM_PROMPT,
429
+ prompt=_USER_PROMPT_TEMPLATE.format(text=unit[3]),
430
+ schema=UserFrictionSignal,
431
+ ),
432
+ budget=budget,
433
+ )
434
+ if not chunk:
435
+ logger.warning(
436
+ "user_friction: cost ceiling hit (${:.2f} >= ${:.2f}) — "
437
+ "aborting with {} messages unstarted (nothing stamped)",
438
+ budget.spent_usd() if budget is not None else 0.0,
439
+ budget.max_cost_usd if budget is not None else 0.0,
440
+ len(llm_pending) - i,
441
+ )
442
+ # Sessions with unstarted messages must NOT be checkpointed —
443
+ # the next run has to re-admit their remaining candidates.
444
+ aborted_sessions = {session_id for _, session_id, _, _ in llm_pending[i:]}
445
+ break
446
+ i += len(chunk)
447
+ now = datetime.now(UTC)
448
+
449
+ ok_rows: list[dict[str, Any]] = []
450
+ done_uuids: list[str] = []
451
+ errors = 0
452
+ for (uuid, session_id, ts, text), res in zip(chunk, results, strict=True):
453
+ if isinstance(res, BaseException):
454
+ from atif_models.domain.ports import RefusalError
455
+
456
+ if isinstance(res, RefusalError):
457
+ logger.info("user_friction: {} refused — marking none", uuid)
458
+ ok_rows.append(
459
+ {
460
+ "uuid": uuid,
461
+ "session_id": session_id,
462
+ "ts": ts,
463
+ "text_snippet": text[:200],
464
+ "label": "none",
465
+ "rationale": "refused by provider",
466
+ "source": "refused",
467
+ "confidence": 0.0,
468
+ "classified_at": now,
469
+ }
470
+ )
471
+ done_uuids.append(uuid)
472
+ processed_sessions.add(session_id)
473
+ continue
474
+ errors += 1
475
+ logger.warning("user_friction: {} failed (queued for retry): {}", uuid, res)
476
+ retry.enqueue(pipeline="user_friction", unit_id=uuid, error=str(res))
477
+ continue
478
+ ok_rows.append(
479
+ {
480
+ "uuid": uuid,
481
+ "session_id": session_id,
482
+ "ts": ts,
483
+ "text_snippet": text[:200],
484
+ "label": res.label,
485
+ "rationale": (res.rationale or "")[:200],
486
+ "source": "llm",
487
+ "confidence": float(res.confidence),
488
+ "classified_at": now,
489
+ }
490
+ )
491
+ done_uuids.append(uuid)
492
+ processed_sessions.add(session_id)
493
+
494
+ if ok_rows:
495
+ cache.write_part(pl.DataFrame(ok_rows, schema=_SCHEMA))
496
+ retry.mark_done(pipeline="user_friction", unit_ids=done_uuids)
497
+ # Checkpoint only sessions with no messages left to dispatch —
498
+ # stamping early would let a budget abort strand their remainder.
499
+ still_pending = {sid for _, sid, _, _ in llm_pending[i:]}
500
+ chunk_sessions = {r["session_id"] for r in ok_rows} - still_pending
501
+ if chunk_sessions:
502
+ checkpoint.mark_completed(
503
+ pipeline="user_friction",
504
+ rows=[(sid, *bounds.get(sid, (None, None))) for sid in chunk_sessions],
505
+ )
506
+
507
+ written += len(ok_rows)
508
+ logger.info(
509
+ "user_friction chunk {}/{}: {} ok, {} errors, {:.1f}s",
510
+ chunk_index,
511
+ total_chunks,
512
+ len(ok_rows),
513
+ errors,
514
+ time.monotonic() - t0,
515
+ )
516
+
517
+ completed_sessions = processed_sessions - aborted_sessions
518
+ if completed_sessions:
519
+ checkpoint.mark_completed(
520
+ pipeline="user_friction",
521
+ rows=[(sid, *bounds.get(sid, (None, None))) for sid in completed_sessions],
522
+ )
523
+ logger.info("user_friction: wrote {} total rows", written)
524
+ return written
525
+
526
+
527
+ def detect_user_friction(
528
+ settings: AnalyticsSettings,
529
+ *,
530
+ since_days: int | None = None,
531
+ limit: int | None = None,
532
+ dry_run: bool = True,
533
+ reader: CorpusReader | None = None,
534
+ provider: LlmStructuredProvider | None = None,
535
+ spec: ModelSpec | None = None,
536
+ cache: ParquetCache | None = None,
537
+ checkpoint: SqliteCheckpoint | None = None,
538
+ retry: SqliteRetryQueue | None = None,
539
+ budget: RunBudget | None = None,
540
+ ) -> int | dict[str, Any]:
541
+ """Classify short user messages for friction signals.
542
+
543
+ See the module docstring for the tier structure. ``dry_run`` (the
544
+ default) returns a plan dict; a real run returns the row count.
545
+
546
+ ``limit`` caps SESSIONS, not candidate messages: an admitted session
547
+ always has its whole candidate set classified before it is checkpointed,
548
+ so no message is ever stranded outside every tier.
549
+ """
550
+ layout = settings.layout()
551
+ reader = (
552
+ reader
553
+ if reader is not None
554
+ else CorpusReader(settings.corpus_root, caps=settings.transcript_caps())
555
+ )
556
+ cache = cache if cache is not None else build_cache(layout.user_friction_dir)
557
+
558
+ if provider is None or spec is None:
559
+ provider, spec = build_provider(settings, "friction")
560
+
561
+ if dry_run:
562
+ # Measured estimation: candidate count × ~250 in-tokens (template +
563
+ # short message), capped at the session ceiling like the real run.
564
+ bounds = reader.session_bounds(since_days=since_days, limit=limit)
565
+ max_sessions = settings.llm_max_sessions_per_run
566
+ if limit is not None:
567
+ max_sessions = min(max_sessions, int(limit))
568
+ n = 0
569
+ capped_sessions = 0
570
+ for sid in bounds:
571
+ if capped_sessions >= max_sessions:
572
+ break
573
+ c = len(
574
+ candidate_messages(reader.load_steps(sid), max_chars=settings.friction_max_chars)
575
+ )
576
+ if c:
577
+ capped_sessions += 1
578
+ n += c
579
+ # Budget-guard posture: assume EVERY candidate reaches the LLM tier.
580
+ # Roughly half survive the fast tiers in practice, but a budget
581
+ # estimate must bound the worst case, not the average.
582
+ llm_n = n
583
+ # Per-call input measured from what each call actually sends: the
584
+ # system prompt + the rendered template + ~250 chars of message.
585
+ per_call_chars = (
586
+ len(USER_FRICTION_SYSTEM_PROMPT) + len(_USER_PROMPT_TEMPLATE) + _AVG_MESSAGE_CHARS
587
+ )
588
+ in_tokens = llm_n * tokens_for_chars(per_call_chars)
589
+ pricing = (spec.pricing_in or 0.0, spec.pricing_out or 0.0)
590
+ cost = estimate_cost_tokens(in_tokens, llm_n * _AVG_OUT_TOKENS, pricing)
591
+ logger.info(
592
+ "user_friction --dry-run: {} candidates (~{} hit LLM), estimated cost ~${:.2f}",
593
+ n,
594
+ llm_n,
595
+ cost,
596
+ )
597
+ return {
598
+ "pipeline": "friction",
599
+ "candidates": n,
600
+ "capped_candidates": n,
601
+ "llm_calls": llm_n,
602
+ "estimated_input_tokens": in_tokens,
603
+ "estimated_output_tokens": llm_n * _AVG_OUT_TOKENS,
604
+ "estimated_cost_usd": round(cost, 4),
605
+ "max_sessions_per_run": max_sessions,
606
+ "max_cost_usd_per_run": settings.llm_max_cost_usd_per_run,
607
+ "model": spec.model_id,
608
+ "since_days": since_days,
609
+ "limit": limit,
610
+ "friction_max_chars": settings.friction_max_chars,
611
+ "dry_run": True,
612
+ }
613
+
614
+ checkpoint = checkpoint if checkpoint is not None else build_checkpoint(layout.state_db_path)
615
+ retry = retry if retry is not None else build_retry_queue(layout.state_db_path)
616
+ if budget is not None:
617
+ budget.watch(provider, spec)
618
+ with pipeline_usage(provider, spec, "friction"):
619
+ return asyncio.run(
620
+ _friction_async(
621
+ settings,
622
+ reader=reader,
623
+ provider=provider,
624
+ since_days=since_days,
625
+ limit=limit,
626
+ cache=cache,
627
+ checkpoint=checkpoint,
628
+ retry=retry,
629
+ budget=budget,
630
+ )
631
+ )
632
+
633
+
634
+ __all__ = ["candidate_messages", "detect_user_friction", "deterministic_stamps"]