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,186 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Sharded parquet I/O helpers for the pipeline-append caches.
|
|
4
|
+
|
|
5
|
+
Each pipeline chunk writes ``<dir>/part-<ts_ns>.parquet`` and readers glob
|
|
6
|
+
the directory, so append cost is proportional to the chunk size rather than
|
|
7
|
+
the cache size. The legacy single-file branch is kept for tool-side
|
|
8
|
+
inspection of ad-hoc ``*.parquet`` targets (tests use it).
|
|
9
|
+
|
|
10
|
+
Public API: :func:`is_sharded_dir`, :func:`write_part`, :func:`read_all`,
|
|
11
|
+
:func:`iter_part_files`, :func:`count_rows`. (``replace_sessions``, the
|
|
12
|
+
per-session shard rewrite, left with the trajectory pipeline, its one
|
|
13
|
+
caller, on 2026-09-27.)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import polars as pl
|
|
22
|
+
from loguru import logger
|
|
23
|
+
|
|
24
|
+
#: Glob pattern for shard part files within a sharded cache directory.
|
|
25
|
+
PART_GLOB: str = "part-*.parquet"
|
|
26
|
+
|
|
27
|
+
#: Smallest byte count a readable parquet file can have. The format's own
|
|
28
|
+
#: framing is ``PAR1`` + footer + 4-byte footer length + ``PAR1``, so anything
|
|
29
|
+
#: below this is a file a crashed or half-flushed writer left behind, not a
|
|
30
|
+
#: parquet with zero rows. Reading one raises inside polars, so the size check
|
|
31
|
+
#: is what keeps a torn artifact from failing a pipeline that could skip it.
|
|
32
|
+
MIN_PARQUET_BYTES: int = 16
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_sharded_dir(path: Path) -> bool:
|
|
36
|
+
"""Return True iff ``path`` is (or should be) treated as a sharded cache directory.
|
|
37
|
+
|
|
38
|
+
Two cases qualify: ``path`` exists and is a directory, or ``path`` does
|
|
39
|
+
not exist yet and has no ``.parquet`` suffix (new caches default to the
|
|
40
|
+
directory layout; a missing ``x.parquet`` stays legacy single-file).
|
|
41
|
+
"""
|
|
42
|
+
if path.exists():
|
|
43
|
+
return path.is_dir()
|
|
44
|
+
return path.suffix != ".parquet"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_whole_parquet(part: Path) -> bool:
|
|
48
|
+
"""False for a file too small to carry parquet framing; warns when it drops one.
|
|
49
|
+
|
|
50
|
+
A shard at or below :data:`MIN_PARQUET_BYTES` is what a crashed or
|
|
51
|
+
half-flushed writer leaves behind, and :func:`write_part` drops new
|
|
52
|
+
shards straight into the directory readers glob, so the torn file is
|
|
53
|
+
live from the moment it appears.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
size = part.stat().st_size
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
logger.warning("parquet cache: cannot stat {} ({}); skipping", part, exc)
|
|
59
|
+
return False
|
|
60
|
+
if size <= MIN_PARQUET_BYTES:
|
|
61
|
+
logger.warning(
|
|
62
|
+
"parquet cache: skipping torn shard {} ({} bytes, minimum {})",
|
|
63
|
+
part,
|
|
64
|
+
size,
|
|
65
|
+
MIN_PARQUET_BYTES,
|
|
66
|
+
)
|
|
67
|
+
return False
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def iter_part_files(target: Path) -> list[Path]:
|
|
72
|
+
"""Return a sorted list of whole parquet files backing ``target``.
|
|
73
|
+
|
|
74
|
+
For a sharded directory: every ``part-*.parquet`` under it, sorted by
|
|
75
|
+
name (timestamp-keyed, so the order is also chronological). For a
|
|
76
|
+
legacy single-file path: ``[target]`` if it exists, else ``[]``.
|
|
77
|
+
|
|
78
|
+
Torn files are dropped here rather than at each call site, because this
|
|
79
|
+
is the one choke point every reader shares — :func:`read_all` and
|
|
80
|
+
:func:`count_rows` — so
|
|
81
|
+
:data:`MIN_PARQUET_BYTES` governs the sharded read path and not only the
|
|
82
|
+
legacy rewrite in :func:`write_part`.
|
|
83
|
+
"""
|
|
84
|
+
if not target.exists():
|
|
85
|
+
return []
|
|
86
|
+
candidates = sorted(target.glob(PART_GLOB)) if target.is_dir() else [target]
|
|
87
|
+
return [part for part in candidates if _is_whole_parquet(part)]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def write_part(target: Path, df: pl.DataFrame) -> Path:
|
|
91
|
+
"""Write ``df`` as a new shard (or rewrite the legacy single file).
|
|
92
|
+
|
|
93
|
+
Sharded branch: drop a brand-new ``part-<ns>.parquet`` into the
|
|
94
|
+
directory — no read-then-rewrite. Legacy branch: concat onto the
|
|
95
|
+
existing file and rewrite (historical behavior).
|
|
96
|
+
"""
|
|
97
|
+
if is_sharded_dir(target):
|
|
98
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
# Nanosecond timestamps are sortable, monotonic on Linux, and avoid
|
|
100
|
+
# filename collisions when two part-writes land in the same
|
|
101
|
+
# millisecond under concurrency.
|
|
102
|
+
part_path = target / f"part-{time.time_ns()}.parquet"
|
|
103
|
+
df.write_parquet(part_path)
|
|
104
|
+
return part_path
|
|
105
|
+
|
|
106
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
if target.exists() and target.stat().st_size > MIN_PARQUET_BYTES:
|
|
108
|
+
existing = pl.read_parquet(target)
|
|
109
|
+
df = pl.concat([existing, df], how="diagonal_relaxed")
|
|
110
|
+
df.write_parquet(target)
|
|
111
|
+
return target
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def read_all(
|
|
115
|
+
target: Path,
|
|
116
|
+
*,
|
|
117
|
+
columns: list[str] | None = None,
|
|
118
|
+
) -> pl.DataFrame | None:
|
|
119
|
+
"""Return the union of all part files (or the legacy single file).
|
|
120
|
+
|
|
121
|
+
Returns ``None`` when the cache is empty or missing. ``columns``
|
|
122
|
+
projects the read down via parquet column pushdown — callers that only
|
|
123
|
+
need a key column (the anti-join sets) never decode the wide text
|
|
124
|
+
columns.
|
|
125
|
+
"""
|
|
126
|
+
parts = iter_part_files(target)
|
|
127
|
+
if not parts:
|
|
128
|
+
return None
|
|
129
|
+
return pl.read_parquet([str(p) for p in parts], columns=columns)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def count_rows(target: Path) -> int:
|
|
133
|
+
"""Total row count across every part file (parquet footers only)."""
|
|
134
|
+
parts = iter_part_files(target)
|
|
135
|
+
if not parts:
|
|
136
|
+
return 0
|
|
137
|
+
import pyarrow.parquet as pq
|
|
138
|
+
|
|
139
|
+
total = 0
|
|
140
|
+
for p in parts:
|
|
141
|
+
total += int(pq.ParquetFile(str(p)).metadata.num_rows)
|
|
142
|
+
return total
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class ParquetCache:
|
|
146
|
+
"""The cache port over one sharded-parquet target.
|
|
147
|
+
|
|
148
|
+
Binds the target in the constructor; methods delegate to the
|
|
149
|
+
module-level functions so tests can monkeypatch them.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(self, target: Path) -> None:
|
|
153
|
+
self._target = target
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def target(self) -> Path:
|
|
157
|
+
"""The bound cache directory / legacy file path."""
|
|
158
|
+
return self._target
|
|
159
|
+
|
|
160
|
+
def write_part(self, df: pl.DataFrame) -> Path:
|
|
161
|
+
"""Append ``df`` as a new shard; return the written path."""
|
|
162
|
+
return write_part(self._target, df)
|
|
163
|
+
|
|
164
|
+
def read_all(self, *, columns: list[str] | None = None) -> pl.DataFrame | None:
|
|
165
|
+
"""Union of all parts (``None`` when empty)."""
|
|
166
|
+
return read_all(self._target, columns=columns)
|
|
167
|
+
|
|
168
|
+
def count_rows(self) -> int:
|
|
169
|
+
"""Total row count (footer reads only)."""
|
|
170
|
+
return count_rows(self._target)
|
|
171
|
+
|
|
172
|
+
def iter_part_files(self) -> list[Path]:
|
|
173
|
+
"""Sorted list of backing parquet files."""
|
|
174
|
+
return iter_part_files(self._target)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
__all__ = [
|
|
178
|
+
"MIN_PARQUET_BYTES",
|
|
179
|
+
"PART_GLOB",
|
|
180
|
+
"ParquetCache",
|
|
181
|
+
"count_rows",
|
|
182
|
+
"is_sharded_dir",
|
|
183
|
+
"iter_part_files",
|
|
184
|
+
"read_all",
|
|
185
|
+
"write_part",
|
|
186
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Env-driven settings for the analytics pipelines.
|
|
4
|
+
|
|
5
|
+
Pydantic v2 ``BaseSettings`` under the workspace ``ATIF_SQL_`` prefix
|
|
6
|
+
(atif-corpus / atif-models precedent). Carries the corpus root and the
|
|
7
|
+
per-pipeline knobs (friction char cutoff, batch size, budget ceilings,
|
|
8
|
+
transcript caps).
|
|
9
|
+
|
|
10
|
+
Composes :class:`atif_models.infrastructure.settings.LlmSettings` for the
|
|
11
|
+
LLM family/size/region/concurrency selection rather than duplicating those
|
|
12
|
+
fields.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from pydantic import Field
|
|
22
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
23
|
+
|
|
24
|
+
from atif_analytics.domain.config import TranscriptCaps
|
|
25
|
+
from atif_analytics.domain.layout import AnalyticsLayout
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from atif_models.infrastructure.settings import LlmSettings
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _default_corpus_root() -> Path:
|
|
32
|
+
"""Mirror atif-corpus's default: ``~/.atif-sql/corpus/<slug of source root>``.
|
|
33
|
+
|
|
34
|
+
Re-implements the slug locally (sha256 of the resolved source root,
|
|
35
|
+
``default`` for ``~/.claude``) because atif-analytics may not import
|
|
36
|
+
atif-corpus (independence contract). The parity test pins both against
|
|
37
|
+
the same expected strings.
|
|
38
|
+
"""
|
|
39
|
+
import hashlib
|
|
40
|
+
import re
|
|
41
|
+
|
|
42
|
+
env_source = os.environ.get("ATIF_SQL_SOURCE_ROOT")
|
|
43
|
+
if env_source is not None:
|
|
44
|
+
source_root = Path(env_source)
|
|
45
|
+
else:
|
|
46
|
+
config_root = Path(os.environ.get("CLAUDE_CONFIG_DIR", "~/.claude")).expanduser()
|
|
47
|
+
source_root = config_root / "projects"
|
|
48
|
+
resolved = source_root.expanduser().resolve()
|
|
49
|
+
# corpus_slug parity: a bare ~/.claude source maps to the reserved
|
|
50
|
+
# "default" key; everything else is <sanitized-dirname>-<8-hex sha256>.
|
|
51
|
+
if resolved == Path("~/.claude").expanduser().resolve():
|
|
52
|
+
slug = "default"
|
|
53
|
+
else:
|
|
54
|
+
name = re.sub(r"[^a-z0-9]+", "-", resolved.name.lower()).strip("-")[:32]
|
|
55
|
+
digest = hashlib.sha256(str(resolved).encode("utf-8")).hexdigest()[:8]
|
|
56
|
+
slug = f"{name}-{digest}" if name else digest
|
|
57
|
+
return Path.home() / ".atif-sql" / "corpus" / slug
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AnalyticsSettings(BaseSettings):
|
|
61
|
+
"""Env-driven configuration for the analytics pipelines."""
|
|
62
|
+
|
|
63
|
+
model_config = SettingsConfigDict(
|
|
64
|
+
env_prefix="ATIF_SQL_",
|
|
65
|
+
env_file=".env",
|
|
66
|
+
extra="ignore",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
#: Materialized corpus root (the directory containing ``sessions/``).
|
|
70
|
+
corpus_root: Path = Field(default_factory=_default_corpus_root)
|
|
71
|
+
|
|
72
|
+
# --- LLM pipeline knobs ---
|
|
73
|
+
#: Char cutoff for friction candidates: longer user turns are almost
|
|
74
|
+
#: always genuine instructions.
|
|
75
|
+
friction_max_chars: int = 300
|
|
76
|
+
#: Hard per-run session ceiling for each LLM pipeline's candidate list
|
|
77
|
+
#: (newest-first, so fresh sessions win). The unattended nightly cron
|
|
78
|
+
#: relies on this: a backlogged corpus must never turn one tick into an
|
|
79
|
+
#: unbounded spend. Env: ``ATIF_SQL_LLM_MAX_SESSIONS_PER_RUN``.
|
|
80
|
+
llm_max_sessions_per_run: int = 50
|
|
81
|
+
#: Hard per-run dollar ceiling across ALL LLM pipelines, checked against
|
|
82
|
+
#: the UsageAccumulator's running actuals between stages — when crossed,
|
|
83
|
+
#: the remaining LLM stages are aborted cleanly (nothing stamped for
|
|
84
|
+
#: unstarted sessions). Env: ``ATIF_SQL_LLM_MAX_COST_USD_PER_RUN``.
|
|
85
|
+
llm_max_cost_usd_per_run: float = 25.0
|
|
86
|
+
#: Write chunking: rows land every ``max(batch_size * 4, 256)`` units.
|
|
87
|
+
batch_size: int = 96
|
|
88
|
+
#: Transcript caps: 800K per session, 50K per tool_result.
|
|
89
|
+
session_text_total_max_chars: int = 800_000
|
|
90
|
+
session_text_tool_result_max_chars: int = 50_000
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
# Derivations
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def layout(self) -> AnalyticsLayout:
|
|
97
|
+
"""The analytics artifact layout under :attr:`corpus_root`."""
|
|
98
|
+
return AnalyticsLayout(corpus_root=self.corpus_root)
|
|
99
|
+
|
|
100
|
+
def llm(self) -> LlmSettings:
|
|
101
|
+
"""The atif-models LLM settings (family/sizes/region/concurrency)."""
|
|
102
|
+
from atif_models.infrastructure.settings import LlmSettings
|
|
103
|
+
|
|
104
|
+
return LlmSettings()
|
|
105
|
+
|
|
106
|
+
def transcript_caps(self) -> TranscriptCaps:
|
|
107
|
+
"""Project the transcript char caps."""
|
|
108
|
+
return TranscriptCaps(
|
|
109
|
+
session_text_total_max_chars=self.session_text_total_max_chars,
|
|
110
|
+
session_text_tool_result_max_chars=self.session_text_tool_result_max_chars,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = ["AnalyticsSettings"]
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Per-(session_id, pipeline) checkpoint backed by a SQLite WAL file.
|
|
4
|
+
|
|
5
|
+
Tracks when each LLM pipeline last processed each session so re-runs skip
|
|
6
|
+
sessions whose transcripts have not advanced. One row per
|
|
7
|
+
``(session_id, pipeline)``; UPSERT via ``INSERT ... ON CONFLICT DO UPDATE``.
|
|
8
|
+
|
|
9
|
+
Schema::
|
|
10
|
+
|
|
11
|
+
CREATE TABLE session_checkpoint (
|
|
12
|
+
session_id TEXT NOT NULL,
|
|
13
|
+
pipeline TEXT NOT NULL,
|
|
14
|
+
last_ts_processed TEXT,
|
|
15
|
+
last_mtime_processed TEXT,
|
|
16
|
+
completed_at TEXT NOT NULL,
|
|
17
|
+
PRIMARY KEY (session_id, pipeline)
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
All timestamps are stored as ISO-8601 UTC strings; they sort
|
|
21
|
+
lexicographically. WAL journal mode lets multiple readers run concurrently
|
|
22
|
+
with one writer; ``busy_timeout`` absorbs transient writer contention.
|
|
23
|
+
The file lives at ``<corpus_root>/analytics/state.db`` — corpus-scoped, so
|
|
24
|
+
one corpus's completions can never skip another's re-scoring.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import sqlite3
|
|
30
|
+
import threading
|
|
31
|
+
from collections.abc import Iterable
|
|
32
|
+
from datetime import UTC, datetime
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
#: The checkpointed LLM pipelines. ``trajectory`` left on 2026-09-27; its
|
|
36
|
+
#: rows stay in an older ``state.db`` and nothing reads them.
|
|
37
|
+
PIPELINE_NAMES: tuple[str, ...] = (
|
|
38
|
+
"classify",
|
|
39
|
+
"conflicts",
|
|
40
|
+
"user_friction",
|
|
41
|
+
"perceived",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_CREATE_TABLES_SQL = """
|
|
45
|
+
CREATE TABLE IF NOT EXISTS session_checkpoint (
|
|
46
|
+
session_id TEXT NOT NULL,
|
|
47
|
+
pipeline TEXT NOT NULL,
|
|
48
|
+
last_ts_processed TEXT,
|
|
49
|
+
last_mtime_processed TEXT,
|
|
50
|
+
completed_at TEXT NOT NULL,
|
|
51
|
+
PRIMARY KEY (session_id, pipeline)
|
|
52
|
+
);
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
# Starvation counter: one row per budget-skipped stage per run. A stage
|
|
56
|
+
# that actually runs clears its rows, so the row count IS the consecutive
|
|
57
|
+
# budget-skip streak (run_analyze escalates to ERROR at >= 3).
|
|
58
|
+
_CREATE_BUDGET_SKIPS_SQL = """
|
|
59
|
+
CREATE TABLE IF NOT EXISTS budget_skips (
|
|
60
|
+
pipeline TEXT NOT NULL,
|
|
61
|
+
run_at TEXT NOT NULL
|
|
62
|
+
);
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
# Process-local set of paths whose schema has already been bootstrapped this
|
|
66
|
+
# process. Skipping the redundant ``CREATE TABLE IF NOT EXISTS`` on every
|
|
67
|
+
# ``connect`` call avoids racing the writer lock when concurrent threads
|
|
68
|
+
# open the same file: on a cold start the WAL transition and the CREATE
|
|
69
|
+
# both take the writer lock, and racing them raises "database is locked".
|
|
70
|
+
_SCHEMA_BOOTSTRAPPED: set[str] = set()
|
|
71
|
+
_SCHEMA_BOOTSTRAP_LOCK = threading.Lock()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def to_iso(dt: datetime | None) -> str | None:
|
|
75
|
+
"""ISO-8601 UTC string, or None."""
|
|
76
|
+
if dt is None:
|
|
77
|
+
return None
|
|
78
|
+
return dt.astimezone(UTC).isoformat()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _from_iso(s: str | None) -> datetime | None:
|
|
82
|
+
"""Parse an ISO-8601 string back to a tz-aware UTC datetime."""
|
|
83
|
+
if s is None:
|
|
84
|
+
return None
|
|
85
|
+
dt = datetime.fromisoformat(s)
|
|
86
|
+
if dt.tzinfo is None:
|
|
87
|
+
dt = dt.replace(tzinfo=UTC)
|
|
88
|
+
return dt.astimezone(UTC)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def connect(path: Path) -> sqlite3.Connection:
|
|
92
|
+
"""Open the SQLite checkpoint DB and ensure tables exist.
|
|
93
|
+
|
|
94
|
+
Per-connection PRAGMAs run every open; the file-level WAL/synchronous
|
|
95
|
+
setup + DDL run once per (process, path) under a lock, because the WAL
|
|
96
|
+
transition and ``CREATE TABLE`` both grab the writer lock and racing
|
|
97
|
+
them on cold start raises ``database is locked``.
|
|
98
|
+
"""
|
|
99
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
con = sqlite3.connect(str(path), isolation_level=None, timeout=30.0)
|
|
101
|
+
con.execute("PRAGMA busy_timeout=10000")
|
|
102
|
+
con.execute("PRAGMA foreign_keys=ON")
|
|
103
|
+
key = str(path.resolve())
|
|
104
|
+
with _SCHEMA_BOOTSTRAP_LOCK:
|
|
105
|
+
if key not in _SCHEMA_BOOTSTRAPPED:
|
|
106
|
+
con.execute("PRAGMA journal_mode=WAL")
|
|
107
|
+
con.execute("PRAGMA synchronous=NORMAL")
|
|
108
|
+
con.execute(_CREATE_TABLES_SQL)
|
|
109
|
+
con.execute(_CREATE_BUDGET_SKIPS_SQL)
|
|
110
|
+
_SCHEMA_BOOTSTRAPPED.add(key)
|
|
111
|
+
# Writes wrap in implicit BEGIN/COMMIT from here on.
|
|
112
|
+
con.isolation_level = "DEFERRED"
|
|
113
|
+
return con
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load_as_map(db_path: Path, pipeline: str) -> dict[str, tuple[datetime | None, datetime | None]]:
|
|
117
|
+
"""Return ``{session_id: (last_ts, last_mtime)}`` for one pipeline.
|
|
118
|
+
|
|
119
|
+
Empty dict when the DB doesn't exist yet or the pipeline has no rows.
|
|
120
|
+
"""
|
|
121
|
+
if not db_path.exists():
|
|
122
|
+
return {}
|
|
123
|
+
con = connect(db_path)
|
|
124
|
+
try:
|
|
125
|
+
rows = con.execute(
|
|
126
|
+
"SELECT session_id, last_ts_processed, last_mtime_processed "
|
|
127
|
+
"FROM session_checkpoint WHERE pipeline = ?",
|
|
128
|
+
[pipeline],
|
|
129
|
+
).fetchall()
|
|
130
|
+
finally:
|
|
131
|
+
con.close()
|
|
132
|
+
return {
|
|
133
|
+
str(sid): (_from_iso(last_ts), _from_iso(last_mtime)) for sid, last_ts, last_mtime in rows
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def filter_unchanged(
|
|
138
|
+
candidates: Iterable[tuple[str, datetime | None, datetime | None]],
|
|
139
|
+
*,
|
|
140
|
+
pipeline: str,
|
|
141
|
+
checkpoint_db_path: Path,
|
|
142
|
+
) -> tuple[list[str], int]:
|
|
143
|
+
"""Drop sessions whose ``(last_ts, last_mtime)`` has not advanced.
|
|
144
|
+
|
|
145
|
+
``candidates`` is an iterable of ``(session_id, current_last_ts,
|
|
146
|
+
current_last_mtime)``. Returns ``(pending_session_ids, skipped_count)``.
|
|
147
|
+
|
|
148
|
+
A session is skipped iff a checkpoint row exists for ``pipeline`` AND
|
|
149
|
+
both ``current_last_ts <= ckpt.last_ts`` AND ``current_last_mtime <=
|
|
150
|
+
ckpt.last_mtime``. Either bound moving forward invalidates the skip.
|
|
151
|
+
"""
|
|
152
|
+
ckpt = load_as_map(checkpoint_db_path, pipeline)
|
|
153
|
+
pending: list[str] = []
|
|
154
|
+
skipped = 0
|
|
155
|
+
for sid, cur_ts, cur_mtime in candidates:
|
|
156
|
+
prev = ckpt.get(sid)
|
|
157
|
+
if prev is None:
|
|
158
|
+
pending.append(sid)
|
|
159
|
+
continue
|
|
160
|
+
prev_ts, prev_mtime = prev
|
|
161
|
+
if _stale_or_equal(cur_ts, prev_ts) and _stale_or_equal(cur_mtime, prev_mtime):
|
|
162
|
+
skipped += 1
|
|
163
|
+
continue
|
|
164
|
+
pending.append(sid)
|
|
165
|
+
return pending, skipped
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _stale_or_equal(cur: datetime | None, prev: datetime | None) -> bool:
|
|
169
|
+
"""True iff both are present and ``cur`` has not advanced past ``prev``.
|
|
170
|
+
|
|
171
|
+
None on either side returns False (treat as advanced).
|
|
172
|
+
"""
|
|
173
|
+
if cur is None or prev is None:
|
|
174
|
+
return False
|
|
175
|
+
cur_aware = cur.astimezone(UTC) if cur.tzinfo else cur.replace(tzinfo=UTC)
|
|
176
|
+
prev_aware = prev.astimezone(UTC) if prev.tzinfo else prev.replace(tzinfo=UTC)
|
|
177
|
+
return cur_aware <= prev_aware
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def mark_completed(
|
|
181
|
+
db_path: Path,
|
|
182
|
+
*,
|
|
183
|
+
pipeline: str,
|
|
184
|
+
rows: Iterable[tuple[str, datetime | None, datetime | None]],
|
|
185
|
+
) -> int:
|
|
186
|
+
"""Upsert checkpoint rows for ``(session_id, pipeline)``.
|
|
187
|
+
|
|
188
|
+
Each row is ``(session_id, last_ts_processed, last_mtime_processed)``.
|
|
189
|
+
``completed_at`` is stamped with ``datetime.now(UTC)``. Returns the
|
|
190
|
+
number of upserted rows; an empty ``rows`` leaves the DB untouched.
|
|
191
|
+
"""
|
|
192
|
+
incoming = list(rows)
|
|
193
|
+
if not incoming:
|
|
194
|
+
return 0
|
|
195
|
+
now_iso = to_iso(datetime.now(UTC))
|
|
196
|
+
payload = [
|
|
197
|
+
(sid, pipeline, to_iso(last_ts), to_iso(last_mtime), now_iso)
|
|
198
|
+
for sid, last_ts, last_mtime in incoming
|
|
199
|
+
]
|
|
200
|
+
con = connect(db_path)
|
|
201
|
+
try:
|
|
202
|
+
con.executemany(
|
|
203
|
+
"INSERT INTO session_checkpoint "
|
|
204
|
+
"(session_id, pipeline, last_ts_processed, last_mtime_processed, completed_at) "
|
|
205
|
+
"VALUES (?, ?, ?, ?, ?) "
|
|
206
|
+
"ON CONFLICT(session_id, pipeline) DO UPDATE SET "
|
|
207
|
+
"last_ts_processed = excluded.last_ts_processed, "
|
|
208
|
+
"last_mtime_processed = excluded.last_mtime_processed, "
|
|
209
|
+
"completed_at = excluded.completed_at",
|
|
210
|
+
payload,
|
|
211
|
+
)
|
|
212
|
+
con.commit()
|
|
213
|
+
finally:
|
|
214
|
+
con.close()
|
|
215
|
+
return len(incoming)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def record_budget_skip(db_path: Path, pipeline: str, *, now: datetime | None = None) -> int:
|
|
219
|
+
"""Append one budget-skip row for ``pipeline``; return the streak length.
|
|
220
|
+
|
|
221
|
+
The streak is the number of consecutive runs this stage was skipped
|
|
222
|
+
with ``budget_exhausted`` — a successful run clears the rows via
|
|
223
|
+
:func:`clear_budget_skips`, so a plain count is the streak.
|
|
224
|
+
"""
|
|
225
|
+
con = connect(db_path)
|
|
226
|
+
try:
|
|
227
|
+
con.execute(
|
|
228
|
+
"INSERT INTO budget_skips (pipeline, run_at) VALUES (?, ?)",
|
|
229
|
+
[pipeline, to_iso(now or datetime.now(UTC))],
|
|
230
|
+
)
|
|
231
|
+
con.commit()
|
|
232
|
+
row = con.execute(
|
|
233
|
+
"SELECT count(*) FROM budget_skips WHERE pipeline = ?", [pipeline]
|
|
234
|
+
).fetchone()
|
|
235
|
+
finally:
|
|
236
|
+
con.close()
|
|
237
|
+
return int(row[0]) if row else 1
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def clear_budget_skips(db_path: Path, pipeline: str) -> None:
|
|
241
|
+
"""Reset ``pipeline``'s budget-skip streak (called when the stage runs)."""
|
|
242
|
+
con = connect(db_path)
|
|
243
|
+
try:
|
|
244
|
+
con.execute("DELETE FROM budget_skips WHERE pipeline = ?", [pipeline])
|
|
245
|
+
con.commit()
|
|
246
|
+
finally:
|
|
247
|
+
con.close()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def budget_skip_count(db_path: Path, pipeline: str) -> int:
|
|
251
|
+
"""Current consecutive budget-skip streak for ``pipeline`` (0 when absent)."""
|
|
252
|
+
if not db_path.exists():
|
|
253
|
+
return 0
|
|
254
|
+
con = connect(db_path)
|
|
255
|
+
try:
|
|
256
|
+
row = con.execute(
|
|
257
|
+
"SELECT count(*) FROM budget_skips WHERE pipeline = ?", [pipeline]
|
|
258
|
+
).fetchone()
|
|
259
|
+
finally:
|
|
260
|
+
con.close()
|
|
261
|
+
return int(row[0]) if row else 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def count_rows(db_path: Path) -> int:
|
|
265
|
+
"""Total number of checkpoint rows, or 0 when the DB is missing."""
|
|
266
|
+
if not db_path.exists():
|
|
267
|
+
return 0
|
|
268
|
+
con = connect(db_path)
|
|
269
|
+
try:
|
|
270
|
+
row = con.execute("SELECT count(*) FROM session_checkpoint").fetchone()
|
|
271
|
+
finally:
|
|
272
|
+
con.close()
|
|
273
|
+
return int(row[0]) if row else 0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class SqliteCheckpoint:
|
|
277
|
+
"""The checkpoint port over one ``state.db``."""
|
|
278
|
+
|
|
279
|
+
def __init__(self, db_path: Path) -> None:
|
|
280
|
+
self._db_path = db_path
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def db_path(self) -> Path:
|
|
284
|
+
"""The bound sqlite file."""
|
|
285
|
+
return self._db_path
|
|
286
|
+
|
|
287
|
+
def load_as_map(self, pipeline: str) -> dict[str, tuple[datetime | None, datetime | None]]:
|
|
288
|
+
"""``{session_id: (last_ts, last_mtime)}`` for one pipeline."""
|
|
289
|
+
return load_as_map(self._db_path, pipeline)
|
|
290
|
+
|
|
291
|
+
def filter_unchanged(
|
|
292
|
+
self,
|
|
293
|
+
candidates: Iterable[tuple[str, datetime | None, datetime | None]],
|
|
294
|
+
*,
|
|
295
|
+
pipeline: str,
|
|
296
|
+
) -> tuple[list[str], int]:
|
|
297
|
+
"""``(pending_session_ids, skipped_count)`` per the staleness rule."""
|
|
298
|
+
return filter_unchanged(candidates, pipeline=pipeline, checkpoint_db_path=self._db_path)
|
|
299
|
+
|
|
300
|
+
def mark_completed(
|
|
301
|
+
self,
|
|
302
|
+
*,
|
|
303
|
+
pipeline: str,
|
|
304
|
+
rows: Iterable[tuple[str, datetime | None, datetime | None]],
|
|
305
|
+
) -> int:
|
|
306
|
+
"""Upsert checkpoint rows; return the number upserted."""
|
|
307
|
+
return mark_completed(self._db_path, pipeline=pipeline, rows=rows)
|
|
308
|
+
|
|
309
|
+
def count_rows(self) -> int:
|
|
310
|
+
"""Total checkpoint rows (``0`` when absent)."""
|
|
311
|
+
return count_rows(self._db_path)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
__all__ = [
|
|
315
|
+
"PIPELINE_NAMES",
|
|
316
|
+
"SqliteCheckpoint",
|
|
317
|
+
"budget_skip_count",
|
|
318
|
+
"clear_budget_skips",
|
|
319
|
+
"connect",
|
|
320
|
+
"count_rows",
|
|
321
|
+
"filter_unchanged",
|
|
322
|
+
"load_as_map",
|
|
323
|
+
"mark_completed",
|
|
324
|
+
"record_budget_skip",
|
|
325
|
+
"to_iso",
|
|
326
|
+
]
|