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,50 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Pure cost arithmetic for the LLM-analytics pipelines.
|
|
4
|
+
|
|
5
|
+
:func:`estimate_cost_tokens` is the back-of-envelope dollar projection every
|
|
6
|
+
``--dry-run`` path uses to price a pending batch of classification calls. It
|
|
7
|
+
is pure arithmetic over token counts and a ``(input_rate, output_rate)``
|
|
8
|
+
$/MTok tuple — no DuckDB, no Bedrock, no settings — so it belongs in the
|
|
9
|
+
domain hexagon.
|
|
10
|
+
|
|
11
|
+
Distinct from :func:`atif_models.domain.registry.estimate_cost`, which prices
|
|
12
|
+
one ACCUMULATED usage total against a resolved ``ModelSpec``; this one prices
|
|
13
|
+
a PLANNED batch before any call is made. The dry-run plan dicts use this; the
|
|
14
|
+
post-run usage log uses the atif-models one.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
#: The chars→tokens heuristic every measured dry-run estimate uses. Four
|
|
20
|
+
#: chars per token is the standard English/code prior. Measuring the real
|
|
21
|
+
#: rendered length matters more than the ratio: a fixed per-call "avg
|
|
22
|
+
#: tokens" constant understates a real transcript by an order of magnitude.
|
|
23
|
+
CHARS_PER_TOKEN: int = 4
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def tokens_for_chars(chars: int) -> int:
|
|
27
|
+
"""Estimate the token count of ``chars`` characters (chars/4, floor 1 when non-empty)."""
|
|
28
|
+
if chars <= 0:
|
|
29
|
+
return 0
|
|
30
|
+
return max(1, chars // CHARS_PER_TOKEN)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def estimate_cost_tokens(
|
|
34
|
+
input_tokens: int,
|
|
35
|
+
output_tokens: int,
|
|
36
|
+
pricing: tuple[float, float],
|
|
37
|
+
) -> float:
|
|
38
|
+
"""Dollar estimate for MEASURED token totals (the dry-run plan path).
|
|
39
|
+
|
|
40
|
+
``pricing`` is ``(input_rate, output_rate)`` in $/MTok. Cost is
|
|
41
|
+
``(in_tokens * in_rate + out_tokens * out_rate) / 1e6`` — a flat linear
|
|
42
|
+
projection with no minimums, tiers, or cache accounting. Totals are
|
|
43
|
+
summed per unit from real rendered prompt lengths, never from a static
|
|
44
|
+
per-call average.
|
|
45
|
+
"""
|
|
46
|
+
in_rate, out_rate = pricing
|
|
47
|
+
return (input_tokens * in_rate + output_tokens * out_rate) / 1_000_000
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["CHARS_PER_TOKEN", "estimate_cost_tokens", "tokens_for_chars"]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Pure regex fast-path for user-friction classification.
|
|
4
|
+
|
|
5
|
+
The friction pipeline's regex fast-path is a pure function over a message
|
|
6
|
+
string — stdlib ``re`` only, no DuckDB, no LLM, no settings — so it belongs
|
|
7
|
+
in the domain layer. It catches the unambiguous friction shapes (status pings, hard
|
|
8
|
+
interruption keywords, explicit corrections) so those never pay an LLM call;
|
|
9
|
+
everything ambiguous falls through to the SQL-stamp layer and then the LLM
|
|
10
|
+
(both of which live in ``application.use_cases.friction``).
|
|
11
|
+
|
|
12
|
+
The label semantics are **frozen**: ``status_ping`` / ``interruption`` /
|
|
13
|
+
``correction`` at a flat 0.9 confidence. Ambiguous phrasings deliberately fall
|
|
14
|
+
through so a single mis-tuned pattern can't poison the corpus.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Regex fast-path
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
#
|
|
25
|
+
# These patterns catch the unambiguous cases so we don't pay the LLM for them.
|
|
26
|
+
# Everything ambiguous falls through to the LLM, which is where the
|
|
27
|
+
# "screenshot?" / "tests?" class lives (those need semantic context to
|
|
28
|
+
# distinguish from genuine topic questions like "can you write tests?").
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
REGEX_BANK: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
32
|
+
# Status pings — only multi-word phrasings that unambiguously ask about
|
|
33
|
+
# progress. Anything shorter or even slightly ambiguous ("status?",
|
|
34
|
+
# "what's the status column called?") falls through to the LLM so it
|
|
35
|
+
# can disambiguate via context. The trailing-context guards below
|
|
36
|
+
# require a question mark, end-of-string, or a progress-related word
|
|
37
|
+
# to avoid matching "what's the status column called".
|
|
38
|
+
(
|
|
39
|
+
"status_ping",
|
|
40
|
+
re.compile(
|
|
41
|
+
r"""
|
|
42
|
+
\bhow(?:'s|\s+is|\s+are|\s+it)?\s+(?:it|we|things|progress)\s+
|
|
43
|
+
(?:going|coming|doing|looking|progressing|holding\s+up)\b
|
|
44
|
+
| \bhow'?s?\s+progress\b(?=\s*[?.!]?\s*$)
|
|
45
|
+
| \bany\s+update(?:s)?\b(?=\s*[?.!]?\s*$)
|
|
46
|
+
| \bstatus\s+update\b
|
|
47
|
+
| \bwhere\s+(?:are\s+we|we['’]re)\s+(?:at|with)\b
|
|
48
|
+
| \b(?:are\s+you\s+)?still\s+(?:working|going|running|on\s+it)\b
|
|
49
|
+
| \bwhat'?s?\s+(?:the|your)\s+eta\b
|
|
50
|
+
| \bhow\s+(?:much\s+)?long\s+(?:until|till|more|left)\b
|
|
51
|
+
""",
|
|
52
|
+
re.IGNORECASE | re.VERBOSE,
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
# Hard interruption keywords at the start of a message.
|
|
56
|
+
(
|
|
57
|
+
"interruption",
|
|
58
|
+
re.compile(
|
|
59
|
+
r"""
|
|
60
|
+
^\s*
|
|
61
|
+
(?:
|
|
62
|
+
wait(?:\s*[.,!]|\s+a\s+(?:sec|second|moment|minute)|[\s$])
|
|
63
|
+
| stop(?:\s*[.,!]|\s+(?:right\s+)?there|[\s$])
|
|
64
|
+
| hold\s+on
|
|
65
|
+
| hold\s+up
|
|
66
|
+
| hang\s+on
|
|
67
|
+
| actually[,\s]
|
|
68
|
+
| before\s+you\s+(?:do|go)
|
|
69
|
+
| pause\b
|
|
70
|
+
| nvm\b
|
|
71
|
+
| never\s*mind\b
|
|
72
|
+
)
|
|
73
|
+
""",
|
|
74
|
+
re.IGNORECASE | re.VERBOSE,
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
# Explicit corrections.
|
|
78
|
+
(
|
|
79
|
+
"correction",
|
|
80
|
+
re.compile(
|
|
81
|
+
r"""
|
|
82
|
+
^\s*
|
|
83
|
+
(?:
|
|
84
|
+
no[,\s.!]
|
|
85
|
+
| nope[,\s.!]?
|
|
86
|
+
| nah[,\s.!]?
|
|
87
|
+
| that'?s\s+(?:wrong|not\s+(?:right|it|what)|incorrect)
|
|
88
|
+
| not\s+(?:that|what\s+i)
|
|
89
|
+
| try\s+again
|
|
90
|
+
| wrong\b
|
|
91
|
+
| that'?s\s+not\s+
|
|
92
|
+
)
|
|
93
|
+
""",
|
|
94
|
+
re.IGNORECASE | re.VERBOSE,
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def regex_fast_path(text: str) -> tuple[str, float] | None:
|
|
101
|
+
"""Return ``(label, confidence)`` for a regex hit or ``None``.
|
|
102
|
+
|
|
103
|
+
Confidence is a flat 0.9 for regex hits -- these are hand-picked,
|
|
104
|
+
unambiguous phrasings. Ambiguous shapes deliberately fall through to
|
|
105
|
+
the LLM so a single misclassification in the bank does not poison the
|
|
106
|
+
corpus.
|
|
107
|
+
"""
|
|
108
|
+
if not text:
|
|
109
|
+
return None
|
|
110
|
+
probe = text[:512]
|
|
111
|
+
for label, pat in REGEX_BANK:
|
|
112
|
+
if pat.search(probe):
|
|
113
|
+
return label, 0.9
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = ["REGEX_BANK", "regex_fast_path"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""The analytics artifact layout under one corpus root, as a value object.
|
|
4
|
+
|
|
5
|
+
CONTRACT-V2 §Ports & state fixes the storage shape: sharded parquet dirs
|
|
6
|
+
under ``<corpus_root>/analytics/<name>/`` for the LLM pipelines and one
|
|
7
|
+
sqlite WAL ``state.db`` per corpus. The trajectory dir and the structural
|
|
8
|
+
single-file parquets (clusters, cluster_terms, session_communities,
|
|
9
|
+
community_profile) were removed with their pipelines on 2026-09-27; an older
|
|
10
|
+
corpus may still hold them, and nothing reads them.
|
|
11
|
+
This module is the only place those paths are computed (the atif-corpus
|
|
12
|
+
``CorpusLayout`` precedent) — atif-duck reads the same shape without
|
|
13
|
+
importing us, and atif-cli threads ``corpus_root`` through.
|
|
14
|
+
|
|
15
|
+
Pure path arithmetic; nothing here touches the filesystem.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
#: Sharded-cache directory names for the LLM pipelines. The names match
|
|
24
|
+
#: the DuckDB view each backs.
|
|
25
|
+
CLASSIFICATIONS_DIRNAME = "session_classifications"
|
|
26
|
+
CONFLICTS_DIRNAME = "session_conflicts"
|
|
27
|
+
USER_FRICTION_DIRNAME = "user_friction"
|
|
28
|
+
PERCEIVED_ERRORS_DIRNAME = "perceived_errors"
|
|
29
|
+
|
|
30
|
+
#: Refusal audit sidecar: a durable parquet record of a refusal-skip rather
|
|
31
|
+
#: than a log line that ages out. Pipelines whose in-band schema can't hold a
|
|
32
|
+
#: sentinel row (conflicts: rows are uuid-keyed PAIRS) append here instead:
|
|
33
|
+
#: ``{pipeline, unit_id, reason, refused_at}``.
|
|
34
|
+
#:
|
|
35
|
+
#: The atif-duck catalog binds NO view over this directory, so it has no name
|
|
36
|
+
#: in ``atif-sql schema`` and no name to SELECT from. A reader has to point
|
|
37
|
+
#: ``read_parquet`` at the shard files under
|
|
38
|
+
#: ``<corpus_root>/analytics/refusals/`` itself.
|
|
39
|
+
REFUSALS_DIRNAME = "refusals"
|
|
40
|
+
|
|
41
|
+
STATE_DB_FILENAME = "state.db"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class AnalyticsLayout:
|
|
46
|
+
"""Computes every analytics artifact path under one ``corpus_root``."""
|
|
47
|
+
|
|
48
|
+
corpus_root: Path
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def analytics_dir(self) -> Path:
|
|
52
|
+
"""``<corpus_root>/analytics/`` — parent of every artifact below."""
|
|
53
|
+
return self.corpus_root / "analytics"
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def classifications_dir(self) -> Path:
|
|
57
|
+
"""Sharded cache for the classify pipeline."""
|
|
58
|
+
return self.analytics_dir / CLASSIFICATIONS_DIRNAME
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def conflicts_dir(self) -> Path:
|
|
62
|
+
"""Sharded cache for the conflicts pipeline."""
|
|
63
|
+
return self.analytics_dir / CONFLICTS_DIRNAME
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def user_friction_dir(self) -> Path:
|
|
67
|
+
"""Sharded cache for the friction pipeline."""
|
|
68
|
+
return self.analytics_dir / USER_FRICTION_DIRNAME
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def perceived_errors_dir(self) -> Path:
|
|
72
|
+
"""Sharded cache for the perceived-error pipeline."""
|
|
73
|
+
return self.analytics_dir / PERCEIVED_ERRORS_DIRNAME
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def refusals_dir(self) -> Path:
|
|
77
|
+
"""Sharded refusal-audit sidecar (see :data:`REFUSALS_DIRNAME`)."""
|
|
78
|
+
return self.analytics_dir / REFUSALS_DIRNAME
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def state_db_path(self) -> Path:
|
|
82
|
+
"""The sqlite WAL checkpoint + retry-queue file (one per corpus)."""
|
|
83
|
+
return self.analytics_dir / STATE_DB_FILENAME
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
__all__ = [
|
|
87
|
+
"CLASSIFICATIONS_DIRNAME",
|
|
88
|
+
"CONFLICTS_DIRNAME",
|
|
89
|
+
"PERCEIVED_ERRORS_DIRNAME",
|
|
90
|
+
"REFUSALS_DIRNAME",
|
|
91
|
+
"STATE_DB_FILENAME",
|
|
92
|
+
"USER_FRICTION_DIRNAME",
|
|
93
|
+
"AnalyticsLayout",
|
|
94
|
+
]
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Pydantic v2 classification schemas (pure domain models).
|
|
4
|
+
|
|
5
|
+
Enum value sets, field names, bounds, and descriptions are FROZEN by
|
|
6
|
+
CONTRACT-V2 §Ports & state. The descriptions are part of the prompt surface
|
|
7
|
+
— the strict-schema transform in atif-models ships them to the model — so
|
|
8
|
+
editing one here changes classifier behavior, and changing an enum value
|
|
9
|
+
breaks the analytics views bound to it.
|
|
10
|
+
|
|
11
|
+
The OpenAI strict-mode wire transform lives in
|
|
12
|
+
:mod:`atif_models.domain.schema`; these models stay pure pydantic.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Literal
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SessionClassification(BaseModel):
|
|
23
|
+
"""Classify an entire Claude Code session: what kind of work, toward what goal.
|
|
24
|
+
|
|
25
|
+
One row per session, written to the ``session_classifications`` parquet
|
|
26
|
+
cache. Used to build the ``session_classifications`` DuckDB view.
|
|
27
|
+
|
|
28
|
+
``autonomy_tier`` and ``success`` were removed on 2026-09-27: neither was
|
|
29
|
+
reproducible, and what a session achieved is now computed from its steps
|
|
30
|
+
(atif-duck's ``session_outcomes``).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
model_config = ConfigDict(extra="forbid")
|
|
34
|
+
|
|
35
|
+
work_category: Literal[
|
|
36
|
+
"sde",
|
|
37
|
+
"admin",
|
|
38
|
+
"strategy_business",
|
|
39
|
+
"events",
|
|
40
|
+
"thought_leadership",
|
|
41
|
+
"other",
|
|
42
|
+
] = Field(
|
|
43
|
+
...,
|
|
44
|
+
description=(
|
|
45
|
+
"Dominant activity. "
|
|
46
|
+
"'sde': software engineering, debugging, code review, tests, CI. "
|
|
47
|
+
"'admin': expense reports, scheduling, low-signal email triage, routine ops. "
|
|
48
|
+
"'strategy_business': business analysis, competitive research, strategic memos, "
|
|
49
|
+
"proposals. "
|
|
50
|
+
"'events': event planning/logistics, speaker prep, agenda building. "
|
|
51
|
+
"'thought_leadership': writing for external audiences (blog posts, conference "
|
|
52
|
+
"abstracts, LinkedIn). "
|
|
53
|
+
"'other': use only when nothing else fits."
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
goal: str = Field(
|
|
57
|
+
...,
|
|
58
|
+
min_length=1,
|
|
59
|
+
max_length=280,
|
|
60
|
+
description=(
|
|
61
|
+
"ONE sentence summarizing the user's goal, inferred from the opening user "
|
|
62
|
+
"messages and overall arc. Present tense, <= 280 chars. Example: "
|
|
63
|
+
'"Refactor the auth middleware to use the new token rotator."'
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
confidence: float = Field(
|
|
67
|
+
...,
|
|
68
|
+
ge=0.0,
|
|
69
|
+
le=1.0,
|
|
70
|
+
description=(
|
|
71
|
+
"Classifier self-assessed confidence 0.0-1.0. Use <0.5 for genuinely "
|
|
72
|
+
"ambiguous sessions."
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ConflictPair(BaseModel):
|
|
78
|
+
"""A single stance-conflict pair, keyed on the two opposing turn UUIDs."""
|
|
79
|
+
|
|
80
|
+
model_config = ConfigDict(extra="forbid")
|
|
81
|
+
|
|
82
|
+
turn_a_uuid: str = Field(
|
|
83
|
+
...,
|
|
84
|
+
min_length=1,
|
|
85
|
+
max_length=64,
|
|
86
|
+
description=(
|
|
87
|
+
"UUID of the first turn that holds stance A. Pull from the "
|
|
88
|
+
"``[uuid=...]`` headers in the bound transcript -- copy verbatim, "
|
|
89
|
+
"do NOT invent or paraphrase. Together with ``turn_b_uuid`` this "
|
|
90
|
+
"is the natural key of a conflict row."
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
turn_b_uuid: str = Field(
|
|
94
|
+
...,
|
|
95
|
+
min_length=1,
|
|
96
|
+
max_length=64,
|
|
97
|
+
description=(
|
|
98
|
+
"UUID of the second turn that holds the opposing stance. Same "
|
|
99
|
+
"rules as ``turn_a_uuid`` -- copy verbatim from the transcript "
|
|
100
|
+
"headers. Must differ from ``turn_a_uuid``."
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
conflict_kind: Literal["disagreement", "correction", "reversal", "impasse"] = Field(
|
|
104
|
+
...,
|
|
105
|
+
description=(
|
|
106
|
+
"Shape of the conflict. "
|
|
107
|
+
"'disagreement': two parties hold opposing positions and discuss them. "
|
|
108
|
+
"'correction': one party explicitly tells the other their answer/action "
|
|
109
|
+
"was wrong (e.g. 'no, not that, do X instead'). "
|
|
110
|
+
"'reversal': the same party flips their own earlier position "
|
|
111
|
+
"('actually let's NOT do X'). "
|
|
112
|
+
"'impasse': both sides restate their positions without converging "
|
|
113
|
+
"and the topic stalls."
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
severity: Literal["low", "medium", "high"] = Field(
|
|
117
|
+
...,
|
|
118
|
+
description=(
|
|
119
|
+
"How consequential the conflict is for the session outcome. "
|
|
120
|
+
"'low': a minor course nudge with little downstream impact. "
|
|
121
|
+
"'medium': changes the implementation approach or scope but stays "
|
|
122
|
+
"inside the original goal. "
|
|
123
|
+
"'high': blocks progress, reverses a major decision, or "
|
|
124
|
+
"fundamentally changes the goal."
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
agent_position: str = Field(
|
|
128
|
+
...,
|
|
129
|
+
min_length=1,
|
|
130
|
+
max_length=280,
|
|
131
|
+
description=(
|
|
132
|
+
"One-sentence summary of the agent's stance in this conflict, "
|
|
133
|
+
"phrased in the agent's own framing. Strip pleasantries; keep "
|
|
134
|
+
"the substantive claim."
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
user_position: str = Field(
|
|
138
|
+
...,
|
|
139
|
+
min_length=1,
|
|
140
|
+
max_length=280,
|
|
141
|
+
description=(
|
|
142
|
+
"One-sentence summary of the user's stance, phrased the way the "
|
|
143
|
+
"user phrased it. Same length budget as ``agent_position``."
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
confidence: float = Field(
|
|
147
|
+
...,
|
|
148
|
+
ge=0.0,
|
|
149
|
+
le=1.0,
|
|
150
|
+
description=(
|
|
151
|
+
"Classifier self-confidence 0.0-1.0 that this is a real, "
|
|
152
|
+
"substantive conflict (not deliberation, not collaboration, not "
|
|
153
|
+
"an accepted-risk caveat). Use <0.5 for borderline cases."
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ConflictsResult(BaseModel):
|
|
159
|
+
"""The model's response: zero or more conflict pairs.
|
|
160
|
+
|
|
161
|
+
An empty list is valid and common. When the model returns an empty
|
|
162
|
+
list the pipeline writes ZERO rows for that session — sessions with no
|
|
163
|
+
conflicts simply don't appear in ``session_conflicts`` (or in the
|
|
164
|
+
derived ``conflicts_summary`` view).
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
model_config = ConfigDict(extra="forbid")
|
|
168
|
+
|
|
169
|
+
conflicts: list[ConflictPair] = Field(
|
|
170
|
+
default_factory=list,
|
|
171
|
+
description=(
|
|
172
|
+
"Zero or more stance-conflict pairs. Each entry names the two "
|
|
173
|
+
"turn UUIDs whose stances clash, the kind/severity, both party "
|
|
174
|
+
"positions, and a confidence score. Report only substantive "
|
|
175
|
+
"technical or strategic conflicts -- skip trivial style "
|
|
176
|
+
"disagreements."
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class UserFrictionSignal(BaseModel):
|
|
182
|
+
"""Classify a single short user message for friction signals.
|
|
183
|
+
|
|
184
|
+
A "friction signal" is anything in the user's utterance that implies the
|
|
185
|
+
agent's last turn fell short of expectations: an impatient status ping,
|
|
186
|
+
a one-word question pointing at a missed artifact (``screenshot?``,
|
|
187
|
+
``tests?``), confusion about what happened, a hard interruption, a
|
|
188
|
+
correction, or open frustration.
|
|
189
|
+
|
|
190
|
+
Applied only to user-role messages below the friction char cutoff
|
|
191
|
+
(default 300). Long user messages are almost always genuine task turns
|
|
192
|
+
rather than interrupt/confusion signals — the filter keeps LLM cost
|
|
193
|
+
linear in the interesting slice.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
model_config = ConfigDict(extra="forbid")
|
|
197
|
+
|
|
198
|
+
label: Literal[
|
|
199
|
+
"status_ping",
|
|
200
|
+
"unmet_expectation",
|
|
201
|
+
"confusion",
|
|
202
|
+
"interruption",
|
|
203
|
+
"correction",
|
|
204
|
+
"frustration",
|
|
205
|
+
"none",
|
|
206
|
+
] = Field(
|
|
207
|
+
...,
|
|
208
|
+
description=(
|
|
209
|
+
"Dominant friction category for this single user message. "
|
|
210
|
+
"'status_ping': the user asks about progress/ETA ('how's it going?', "
|
|
211
|
+
"'any update?', 'status?', 'where are we?'). "
|
|
212
|
+
"'unmet_expectation': a one- or two-word question that points at "
|
|
213
|
+
"something the agent should have done proactively but didn't "
|
|
214
|
+
"('screenshot?', 'tests?', 'link?', 'diff?'). "
|
|
215
|
+
"'confusion': the user signals they don't understand the output or "
|
|
216
|
+
"state ('what does that mean?', 'why did you do X?', 'I don't get it'). "
|
|
217
|
+
"'interruption': the user cuts the agent off or redirects mid-task "
|
|
218
|
+
"('wait', 'stop', 'hold on', 'actually...', 'before you do that'). "
|
|
219
|
+
"'correction': the user tells the agent its last action/answer was "
|
|
220
|
+
"wrong ('no, not that', 'that's wrong', 'nope', 'try again'). "
|
|
221
|
+
"'frustration': terse annoyance or sarcasm ('ugh', 'seriously?', "
|
|
222
|
+
"'are you kidding', 'really?'). "
|
|
223
|
+
"'none': ordinary task turn with no friction signal -- a substantive "
|
|
224
|
+
"instruction, a plain question, an acknowledgement. USE THIS FOR "
|
|
225
|
+
"THE MAJORITY OF MESSAGES."
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
rationale: str = Field(
|
|
229
|
+
...,
|
|
230
|
+
min_length=1,
|
|
231
|
+
max_length=200,
|
|
232
|
+
description=(
|
|
233
|
+
"One short sentence (<=200 chars) naming the specific phrase or "
|
|
234
|
+
"structural cue that triggered the label. Use an empty-ish "
|
|
235
|
+
"placeholder like 'ordinary instruction' when label='none'."
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
confidence: float = Field(
|
|
239
|
+
...,
|
|
240
|
+
ge=0.0,
|
|
241
|
+
le=1.0,
|
|
242
|
+
description=(
|
|
243
|
+
"Classifier self-confidence 0.0-1.0. Use <0.5 when the message is "
|
|
244
|
+
"genuinely ambiguous between 'none' and a friction label."
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class PerceivedError(BaseModel):
|
|
250
|
+
"""One user-perceived agent error, keyed on the USER turn where it surfaces.
|
|
251
|
+
|
|
252
|
+
Implements LangSmith's published "Perceived Error" definition —
|
|
253
|
+
conversations where the agent "made a mistake, misunderstood a request,
|
|
254
|
+
or took the interaction in the wrong direction", judged from
|
|
255
|
+
USER-VISIBLE evidence only, never objective correctness. Explicit
|
|
256
|
+
signals (correction / repeated_request / rejected_action) come from the
|
|
257
|
+
user's own words; inferred signals (contradictory_response /
|
|
258
|
+
acknowledged_mistake / persistent_misunderstanding / unresolved_outcome)
|
|
259
|
+
come from the conversation shape.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
model_config = ConfigDict(extra="forbid")
|
|
263
|
+
|
|
264
|
+
turn_uuid: str = Field(
|
|
265
|
+
...,
|
|
266
|
+
min_length=1,
|
|
267
|
+
max_length=64,
|
|
268
|
+
description=(
|
|
269
|
+
"UUID of the USER turn where the perception of error surfaces "
|
|
270
|
+
"(the correction, the repeat, the rejection, or the user-visible "
|
|
271
|
+
"cue an inferred signal rests on). Pull from the ``[uuid=...]`` "
|
|
272
|
+
"headers in the bound transcript -- copy verbatim, do NOT invent "
|
|
273
|
+
"or paraphrase. For inferred signals with no single user cue "
|
|
274
|
+
"turn, use the user turn closest AFTER the agent's error."
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
signal: Literal[
|
|
278
|
+
"correction",
|
|
279
|
+
"repeated_request",
|
|
280
|
+
"rejected_action",
|
|
281
|
+
"contradictory_response",
|
|
282
|
+
"acknowledged_mistake",
|
|
283
|
+
"persistent_misunderstanding",
|
|
284
|
+
"unresolved_outcome",
|
|
285
|
+
] = Field(
|
|
286
|
+
...,
|
|
287
|
+
description=(
|
|
288
|
+
"The evidence category. EXPLICIT (the user says so): "
|
|
289
|
+
"'correction': the user tells the agent its answer/action was "
|
|
290
|
+
"wrong or restates what they actually meant. "
|
|
291
|
+
"'repeated_request': the user asks for the same thing again "
|
|
292
|
+
"because the first response missed it. "
|
|
293
|
+
"'rejected_action': the user declines, reverts, or interrupts "
|
|
294
|
+
"an action the agent took or proposed. "
|
|
295
|
+
"INFERRED (the conversation shows it): "
|
|
296
|
+
"'contradictory_response': the agent contradicts its own "
|
|
297
|
+
"earlier statement and the user is exposed to both. "
|
|
298
|
+
"'acknowledged_mistake': the agent itself admits an error "
|
|
299
|
+
"('you're right, I misread...') in front of the user. "
|
|
300
|
+
"'persistent_misunderstanding': the agent keeps answering a "
|
|
301
|
+
"different question than the one the user is asking across "
|
|
302
|
+
"multiple turns. "
|
|
303
|
+
"'unresolved_outcome': the session ends with the user's problem "
|
|
304
|
+
"visibly unsolved after the agent's attempts."
|
|
305
|
+
),
|
|
306
|
+
)
|
|
307
|
+
severity: Literal["minor", "moderate", "major"] = Field(
|
|
308
|
+
...,
|
|
309
|
+
description=(
|
|
310
|
+
"How much the perceived error cost the user. "
|
|
311
|
+
"'minor': a one-turn nudge; the user corrected and work resumed "
|
|
312
|
+
"immediately (the common case in coding sessions). "
|
|
313
|
+
"'moderate': the error cost visible rework, repeated exchanges, "
|
|
314
|
+
"or an undone/redone action. "
|
|
315
|
+
"'major': the error derailed the session -- wrong direction "
|
|
316
|
+
"sustained across many turns, destructive action rejected too "
|
|
317
|
+
"late, or the user gave up."
|
|
318
|
+
),
|
|
319
|
+
)
|
|
320
|
+
evidence: str = Field(
|
|
321
|
+
...,
|
|
322
|
+
min_length=1,
|
|
323
|
+
max_length=280,
|
|
324
|
+
description=(
|
|
325
|
+
"The user's own words that show the perception -- a short "
|
|
326
|
+
"verbatim quote or close paraphrase of the user turn(s), "
|
|
327
|
+
"<= 280 chars. For inferred signals, quote the user-visible "
|
|
328
|
+
"text the inference rests on (e.g. the agent's own admission)."
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
agent_error_summary: str = Field(
|
|
332
|
+
...,
|
|
333
|
+
min_length=1,
|
|
334
|
+
max_length=280,
|
|
335
|
+
description=(
|
|
336
|
+
"One sentence describing what the agent got wrong FROM THE "
|
|
337
|
+
"USER'S PERSPECTIVE, <= 280 chars. Example: 'The agent renamed "
|
|
338
|
+
"the CLI flag the user had asked to keep.' Describe the "
|
|
339
|
+
"perceived mistake, not whether it was objectively wrong."
|
|
340
|
+
),
|
|
341
|
+
)
|
|
342
|
+
confidence: float = Field(
|
|
343
|
+
...,
|
|
344
|
+
ge=0.0,
|
|
345
|
+
le=1.0,
|
|
346
|
+
description=(
|
|
347
|
+
"Classifier self-confidence 0.0-1.0 that the USER perceived "
|
|
348
|
+
"an error (not that an error objectively occurred). Use <0.5 "
|
|
349
|
+
"when the cue could equally be the user changing their mind or "
|
|
350
|
+
"ordinary iterative steering."
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class PerceivedErrorsResult(BaseModel):
|
|
356
|
+
"""The model's response: zero or more perceived errors for one session.
|
|
357
|
+
|
|
358
|
+
An empty list is valid and common — it means a CLEAN session (the user
|
|
359
|
+
never perceived an error). The pipeline writes ZERO rows for that
|
|
360
|
+
session; the session is checkpointed so it never re-bills.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
model_config = ConfigDict(extra="forbid")
|
|
364
|
+
|
|
365
|
+
errors: list[PerceivedError] = Field(
|
|
366
|
+
default_factory=list,
|
|
367
|
+
description=(
|
|
368
|
+
"Zero or more user-perceived agent errors. Each entry names "
|
|
369
|
+
"the user turn where perception surfaces, the evidence "
|
|
370
|
+
"category, severity, the user's words, a one-sentence summary "
|
|
371
|
+
"of the perceived mistake, and a confidence score. Report "
|
|
372
|
+
"only errors the USER visibly perceived -- an empty array is "
|
|
373
|
+
"the correct answer for a clean session."
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
__all__ = [
|
|
379
|
+
"ConflictPair",
|
|
380
|
+
"ConflictsResult",
|
|
381
|
+
"PerceivedError",
|
|
382
|
+
"PerceivedErrorsResult",
|
|
383
|
+
"SessionClassification",
|
|
384
|
+
"UserFrictionSignal",
|
|
385
|
+
]
|