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.
- atif_analytics/__init__.py +9 -0
- atif_analytics/application/__init__.py +3 -0
- atif_analytics/application/analyze.py +158 -0
- atif_analytics/application/prompts.py +750 -0
- atif_analytics/application/use_cases/__init__.py +3 -0
- atif_analytics/application/use_cases/_shared.py +264 -0
- atif_analytics/application/use_cases/classify.py +430 -0
- atif_analytics/application/use_cases/conflicts.py +448 -0
- atif_analytics/application/use_cases/friction.py +634 -0
- atif_analytics/application/use_cases/perceived.py +451 -0
- atif_analytics/domain/__init__.py +3 -0
- atif_analytics/domain/authorship.py +166 -0
- atif_analytics/domain/config.py +30 -0
- atif_analytics/domain/costs.py +50 -0
- atif_analytics/domain/friction.py +117 -0
- atif_analytics/domain/layout.py +94 -0
- atif_analytics/domain/models.py +385 -0
- atif_analytics/domain/ports.py +100 -0
- atif_analytics/domain/transcript.py +359 -0
- atif_analytics/infrastructure/__init__.py +3 -0
- atif_analytics/infrastructure/corpus_reader.py +546 -0
- atif_analytics/infrastructure/parquet_cache.py +186 -0
- atif_analytics/infrastructure/settings.py +114 -0
- atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
- atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
- atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
- atif_analytics/py.typed +0 -0
- atif_cli/__init__.py +3 -0
- atif_cli/__main__.py +8 -0
- atif_cli/app.py +2459 -0
- atif_cli/converter_adapter.py +151 -0
- atif_cli/corpus.py +409 -0
- atif_cli/cron.py +311 -0
- atif_cli/duck_errors.py +97 -0
- atif_cli/embed_lake.py +101 -0
- atif_cli/errors.py +89 -0
- atif_cli/lake.py +373 -0
- atif_cli/lake_sessions.py +270 -0
- atif_cli/output.py +297 -0
- atif_cli/py.typed +0 -0
- atif_converter/__init__.py +3 -0
- atif_converter/application/__init__.py +3 -0
- atif_converter/application/convert_and_audit.py +294 -0
- atif_converter/application/convert_codex.py +210 -0
- atif_converter/domain/__init__.py +9 -0
- atif_converter/domain/agents.py +40 -0
- atif_converter/domain/atif/__init__.py +55 -0
- atif_converter/domain/atif/agent.py +37 -0
- atif_converter/domain/atif/content.py +161 -0
- atif_converter/domain/atif/final_metrics.py +45 -0
- atif_converter/domain/atif/metrics.py +49 -0
- atif_converter/domain/atif/observation.py +21 -0
- atif_converter/domain/atif/observation_result.py +47 -0
- atif_converter/domain/atif/step.py +144 -0
- atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
- atif_converter/domain/atif/tool_call.py +36 -0
- atif_converter/domain/atif/trajectory.py +211 -0
- atif_converter/domain/atif/trajectory_validator.py +299 -0
- atif_converter/domain/blobs.py +510 -0
- atif_converter/domain/claude_code_conversion.py +1077 -0
- atif_converter/domain/codex_conversion.py +1036 -0
- atif_converter/domain/codex_edges.py +192 -0
- atif_converter/domain/codex_enrichment.py +419 -0
- atif_converter/domain/codex_fidelity.py +124 -0
- atif_converter/domain/codex_nested_calls.py +165 -0
- atif_converter/domain/edges.py +113 -0
- atif_converter/domain/enrichment.py +412 -0
- atif_converter/domain/errors.py +60 -0
- atif_converter/domain/fidelity.py +171 -0
- atif_converter/domain/model_prices.json +8538 -0
- atif_converter/domain/pricing.py +790 -0
- atif_converter/domain/result_signals.py +774 -0
- atif_converter/domain/schema_version.py +90 -0
- atif_converter/domain/session_events.py +408 -0
- atif_converter/infrastructure/__init__.py +3 -0
- atif_converter/infrastructure/census.py +89 -0
- atif_converter/infrastructure/claude_code_converter.py +127 -0
- atif_converter/infrastructure/codex_adapter.py +132 -0
- atif_converter/infrastructure/codex_census.py +100 -0
- atif_converter/infrastructure/codex_converter.py +82 -0
- atif_converter/infrastructure/harbor_adapter.py +187 -0
- atif_converter/infrastructure/raw_records.py +426 -0
- atif_converter/infrastructure/source_archive.py +154 -0
- atif_converter/py.typed +0 -0
- atif_corpus/__init__.py +13 -0
- atif_corpus/application/__init__.py +7 -0
- atif_corpus/application/materialize.py +1826 -0
- atif_corpus/domain/__init__.py +9 -0
- atif_corpus/domain/agents.py +33 -0
- atif_corpus/domain/generation.py +34 -0
- atif_corpus/domain/layout.py +168 -0
- atif_corpus/domain/ports.py +175 -0
- atif_corpus/domain/session_id.py +68 -0
- atif_corpus/domain/sessions.py +228 -0
- atif_corpus/domain/slug.py +72 -0
- atif_corpus/domain/source_layout.py +143 -0
- atif_corpus/domain/watermark.py +76 -0
- atif_corpus/infrastructure/__init__.py +7 -0
- atif_corpus/infrastructure/atomic.py +266 -0
- atif_corpus/infrastructure/compress_artifacts.py +161 -0
- atif_corpus/infrastructure/fake_converter.py +174 -0
- atif_corpus/infrastructure/scanner.py +316 -0
- atif_corpus/infrastructure/settings.py +130 -0
- atif_corpus/infrastructure/source_archive.py +202 -0
- atif_corpus/py.typed +0 -0
- atif_duck/__init__.py +43 -0
- atif_duck/domain/__init__.py +32 -0
- atif_duck/domain/artifacts.py +66 -0
- atif_duck/domain/authorship.py +165 -0
- atif_duck/domain/catalog.py +626 -0
- atif_duck/domain/columnar.py +149 -0
- atif_duck/domain/embedding_guard.py +88 -0
- atif_duck/domain/examples.py +265 -0
- atif_duck/domain/lake.py +427 -0
- atif_duck/domain/lake_sessions.py +97 -0
- atif_duck/domain/raw_readers.py +140 -0
- atif_duck/domain/session_id.py +69 -0
- atif_duck/domain/sql_literal.py +50 -0
- atif_duck/infrastructure/__init__.py +34 -0
- atif_duck/infrastructure/analytics.py +469 -0
- atif_duck/infrastructure/authorship.py +139 -0
- atif_duck/infrastructure/columnar.py +641 -0
- atif_duck/infrastructure/lake.py +1383 -0
- atif_duck/infrastructure/lake_sessions.py +194 -0
- atif_duck/infrastructure/lake_settings.py +63 -0
- atif_duck/infrastructure/lake_steps.py +225 -0
- atif_duck/infrastructure/projections.py +221 -0
- atif_duck/infrastructure/registry.py +2194 -0
- atif_duck/infrastructure/stored_artifacts.py +115 -0
- atif_duck/py.typed +0 -0
- atif_embed/__init__.py +12 -0
- atif_embed/application/__init__.py +3 -0
- atif_embed/application/embed.py +293 -0
- atif_embed/application/prune.py +80 -0
- atif_embed/domain/__init__.py +3 -0
- atif_embed/domain/discovery.py +70 -0
- atif_embed/domain/embedding_guard.py +83 -0
- atif_embed/domain/errors.py +94 -0
- atif_embed/domain/ports.py +223 -0
- atif_embed/domain/sql_literal.py +49 -0
- atif_embed/domain/text_stamp.py +71 -0
- atif_embed/infrastructure/__init__.py +3 -0
- atif_embed/infrastructure/cohere_bedrock.py +433 -0
- atif_embed/infrastructure/corpus_text_rows.py +273 -0
- atif_embed/infrastructure/gemma_local.py +355 -0
- atif_embed/infrastructure/lake_text_rows.py +203 -0
- atif_embed/infrastructure/lance_store.py +440 -0
- atif_embed/infrastructure/providers.py +30 -0
- atif_embed/infrastructure/settings.py +154 -0
- atif_embed/py.typed +0 -0
- atif_models/__init__.py +14 -0
- atif_models/domain/__init__.py +9 -0
- atif_models/domain/ports.py +143 -0
- atif_models/domain/registry.py +151 -0
- atif_models/domain/schema.py +109 -0
- atif_models/infrastructure/__init__.py +8 -0
- atif_models/infrastructure/openai_bedrock.py +314 -0
- atif_models/infrastructure/settings.py +85 -0
- atif_models/py.typed +0 -0
- atif_sql-0.2.0.dist-info/METADATA +272 -0
- atif_sql-0.2.0.dist-info/RECORD +164 -0
- atif_sql-0.2.0.dist-info/WHEEL +4 -0
- atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
- 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"]
|