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,448 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Stance-conflict detection pipeline over the materialized corpus.
4
+
5
+ Transcript text is sent to a third-party model provider on this path: a
6
+ session's full rendered body, tool arguments and tool results included,
7
+ leaves this machine.
8
+
9
+ Reads complete session transcripts WITH uuid headers (``include_uuids=True``
10
+ puts each text step's uuid — the FIRST source_uuid, per CONTRACT-V2 — in
11
+ the header as ``[uuid=... role ts]``) and emits one row per detected
12
+ conflict pair, keyed on ``(turn_a_uuid, turn_b_uuid)``. Sessions with no
13
+ conflicts produce ZERO rows. Refused sessions ALSO produce zero pair rows
14
+ (a sentinel pair would poison the uuid-keyed conversation-time joins);
15
+ the refusal instead lands as a durable audit row in the
16
+ ``analytics/refusals`` sidecar (``{pipeline, unit_id, reason,
17
+ refused_at}``) so refusal-skips are queryable, not just log lines.
18
+
19
+ Only ``interactive`` sessions are sent (``session_kind``, the rule atif-duck's
20
+ ``session_outcomes.kind`` applies): a turn audit has no human turn and a
21
+ one-shot job has one, and a stance conflict between the agent and the user
22
+ needs the user to answer the agent at least once. Both kinds are
23
+ checkpointed as skipped without a call; growth re-admits.
24
+
25
+ The uuid-validity guard validates the model's returned ``turn_*_uuid``
26
+ values against the session's ``edges.jsonl`` uuid set before they land in
27
+ the parquet — a pair naming a uuid that is not a real raw-record uuid can
28
+ never be recovered on a conversation-time axis, so it is dropped at the
29
+ source. The guard FAILS CLOSED: when the uuid universe cannot be read at
30
+ all, every pair from that session is dropped rather than admitted
31
+ unverified, because a wrong uuid in the parquet is indistinguishable from
32
+ a right one to every downstream view.
33
+
34
+ Output schema (per parquet shard)::
35
+
36
+ session_id VARCHAR
37
+ turn_a_uuid VARCHAR (NOT NULL)
38
+ turn_b_uuid VARCHAR (NOT NULL)
39
+ conflict_kind VARCHAR (enum: disagreement|correction|reversal|impasse)
40
+ severity VARCHAR (enum: low|medium|high)
41
+ agent_position VARCHAR
42
+ user_position VARCHAR
43
+ confidence DOUBLE
44
+ detected_at TIMESTAMP
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import asyncio
50
+ import time
51
+ from datetime import UTC, datetime
52
+ from typing import TYPE_CHECKING, Any
53
+
54
+ import polars as pl
55
+ from loguru import logger
56
+
57
+ from atif_analytics.application.prompts import CONFLICTS_SYSTEM_PROMPT
58
+ from atif_analytics.application.use_cases._shared import (
59
+ RunBudget,
60
+ build_cache,
61
+ build_checkpoint,
62
+ build_provider,
63
+ build_retry_queue,
64
+ gather_under_budget,
65
+ pipeline_usage,
66
+ write_chunk_size,
67
+ write_refusal_audit,
68
+ )
69
+ from atif_analytics.domain.costs import estimate_cost_tokens, tokens_for_chars
70
+ from atif_analytics.domain.models import ConflictsResult
71
+ from atif_analytics.infrastructure.corpus_reader import CorpusReader
72
+ from atif_analytics.infrastructure.sqlite_state import checkpointer
73
+
74
+ if TYPE_CHECKING:
75
+ from atif_analytics.infrastructure.parquet_cache import ParquetCache
76
+ from atif_analytics.infrastructure.settings import AnalyticsSettings
77
+ from atif_analytics.infrastructure.sqlite_state.checkpointer import SqliteCheckpoint
78
+ from atif_analytics.infrastructure.sqlite_state.retry_queue import SqliteRetryQueue
79
+ from atif_models.domain.ports import LlmStructuredProvider
80
+ from atif_models.domain.registry import ModelSpec
81
+
82
+ _PARQUET_SCHEMA: dict[str, Any] = {
83
+ "session_id": pl.Utf8,
84
+ "turn_a_uuid": pl.Utf8,
85
+ "turn_b_uuid": pl.Utf8,
86
+ "conflict_kind": pl.Utf8,
87
+ "severity": pl.Utf8,
88
+ "agent_position": pl.Utf8,
89
+ "user_position": pl.Utf8,
90
+ "confidence": pl.Float64,
91
+ "detected_at": pl.Datetime("us", "UTC"),
92
+ }
93
+
94
+ #: Dry-run OUTPUT budget per session (~400 structured-output tokens). Input
95
+ #: is MEASURED from the rendered uuid-headed transcript (chars/4), never a
96
+ #: static prior: a real transcript runs an order of magnitude past any
97
+ #: fixed guess.
98
+ _AVG_OUT_TOKENS = 400
99
+
100
+ #: The one session kind conflicts spends on.
101
+ _CONFLICT_KIND = "interactive"
102
+
103
+
104
+ def _already_done(cache: ParquetCache) -> set[str]:
105
+ """Session ids already present in the cache."""
106
+ done_df = cache.read_all(columns=["session_id"])
107
+ if done_df is not None and done_df.height > 0:
108
+ return set(done_df["session_id"].to_list())
109
+ return set()
110
+
111
+
112
+ async def _conflicts_async(
113
+ settings: AnalyticsSettings,
114
+ *,
115
+ reader: CorpusReader,
116
+ provider: LlmStructuredProvider,
117
+ since_days: int | None,
118
+ limit: int | None,
119
+ cache: ParquetCache,
120
+ checkpoint: SqliteCheckpoint,
121
+ retry: SqliteRetryQueue,
122
+ budget: RunBudget | None,
123
+ ) -> int:
124
+ """Async implementation behind :func:`detect_conflicts`."""
125
+ already = _already_done(cache)
126
+
127
+ bounds = reader.session_bounds(since_days=since_days, limit=limit)
128
+ unchanged_pending, skipped = checkpointer.filter_unchanged(
129
+ ((sid, lt, mt) for sid, (lt, mt) in bounds.items()),
130
+ pipeline="conflicts",
131
+ checkpoint_db_path=checkpoint.db_path,
132
+ )
133
+ keep = set(unchanged_pending)
134
+
135
+ retry_ids = set(retry.drain(pipeline="conflicts"))
136
+ if retry_ids:
137
+ logger.info("conflicts: draining {} retry-queue entries", len(retry_ids))
138
+ keep |= retry_ids
139
+
140
+ # Retry queue is the single re-admission gate for failed units (see
141
+ # classify): a live entry blocks dispatch until drain says it's due.
142
+ blocked = retry.blocked_units(pipeline="conflicts")
143
+ if blocked & keep:
144
+ logger.info(
145
+ "conflicts: skipping {} sessions with live retry entries (backoff/attempts cap)",
146
+ len(blocked & keep),
147
+ )
148
+ keep -= blocked
149
+
150
+ # Session ceiling applied DURING the walk, not after: bounds is
151
+ # newest-first and the cap keeps the freshest sessions, so admitting
152
+ # past it only to slice back would render transcripts (up to 800K chars
153
+ # each) for sessions that can never run this tick. On a backlogged
154
+ # corpus that is the difference between 50 rendered strings and
155
+ # thousands held at once.
156
+ max_sessions = settings.llm_max_sessions_per_run
157
+ pending: list[tuple[str, str]] = []
158
+ empty_sids: list[str] = []
159
+ deferred = 0
160
+ for sid in bounds:
161
+ if sid in already and sid not in retry_ids:
162
+ continue
163
+ if sid not in keep:
164
+ continue
165
+ if len(pending) >= max_sessions:
166
+ deferred += 1
167
+ continue
168
+ # Kind gate before rendering (see the module docstring).
169
+ if reader.session_kind(sid) != _CONFLICT_KIND:
170
+ empty_sids.append(sid)
171
+ continue
172
+ text = reader.session_text(sid, include_uuids=True)
173
+ if not text:
174
+ # A session that renders to nothing yields no row and no LLM
175
+ # call, so without a checkpoint it re-renders every tick
176
+ # forever. Stamp it at current bounds; growth re-admits it.
177
+ empty_sids.append(sid)
178
+ continue
179
+ pending.append((sid, text))
180
+
181
+ if empty_sids:
182
+ checkpoint.mark_completed(
183
+ pipeline="conflicts",
184
+ rows=[(sid, *bounds.get(sid, (None, None))) for sid in empty_sids],
185
+ )
186
+ logger.info(
187
+ "conflicts: checkpointed {} non-interactive or empty-transcript sessions as skipped",
188
+ len(empty_sids),
189
+ )
190
+
191
+ if deferred:
192
+ logger.warning(
193
+ "conflicts: capped at {} sessions (llm_max_sessions_per_run); {} deferred",
194
+ max_sessions,
195
+ deferred,
196
+ )
197
+
198
+ if not pending:
199
+ logger.info("conflicts: no pending sessions (skipped={} via checkpoint)", skipped)
200
+ return 0
201
+ if skipped:
202
+ logger.info("conflicts: skipped {} sessions via checkpoint", skipped)
203
+
204
+ # Per-session set of real raw-record uuids for the returned-uuid guard.
205
+ # ``None`` means the edges file was unreadable — the guard then has no
206
+ # universe to check against and drops the session's pairs.
207
+ valid_uuids = {sid: reader.edges_uuids(sid) for sid, _ in pending}
208
+
209
+ chunk_size = write_chunk_size(settings.batch_size)
210
+ logger.info("conflicts: {} pending sessions", len(pending))
211
+
212
+ written_pairs = 0
213
+ processed_sessions = 0
214
+ i = 0
215
+ chunk_index = 0
216
+ total_chunks = (len(pending) + chunk_size - 1) // chunk_size
217
+ while i < len(pending):
218
+ chunk_index += 1
219
+ t0 = time.monotonic()
220
+ # Budget-checked sub-batches; ``i`` advances by what was actually
221
+ # SENT so a mid-chunk stop leaves the remainder unstamped. Conflicts
222
+ # runs on the LARGE model over full transcripts, so this is the
223
+ # stage where one unchecked gather overshoots hardest.
224
+ chunk, results = await gather_under_budget(
225
+ pending[i : i + chunk_size],
226
+ make_coro=lambda unit: provider.classify_structured(
227
+ system=CONFLICTS_SYSTEM_PROMPT,
228
+ prompt=unit[1],
229
+ schema=ConflictsResult,
230
+ ),
231
+ budget=budget,
232
+ )
233
+ if not chunk:
234
+ logger.warning(
235
+ "conflicts: cost ceiling hit (${:.2f} >= ${:.2f}) — "
236
+ "aborting with {} sessions unstarted (nothing stamped)",
237
+ budget.spent_usd() if budget is not None else 0.0,
238
+ budget.max_cost_usd if budget is not None else 0.0,
239
+ len(pending) - i,
240
+ )
241
+ break
242
+ i += len(chunk)
243
+ now = datetime.now(UTC)
244
+
245
+ rows: list[dict[str, Any]] = []
246
+ errors = 0
247
+ ok_sids: set[str] = set()
248
+ refused: list[tuple[str, str]] = []
249
+ for (sid, _), res in zip(chunk, results, strict=True):
250
+ if isinstance(res, BaseException):
251
+ from atif_models.domain.ports import RefusalError
252
+
253
+ if isinstance(res, RefusalError):
254
+ # Terminal: zero PAIR rows (a sentinel pair would poison
255
+ # the uuid-keyed conversation-time joins — see the
256
+ # refusals sidecar decision in _shared.write_refusal_audit),
257
+ # but the refusal itself lands as a durable audit row.
258
+ logger.info("conflicts: {} refused (terminal) — audit sidecar row", sid)
259
+ ok_sids.add(sid)
260
+ refused.append((sid, str(res)))
261
+ continue
262
+ errors += 1
263
+ logger.warning("conflicts: {} failed (queued for retry): {}", sid, res)
264
+ retry.enqueue(pipeline="conflicts", unit_id=sid, error=str(res))
265
+ continue
266
+ ok_sids.add(sid)
267
+ for c in res.conflicts:
268
+ turn_a = c.turn_a_uuid
269
+ turn_b = c.turn_b_uuid
270
+ if not turn_a or not turn_b or turn_a == turn_b:
271
+ logger.warning(
272
+ "conflicts: {} returned a degenerate pair "
273
+ "(turn_a={!r}, turn_b={!r}) — skipping",
274
+ sid,
275
+ turn_a,
276
+ turn_b,
277
+ )
278
+ continue
279
+ # uuid-validity guard: a pair whose turn uuids are not BOTH
280
+ # real raw-record uuids for this session can never be
281
+ # recovered on a conversation-time axis. FAIL CLOSED — an
282
+ # unreadable edges file (None) yields no universe to check
283
+ # against, and admitting the pair anyway would put an
284
+ # unverified uuid into session_conflicts, where nothing
285
+ # downstream can tell it from a verified one.
286
+ session_uuids = valid_uuids.get(sid)
287
+ if session_uuids is None:
288
+ logger.warning(
289
+ "conflicts: {} has no readable uuid universe — dropping pair "
290
+ "(turn_a={!r}, turn_b={!r}); the validity guard cannot run",
291
+ sid,
292
+ turn_a,
293
+ turn_b,
294
+ )
295
+ continue
296
+ if turn_a not in session_uuids or turn_b not in session_uuids:
297
+ logger.warning(
298
+ "conflicts: {} returned a pair with non-record uuids "
299
+ "(turn_a={!r} valid={}, turn_b={!r} valid={}) — skipping",
300
+ sid,
301
+ turn_a,
302
+ turn_a in session_uuids,
303
+ turn_b,
304
+ turn_b in session_uuids,
305
+ )
306
+ continue
307
+ rows.append(
308
+ {
309
+ "session_id": sid,
310
+ "turn_a_uuid": turn_a,
311
+ "turn_b_uuid": turn_b,
312
+ "conflict_kind": c.conflict_kind,
313
+ "severity": c.severity,
314
+ "agent_position": c.agent_position,
315
+ "user_position": c.user_position,
316
+ "confidence": float(c.confidence),
317
+ "detected_at": now,
318
+ }
319
+ )
320
+ if rows:
321
+ cache.write_part(pl.DataFrame(rows, schema=_PARQUET_SCHEMA))
322
+ if refused:
323
+ write_refusal_audit(
324
+ settings.layout().refusals_dir, pipeline="conflicts", refusals=refused
325
+ )
326
+ if ok_sids:
327
+ checkpoint.mark_completed(
328
+ pipeline="conflicts",
329
+ rows=[(sid, *bounds.get(sid, (None, None))) for sid in ok_sids],
330
+ )
331
+ retry.mark_done(pipeline="conflicts", unit_ids=list(ok_sids))
332
+ written_pairs += len(rows)
333
+ processed_sessions += len(ok_sids)
334
+ logger.info(
335
+ "conflicts chunk {}/{}: {} sessions, {} pairs, {} errors, {:.1f}s",
336
+ chunk_index,
337
+ total_chunks,
338
+ len(ok_sids),
339
+ len(rows),
340
+ errors,
341
+ time.monotonic() - t0,
342
+ )
343
+
344
+ logger.info(
345
+ "conflicts: processed {} sessions, wrote {} pair rows", processed_sessions, written_pairs
346
+ )
347
+ return processed_sessions
348
+
349
+
350
+ def detect_conflicts(
351
+ settings: AnalyticsSettings,
352
+ *,
353
+ since_days: int | None = None,
354
+ limit: int | None = None,
355
+ dry_run: bool = True,
356
+ reader: CorpusReader | None = None,
357
+ provider: LlmStructuredProvider | None = None,
358
+ spec: ModelSpec | None = None,
359
+ cache: ParquetCache | None = None,
360
+ checkpoint: SqliteCheckpoint | None = None,
361
+ retry: SqliteRetryQueue | None = None,
362
+ budget: RunBudget | None = None,
363
+ ) -> int | dict[str, Any]:
364
+ """Detect stance conflicts per session; return the count of sessions processed.
365
+
366
+ The returned int counts SESSIONS, not pair rows: a session that produced
367
+ two conflict pairs and one that produced zero both count as one (and
368
+ both are checkpointed so a rerun is a no-op). ``dry_run`` (the default)
369
+ returns a plan dict instead.
370
+ """
371
+ layout = settings.layout()
372
+ reader = (
373
+ reader
374
+ if reader is not None
375
+ else CorpusReader(settings.corpus_root, caps=settings.transcript_caps())
376
+ )
377
+ cache = cache if cache is not None else build_cache(layout.conflicts_dir)
378
+
379
+ if provider is None or spec is None:
380
+ provider, spec = build_provider(settings, "conflicts")
381
+
382
+ if dry_run:
383
+ # Measured estimation: input tokens from the actual rendered
384
+ # uuid-headed transcript (chars/4), capped like the real run.
385
+ already = _already_done(cache)
386
+ bounds = reader.session_bounds(since_days=since_days, limit=limit)
387
+ pending = [sid for sid in bounds if sid not in already]
388
+ max_sessions = settings.llm_max_sessions_per_run
389
+ capped: list[str] = []
390
+ skipped_kind = 0
391
+ for sid in pending:
392
+ if len(capped) >= max_sessions:
393
+ break
394
+ if reader.session_kind(sid) != _CONFLICT_KIND:
395
+ skipped_kind += 1
396
+ continue
397
+ capped.append(sid)
398
+ in_tokens = sum(
399
+ tokens_for_chars(len(text)) for text in reader.session_texts(capped, include_uuids=True)
400
+ )
401
+ out_tokens = len(capped) * _AVG_OUT_TOKENS
402
+ pricing = (spec.pricing_in or 0.0, spec.pricing_out or 0.0)
403
+ cost = estimate_cost_tokens(in_tokens, out_tokens, pricing)
404
+ logger.info(
405
+ "conflicts --dry-run: {} sessions ({} would run under the cap), "
406
+ "estimated cost ~${:.2f}",
407
+ len(pending),
408
+ len(capped),
409
+ cost,
410
+ )
411
+ return {
412
+ "pipeline": "conflicts",
413
+ "candidates": len(pending),
414
+ "skipped_not_interactive": skipped_kind,
415
+ "capped_candidates": len(capped),
416
+ "llm_calls": len(capped),
417
+ "estimated_input_tokens": in_tokens,
418
+ "estimated_output_tokens": out_tokens,
419
+ "estimated_cost_usd": round(cost, 4),
420
+ "max_sessions_per_run": max_sessions,
421
+ "max_cost_usd_per_run": settings.llm_max_cost_usd_per_run,
422
+ "model": spec.model_id,
423
+ "since_days": since_days,
424
+ "limit": limit,
425
+ "dry_run": True,
426
+ }
427
+
428
+ checkpoint = checkpoint if checkpoint is not None else build_checkpoint(layout.state_db_path)
429
+ retry = retry if retry is not None else build_retry_queue(layout.state_db_path)
430
+ if budget is not None:
431
+ budget.watch(provider, spec)
432
+ with pipeline_usage(provider, spec, "conflicts"):
433
+ return asyncio.run(
434
+ _conflicts_async(
435
+ settings,
436
+ reader=reader,
437
+ provider=provider,
438
+ since_days=since_days,
439
+ limit=limit,
440
+ cache=cache,
441
+ checkpoint=checkpoint,
442
+ retry=retry,
443
+ budget=budget,
444
+ )
445
+ )
446
+
447
+
448
+ __all__ = ["detect_conflicts"]