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,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
+ ]