ragradar-core 0.1.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.
@@ -0,0 +1,25 @@
1
+ # ragradar-core is internal plumbing shared by ragradar-capture, ragradar, and
2
+ # ragradar-evaluate: the run-record dataclasses, the single SQLite store, and
3
+ # the sNrN target parser. End users normally import from ragradar_capture or
4
+ # ragradar_evaluate, both of which re-export the dataclasses.
5
+ from ragradar_core.schema import (
6
+ CacheEvent,
7
+ ChunkRecord,
8
+ RunRecord,
9
+ TokenBudget,
10
+ TokenUsage,
11
+ ToolCallRecord,
12
+ Turn,
13
+ )
14
+ from ragradar_core.targets import parse_target_id
15
+
16
+ __all__ = [
17
+ "ChunkRecord",
18
+ "TokenBudget",
19
+ "TokenUsage",
20
+ "Turn",
21
+ "CacheEvent",
22
+ "ToolCallRecord",
23
+ "RunRecord",
24
+ "parse_target_id",
25
+ ]
@@ -0,0 +1,229 @@
1
+ """Coercion of plain-Python inputs into the ragradar_core schema dataclasses.
2
+
3
+ This is the shared user-input boundary: ragradar_capture's entry points
4
+ (Capture methods, capture(), the thread-local proxies) and ragradar_evaluate's
5
+ target resolution (evaluate()/check() on a hand-built RunRecord) route
6
+ user input through these functions, so naive callers can pass primitives
7
+ — shorthand dicts, tuples, a bare int budget — without knowing the
8
+ dataclasses exist. The dataclasses (Turn, ChunkRecord, TokenBudget,
9
+ CacheEvent, TokenUsage, ToolCallRecord) remain the advanced path and
10
+ always pass through untouched; explicitly provided fields always win
11
+ over computed defaults.
12
+
13
+ Token counts are estimated with a deterministic ~4-characters-per-token
14
+ heuristic (no tokenizer dependency — ragradar-core stays stdlib-only). Pass
15
+ explicit ``tokens`` / ``token_count`` values to override.
16
+
17
+ All functions are pure and raise TypeError/KeyError on unusable input;
18
+ callers decide the failure policy (ragradar_capture swallows/logs by default
19
+ and raises in strict mode; ragradar_evaluate raises ValueError).
20
+ """
21
+
22
+ from collections.abc import Mapping
23
+
24
+ from ragradar_core.schema import (
25
+ CacheEvent,
26
+ ChunkRecord,
27
+ RunRecord,
28
+ TokenBudget,
29
+ TokenUsage,
30
+ ToolCallRecord,
31
+ Turn,
32
+ )
33
+
34
+
35
+ def estimate_tokens(text) -> int:
36
+ """Deterministic token estimate: ~4 characters per token. Pure.
37
+
38
+ Returns 0 for None/empty text, at least 1 for any non-empty text.
39
+ Used wherever a token count is derivable but not explicitly given.
40
+ """
41
+ if not text:
42
+ return 0
43
+ return max(1, round(len(text) / 4))
44
+
45
+
46
+ def coerce_turn(turn) -> Turn:
47
+ """Coerce one history turn. Pure.
48
+
49
+ Accepts: a Turn (passed through untouched); a ("role", "content")
50
+ pair; a full dict with a "role" key; or the shorthand single-entry
51
+ dict {"user": "..."} / {"assistant": "..."} (optionally with a
52
+ "tokens" entry alongside). Tokens are estimated from the content
53
+ unless explicitly provided.
54
+ """
55
+ if isinstance(turn, Turn):
56
+ return turn
57
+ if isinstance(turn, (tuple, list)):
58
+ if len(turn) != 2:
59
+ raise TypeError(f"Turn tuples must be (role, content), got {len(turn)} items: {turn!r}")
60
+ role, content = turn
61
+ return Turn(role=role, content=content, tokens=estimate_tokens(content))
62
+ if isinstance(turn, Mapping):
63
+ d = dict(turn)
64
+ if "role" in d:
65
+ content = d.get("content", "")
66
+ tokens = d["tokens"] if d.get("tokens") is not None else estimate_tokens(content)
67
+ return Turn(role=d["role"], content=content, tokens=tokens)
68
+ tokens = d.pop("tokens", None)
69
+ if len(d) != 1:
70
+ raise TypeError(
71
+ "Shorthand turn dicts must have exactly one role entry, e.g. "
72
+ f'{{"user": "..."}} (plus an optional "tokens"), got: {turn!r}'
73
+ )
74
+ ((role, content),) = d.items()
75
+ if tokens is None:
76
+ tokens = estimate_tokens(content)
77
+ return Turn(role=role, content=content, tokens=tokens)
78
+ raise TypeError(f"Cannot coerce {type(turn).__name__} into a history turn: {turn!r}")
79
+
80
+
81
+ def coerce_turns(turns) -> list[Turn]:
82
+ """Coerce a sequence of history turns (see coerce_turn). Pure."""
83
+ return [coerce_turn(t) for t in turns]
84
+
85
+
86
+ def coerce_chunk(chunk, index: int) -> ChunkRecord:
87
+ """Coerce one retrieval chunk. Pure.
88
+
89
+ Accepts a ChunkRecord (passed through untouched) or a dict; "content"
90
+ is the only required key. Missing boilerplate is filled: chunk_id
91
+ defaults to "chunk_{index}", source_doc_id to "unknown", token_count
92
+ to an estimate of the content. Score/path/flag fields keep their
93
+ dataclass defaults when absent.
94
+ """
95
+ if isinstance(chunk, ChunkRecord):
96
+ return chunk
97
+ if isinstance(chunk, Mapping):
98
+ d = dict(chunk)
99
+ d.setdefault("chunk_id", f"chunk_{index}")
100
+ d.setdefault("source_doc_id", "unknown")
101
+ if d.get("token_count") is None:
102
+ d["token_count"] = estimate_tokens(d.get("content"))
103
+ return ChunkRecord(**d)
104
+ raise TypeError(f"Cannot coerce {type(chunk).__name__} into a chunk: {chunk!r}")
105
+
106
+
107
+ def coerce_chunks(chunks) -> list[ChunkRecord]:
108
+ """Coerce a sequence of retrieval chunks (see coerce_chunk). Pure."""
109
+ return [coerce_chunk(c, i) for i, c in enumerate(chunks)]
110
+
111
+
112
+ def coerce_token_budget(budget, final_prompt=None) -> TokenBudget:
113
+ """Coerce a token budget. Pure.
114
+
115
+ Accepts a TokenBudget (passed through untouched), a bare int (the
116
+ total limit), or a dict with at least "total_limit". Allocation
117
+ fields default to 0. A missing headroom is derived, in order of
118
+ preference: total_limit minus the given allocations (when any
119
+ allocation was provided), total_limit minus the estimated
120
+ final_prompt tokens (when a prompt is available), else total_limit.
121
+ Derived headroom may be negative — that is the over-budget signal.
122
+ """
123
+ if isinstance(budget, TokenBudget):
124
+ return budget
125
+ if isinstance(budget, bool) or not isinstance(budget, (int, Mapping)):
126
+ raise TypeError(f"Cannot coerce {type(budget).__name__} into a token budget: {budget!r}")
127
+ d = {"total_limit": budget} if isinstance(budget, int) else dict(budget)
128
+
129
+ alloc_keys = ("chunks_allocated", "history_allocated", "system_allocated")
130
+ alloc_given = any(d.get(k) is not None for k in alloc_keys)
131
+ for k in alloc_keys:
132
+ if d.get(k) is None:
133
+ d[k] = 0
134
+
135
+ if d.get("headroom") is None:
136
+ total = d["total_limit"]
137
+ if alloc_given:
138
+ d["headroom"] = total - sum(d[k] for k in alloc_keys)
139
+ elif final_prompt:
140
+ d["headroom"] = total - estimate_tokens(final_prompt)
141
+ else:
142
+ d["headroom"] = total
143
+ return TokenBudget(**d)
144
+
145
+
146
+ def coerce_cache_events(events) -> list[CacheEvent]:
147
+ """Coerce cache events. Pure.
148
+
149
+ Accepts a mapping of {chunk_id: hit} for the whole call, or a
150
+ sequence whose items are CacheEvents (passed through untouched),
151
+ dicts, or ("chunk_id", hit) pairs.
152
+ """
153
+ if isinstance(events, Mapping):
154
+ return [CacheEvent(chunk_id=k, hit=bool(v)) for k, v in events.items()]
155
+ out = []
156
+ for e in events:
157
+ if isinstance(e, CacheEvent):
158
+ out.append(e)
159
+ elif isinstance(e, Mapping):
160
+ out.append(CacheEvent(**e))
161
+ elif isinstance(e, (tuple, list)) and len(e) == 2:
162
+ out.append(CacheEvent(chunk_id=e[0], hit=bool(e[1])))
163
+ else:
164
+ raise TypeError(f"Cannot coerce {type(e).__name__} into a cache event: {e!r}")
165
+ return out
166
+
167
+
168
+ def coerce_token_usage(usage) -> TokenUsage:
169
+ """Coerce token usage. Pure.
170
+
171
+ Accepts a TokenUsage (passed through untouched) or a dict; a missing
172
+ total_tokens is derived as input_tokens + output_tokens.
173
+ """
174
+ if isinstance(usage, TokenUsage):
175
+ return usage
176
+ if isinstance(usage, Mapping):
177
+ d = dict(usage)
178
+ if d.get("total_tokens") is None:
179
+ d["total_tokens"] = d.get("input_tokens", 0) + d.get("output_tokens", 0)
180
+ return TokenUsage(**d)
181
+ raise TypeError(f"Cannot coerce {type(usage).__name__} into token usage: {usage!r}")
182
+
183
+
184
+ def coerce_tool_call(call) -> ToolCallRecord:
185
+ """Coerce one tool call: a ToolCallRecord (untouched) or a dict. Pure."""
186
+ if isinstance(call, ToolCallRecord):
187
+ return call
188
+ if isinstance(call, Mapping):
189
+ return ToolCallRecord(**call)
190
+ raise TypeError(f"Cannot coerce {type(call).__name__} into a tool call: {call!r}")
191
+
192
+
193
+ def coerce_run_record(record: RunRecord) -> RunRecord:
194
+ """Normalized copy of ``record`` with every nested field coerced. Pure.
195
+
196
+ RunRecord's constructor stores nested values as given, so a
197
+ hand-built record may carry primitive chunks/turns/budget where the
198
+ metric layers expect dataclasses. This runs each nested field
199
+ through its coercer (dataclass instances pass through untouched)
200
+ and returns a new RunRecord; the input is never mutated.
201
+ """
202
+ return RunRecord(
203
+ query=record.query,
204
+ response=record.response,
205
+ chunks=(coerce_chunks(record.chunks) if record.chunks is not None else None),
206
+ final_prompt=record.final_prompt,
207
+ token_budget=(
208
+ coerce_token_budget(record.token_budget, record.final_prompt)
209
+ if record.token_budget is not None
210
+ else None
211
+ ),
212
+ history_pre=(coerce_turns(record.history_pre) if record.history_pre is not None else None),
213
+ history_post=(
214
+ coerce_turns(record.history_post) if record.history_post is not None else None
215
+ ),
216
+ eviction_reason=record.eviction_reason,
217
+ cache_events=(
218
+ coerce_cache_events(record.cache_events) if record.cache_events is not None else None
219
+ ),
220
+ tool_calls=(
221
+ [coerce_tool_call(c) for c in record.tool_calls]
222
+ if record.tool_calls is not None
223
+ else None
224
+ ),
225
+ model=record.model,
226
+ token_usage=(
227
+ coerce_token_usage(record.token_usage) if record.token_usage is not None else None
228
+ ),
229
+ )
@@ -0,0 +1,183 @@
1
+ """Run record dataclasses shared by every ragradar package.
2
+
3
+ Pure data definitions — nothing in this module touches the store. All
4
+ dataclasses are decorated with ``_flexible`` so unknown keyword arguments
5
+ are silently dropped: instrumentation with extra fields never raises
6
+ ``TypeError`` in a caller's pipeline, and future fields never break old
7
+ readers.
8
+ """
9
+
10
+ import functools
11
+ from dataclasses import asdict, dataclass, fields
12
+ from typing import Optional
13
+
14
+
15
+ def _flexible(cls):
16
+ """Make dataclass __init__ accept and ignore unknown keyword arguments."""
17
+ original_init = cls.__init__
18
+
19
+ @functools.wraps(original_init)
20
+ def init(self, *args, **kwargs):
21
+ valid = {f.name for f in fields(cls)}
22
+ original_init(self, *args, **{k: v for k, v in kwargs.items() if k in valid})
23
+
24
+ cls.__init__ = init
25
+ return cls
26
+
27
+
28
+ @_flexible
29
+ @dataclass
30
+ class ChunkRecord:
31
+ """One retrieved chunk in a run's context window.
32
+
33
+ The advanced/typed path — most callers never construct this directly.
34
+ ``ragradar.capture()``/``cap.chunks()`` accept plain dicts (only
35
+ ``content`` is required; everything else, including ``chunk_id`` and
36
+ ``source_doc_id``, gets a sensible default) and coerce them into this
37
+ shape internally. Construct ``ChunkRecord`` yourself only if you want
38
+ static typing or are round-tripping data you already have in this form.
39
+ """
40
+
41
+ chunk_id: str
42
+ source_doc_id: str
43
+ content: str
44
+ token_count: int
45
+ retrieval_score: Optional[float] = None
46
+ rerank_score: Optional[float] = None
47
+ retrieval_path: Optional[str] = None
48
+ truncated: bool = False
49
+ cache_hit: Optional[bool] = None
50
+
51
+
52
+ @_flexible
53
+ @dataclass
54
+ class TokenBudget:
55
+ """How a run's token limit was allocated across chunks/history/system.
56
+
57
+ Advanced/typed path — ``cap.context(prompt, token_budget=...)`` also
58
+ accepts a bare int (the total limit) or a partial dict; missing
59
+ allocations default to 0 and ``headroom`` is derived when omitted.
60
+ """
61
+
62
+ total_limit: int
63
+ chunks_allocated: int
64
+ history_allocated: int
65
+ system_allocated: int
66
+ headroom: int
67
+
68
+
69
+ @_flexible
70
+ @dataclass
71
+ class TokenUsage:
72
+ """Actual token counts an LLM call reported (as opposed to the budget).
73
+
74
+ Advanced/typed path — ``cap.response(text, token_usage=...)`` also
75
+ accepts a dict; a missing ``total_tokens`` is derived as
76
+ ``input_tokens + output_tokens``.
77
+ """
78
+
79
+ input_tokens: int
80
+ output_tokens: int
81
+ total_tokens: int
82
+
83
+
84
+ @_flexible
85
+ @dataclass
86
+ class Turn:
87
+ """One turn of conversation history, before or after eviction.
88
+
89
+ Advanced/typed path — ``cap.history(pre=..., post=...)`` also accepts
90
+ shorthand ``{"user": "..."}`` / ``{"assistant": "..."}`` dicts or
91
+ ``(role, content)`` tuples; a missing ``tokens`` count is estimated
92
+ from the content.
93
+ """
94
+
95
+ role: str
96
+ content: str
97
+ tokens: Optional[int] = None
98
+
99
+
100
+ @_flexible
101
+ @dataclass
102
+ class CacheEvent:
103
+ """Whether one chunk was served from cache for this run.
104
+
105
+ Advanced/typed path — ``cap.cache(...)`` also accepts a whole-call
106
+ ``{chunk_id: hit}`` mapping or ``(chunk_id, hit)`` pairs.
107
+ """
108
+
109
+ chunk_id: str
110
+ hit: bool
111
+ cache_source: Optional[str] = None
112
+
113
+
114
+ @_flexible
115
+ @dataclass
116
+ class ToolCallRecord:
117
+ """One tool/function call made while producing a run's response.
118
+
119
+ Advanced/typed path — ``cap.tool_call(...)`` also accepts a plain
120
+ dict with the same field names.
121
+ """
122
+
123
+ tool_name: str
124
+ arguments: dict
125
+ result: Optional[str] = None
126
+ error: Optional[str] = None
127
+ latency_ms: Optional[float] = None
128
+
129
+
130
+ @_flexible
131
+ @dataclass
132
+ class RunRecord:
133
+ """The complete captured record of one pipeline run.
134
+
135
+ This is what ``ragradar.capture()``/``Capture`` build up and persist,
136
+ and what ``ragradar.evaluate()``/``check()`` score. Everything past
137
+ ``query``/``response`` is optional — instrument as much or as little
138
+ of your pipeline as you have. Most callers never construct one by
139
+ hand; it is assembled for you from the primitives passed to
140
+ ``capture()`` or the staged ``Capture`` methods.
141
+ """
142
+
143
+ query: str
144
+ response: str
145
+ chunks: Optional[list[ChunkRecord]] = None
146
+ final_prompt: Optional[str] = None
147
+ token_budget: Optional[TokenBudget] = None
148
+ history_pre: Optional[list[Turn]] = None
149
+ history_post: Optional[list[Turn]] = None
150
+ eviction_reason: Optional[str] = None
151
+ cache_events: Optional[list[CacheEvent]] = None
152
+ tool_calls: Optional[list[ToolCallRecord]] = None
153
+ model: Optional[str] = None
154
+ token_usage: Optional[TokenUsage] = None
155
+
156
+ def to_json(self) -> dict:
157
+ """This record as a plain, JSON-serializable dict. Pure."""
158
+ return asdict(self)
159
+
160
+ @classmethod
161
+ def from_json(cls, data: dict) -> "RunRecord":
162
+ """Rebuild a ``RunRecord`` from ``to_json()``'s output. Pure.
163
+
164
+ Nested dicts are reinflated into their dataclasses (``chunks``
165
+ into ``ChunkRecord``s, etc.) so the result is fully typed, not
166
+ just a dict of dicts.
167
+ """
168
+ data = dict(data)
169
+ if data.get("chunks") is not None:
170
+ data["chunks"] = [ChunkRecord(**c) for c in data["chunks"]]
171
+ if data.get("token_budget") is not None:
172
+ data["token_budget"] = TokenBudget(**data["token_budget"])
173
+ if data.get("history_pre") is not None:
174
+ data["history_pre"] = [Turn(**t) for t in data["history_pre"]]
175
+ if data.get("history_post") is not None:
176
+ data["history_post"] = [Turn(**t) for t in data["history_post"]]
177
+ if data.get("cache_events") is not None:
178
+ data["cache_events"] = [CacheEvent(**e) for e in data["cache_events"]]
179
+ if data.get("tool_calls") is not None:
180
+ data["tool_calls"] = [ToolCallRecord(**t) for t in data["tool_calls"]]
181
+ if data.get("token_usage") is not None:
182
+ data["token_usage"] = TokenUsage(**data["token_usage"])
183
+ return cls(**data)
ragradar_core/store.py ADDED
@@ -0,0 +1,773 @@
1
+ """Single source of truth for the ragradar SQLite store.
2
+
3
+ Owns the store location (``~/.ragradar/runs.db``), the schema (always created
4
+ at the LATEST version), the migration chain for databases created by
5
+ older versions, and every run/eval/benchmark/policy persistence
6
+ primitive. All other packages (ragradar_capture, ragradar, ragradar_evaluate) import
7
+ their store access from here — none of them define their own connection
8
+ helper, schema, or version constant.
9
+
10
+ Environment-setup contract: :func:`connect` guarantees that the ``~/.ragradar``
11
+ directory exists, the database file exists, and its schema is at
12
+ ``SCHEMA_VERSION`` — creating fresh databases directly at the latest
13
+ version and migrating old ones in place. Any entry point (library call,
14
+ CLI, example script) therefore works on a fresh machine with no prior
15
+ CLI invocation.
16
+ """
17
+
18
+ import json
19
+ import sqlite3
20
+ import time
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+
24
+ from ragradar_core.schema import RunRecord
25
+
26
+ SCHEMA_VERSION = "3"
27
+
28
+ # How long to keep retrying the one-time WAL-mode switch on a brand-new
29
+ # database file (see _set_wal_mode below) before giving up.
30
+ _WAL_SWITCH_RETRY_SECONDS = 5.0
31
+
32
+ # Latest schema, created as-is for fresh databases. Databases written by
33
+ # older package versions carry meta.schema_version "1" or "2" and are
34
+ # walked to "3" by _ensure_schema()'s migration chain.
35
+ #
36
+ # One statement per tuple entry (not a single multi-statement string run
37
+ # via executescript()): executescript() implicitly commits any pending
38
+ # transaction before it runs, which would silently release the
39
+ # BEGIN IMMEDIATE lock _ensure_schema() holds while bootstrapping a
40
+ # fresh database — reopening the exact concurrent-bootstrap race this
41
+ # structure exists to close. conn.execute() on one statement at a time
42
+ # respects the ambient transaction instead.
43
+ SCHEMA_STATEMENTS: tuple[str, ...] = (
44
+ """CREATE TABLE IF NOT EXISTS meta (
45
+ key TEXT PRIMARY KEY,
46
+ value TEXT NOT NULL
47
+ )""",
48
+ """CREATE TABLE IF NOT EXISTS sessions (
49
+ session_id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ title TEXT,
51
+ pipeline TEXT,
52
+ created_at TEXT NOT NULL
53
+ )""",
54
+ """CREATE TABLE IF NOT EXISTS runs (
55
+ session_id INTEGER NOT NULL REFERENCES sessions(session_id),
56
+ run_seq INTEGER NOT NULL,
57
+ query TEXT NOT NULL,
58
+ pipeline TEXT,
59
+ created_at TEXT NOT NULL,
60
+ run_data TEXT NOT NULL,
61
+ eval_scores TEXT,
62
+ risk_score REAL,
63
+ evaluated_at TEXT,
64
+ PRIMARY KEY (session_id, run_seq)
65
+ )""",
66
+ "CREATE INDEX IF NOT EXISTS idx_runs_created_at ON runs(created_at)",
67
+ "CREATE INDEX IF NOT EXISTS idx_runs_pipeline ON runs(pipeline)",
68
+ """CREATE TABLE IF NOT EXISTS benchmark (
69
+ pipeline TEXT NOT NULL,
70
+ factor TEXT NOT NULL,
71
+ threshold REAL,
72
+ correlation REAL,
73
+ sample_count INTEGER NOT NULL DEFAULT 0,
74
+ updated_at TEXT NOT NULL,
75
+ PRIMARY KEY (pipeline, factor)
76
+ )""",
77
+ """CREATE TABLE IF NOT EXISTS policies (
78
+ pipeline TEXT PRIMARY KEY,
79
+ policy_data TEXT NOT NULL,
80
+ updated_at TEXT NOT NULL
81
+ )""",
82
+ """CREATE VIRTUAL TABLE IF NOT EXISTS runs_fts
83
+ USING fts5(
84
+ query,
85
+ content=runs,
86
+ content_rowid=rowid,
87
+ tokenize='unicode61 remove_diacritics 1'
88
+ )""",
89
+ """CREATE TRIGGER IF NOT EXISTS runs_fts_ins
90
+ AFTER INSERT ON runs BEGIN
91
+ INSERT INTO runs_fts(rowid, query) VALUES (new.rowid, new.query);
92
+ END""",
93
+ """CREATE TRIGGER IF NOT EXISTS runs_fts_del
94
+ AFTER DELETE ON runs BEGIN
95
+ INSERT INTO runs_fts(runs_fts, rowid, query)
96
+ VALUES ('delete', old.rowid, old.query);
97
+ END""",
98
+ """CREATE TRIGGER IF NOT EXISTS runs_fts_upd
99
+ AFTER UPDATE OF query ON runs BEGIN
100
+ INSERT INTO runs_fts(runs_fts, rowid, query)
101
+ VALUES ('delete', old.rowid, old.query);
102
+ INSERT INTO runs_fts(rowid, query) VALUES (new.rowid, new.query);
103
+ END""",
104
+ )
105
+
106
+
107
+ def _ragradar_dir() -> Path:
108
+ """Return the ragradar home directory (``~/.ragradar``). Pure — does not create it.
109
+
110
+ Tests monkeypatch this one function to isolate the store.
111
+ """
112
+ return Path.home() / ".ragradar"
113
+
114
+
115
+ def db_path() -> Path:
116
+ """Return the store's database path. Pure — does not create it."""
117
+ return _ragradar_dir() / "runs.db"
118
+
119
+
120
+ def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
121
+ rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
122
+ return any(row[1] == column for row in rows)
123
+
124
+
125
+ def _set_wal_mode(conn: sqlite3.Connection) -> None:
126
+ """Switch ``conn``'s database to WAL journal mode, retrying briefly
127
+ under concurrent first-time connects. Writes to store (once ever).
128
+
129
+ Changing journal mode requires exclusive access to the database file
130
+ and, unlike ordinary lock contention on a normal statement, does not
131
+ reliably back off via sqlite3's own ``timeout``/busy-handler retry —
132
+ confirmed empirically: many threads opening a brand-new (non-WAL)
133
+ file at once and each issuing this PRAGMA can raise
134
+ ``sqlite3.OperationalError: database is locked`` even with a 5s
135
+ connection timeout. Once the file is already in WAL mode (the
136
+ common case after the very first connect ever), re-issuing this
137
+ PRAGMA is a fast no-op read that never contends, so the retry loop
138
+ below only ever matters for that one-time switch.
139
+ """
140
+ deadline = time.monotonic() + _WAL_SWITCH_RETRY_SECONDS
141
+ while True:
142
+ try:
143
+ conn.execute("PRAGMA journal_mode=WAL")
144
+ return
145
+ except sqlite3.OperationalError:
146
+ if time.monotonic() >= deadline:
147
+ raise
148
+ time.sleep(0.05)
149
+
150
+
151
+ def _ensure_schema(conn: sqlite3.Connection) -> None:
152
+ """Bring ``conn``'s database to SCHEMA_VERSION.
153
+
154
+ Fresh (or meta-less) databases get the full latest schema in one
155
+ shot; version "1"/"2" databases are migrated in place with data
156
+ intact; anything else raises RuntimeError. Writes to store.
157
+
158
+ Runs inside one ``BEGIN IMMEDIATE`` transaction — the same pattern
159
+ :func:`commit_run` uses for run inserts — so concurrent first-time
160
+ ``connect()`` calls against a brand-new database can't interleave
161
+ "check whether meta exists" with "create it and stamp
162
+ schema_version". Without this, one connection could observe the
163
+ ``meta`` table (created by another connection's in-flight bootstrap,
164
+ since plain DDL auto-commits per statement outside an explicit
165
+ transaction) before that connection had committed the
166
+ ``schema_version`` row, and raise a bogus "Unsupported schema
167
+ version: None" error — a real, previously-uncovered race distinct
168
+ from the run_seq race :func:`commit_run` fixes.
169
+ """
170
+ conn.execute("BEGIN IMMEDIATE")
171
+ try:
172
+ has_meta = conn.execute(
173
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='meta'"
174
+ ).fetchone()
175
+ if has_meta is None:
176
+ for stmt in SCHEMA_STATEMENTS:
177
+ conn.execute(stmt)
178
+ conn.execute(
179
+ "INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', ?)",
180
+ (SCHEMA_VERSION,),
181
+ )
182
+ conn.commit()
183
+ return
184
+
185
+ row = conn.execute("SELECT value FROM meta WHERE key = 'schema_version'").fetchone()
186
+ version = row[0] if row else None
187
+
188
+ if version == SCHEMA_VERSION:
189
+ conn.commit()
190
+ return
191
+ if version not in ("1", "2"):
192
+ raise RuntimeError(
193
+ f"Unsupported schema version: {version!r}. "
194
+ f"Expected '1', '2', or '{SCHEMA_VERSION}'. Cannot migrate."
195
+ )
196
+
197
+ if version == "1":
198
+ for col, col_type in [
199
+ ("eval_scores", "TEXT"),
200
+ ("risk_score", "REAL"),
201
+ ("evaluated_at", "TEXT"),
202
+ ]:
203
+ if not _column_exists(conn, "runs", col):
204
+ conn.execute(f"ALTER TABLE runs ADD COLUMN {col} {col_type}")
205
+
206
+ conn.execute(
207
+ """CREATE TABLE IF NOT EXISTS benchmark (
208
+ pipeline TEXT NOT NULL,
209
+ factor TEXT NOT NULL,
210
+ threshold REAL,
211
+ correlation REAL,
212
+ sample_count INTEGER NOT NULL DEFAULT 0,
213
+ updated_at TEXT NOT NULL,
214
+ PRIMARY KEY (pipeline, factor)
215
+ )"""
216
+ )
217
+ conn.execute(
218
+ """CREATE TABLE IF NOT EXISTS policies (
219
+ pipeline TEXT PRIMARY KEY,
220
+ policy_data TEXT NOT NULL,
221
+ updated_at TEXT NOT NULL
222
+ )"""
223
+ )
224
+ conn.execute("UPDATE meta SET value = '2' WHERE key = 'schema_version'")
225
+ version = "2"
226
+
227
+ if version == "2":
228
+ conn.execute(
229
+ """CREATE VIRTUAL TABLE IF NOT EXISTS runs_fts
230
+ USING fts5(
231
+ query,
232
+ content=runs,
233
+ content_rowid=rowid,
234
+ tokenize='unicode61 remove_diacritics 1'
235
+ )"""
236
+ )
237
+ conn.execute("INSERT INTO runs_fts(runs_fts) VALUES('rebuild')")
238
+ conn.execute(
239
+ """CREATE TRIGGER IF NOT EXISTS runs_fts_ins
240
+ AFTER INSERT ON runs BEGIN
241
+ INSERT INTO runs_fts(rowid, query) VALUES (new.rowid, new.query);
242
+ END"""
243
+ )
244
+ conn.execute(
245
+ """CREATE TRIGGER IF NOT EXISTS runs_fts_del
246
+ AFTER DELETE ON runs BEGIN
247
+ INSERT INTO runs_fts(runs_fts, rowid, query)
248
+ VALUES ('delete', old.rowid, old.query);
249
+ END"""
250
+ )
251
+ conn.execute(
252
+ """CREATE TRIGGER IF NOT EXISTS runs_fts_upd
253
+ AFTER UPDATE OF query ON runs BEGIN
254
+ INSERT INTO runs_fts(runs_fts, rowid, query)
255
+ VALUES ('delete', old.rowid, old.query);
256
+ INSERT INTO runs_fts(rowid, query) VALUES (new.rowid, new.query);
257
+ END"""
258
+ )
259
+ conn.execute("DROP INDEX IF EXISTS idx_runs_query")
260
+ conn.execute("UPDATE meta SET value = '3' WHERE key = 'schema_version'")
261
+
262
+ conn.commit()
263
+ except BaseException:
264
+ conn.rollback()
265
+ raise
266
+
267
+
268
+ def connect() -> sqlite3.Connection:
269
+ """Open a Row-factory connection to the store, setting up the environment.
270
+
271
+ Side effects (writes to store): creates ``~/.ragradar`` and ``runs.db`` if
272
+ missing, creates the schema at SCHEMA_VERSION for fresh databases,
273
+ and migrates version "1"/"2" databases in place.
274
+
275
+ Returns an open ``sqlite3.Connection`` with ``sqlite3.Row`` row
276
+ factory — the caller must close it. Raises RuntimeError for a
277
+ database whose schema version is unsupported (newer than this
278
+ package understands).
279
+ """
280
+ path = db_path()
281
+ path.parent.mkdir(parents=True, exist_ok=True)
282
+ conn = sqlite3.connect(str(path))
283
+ conn.row_factory = sqlite3.Row
284
+ try:
285
+ # PRAGMA journal_mode can't be changed from inside a transaction,
286
+ # so this runs before _ensure_schema()'s BEGIN IMMEDIATE.
287
+ _set_wal_mode(conn)
288
+ _ensure_schema(conn)
289
+ except BaseException:
290
+ conn.close()
291
+ raise
292
+ return conn
293
+
294
+
295
+ def ensure_store() -> Path:
296
+ """Create/migrate the store without keeping a connection open.
297
+
298
+ Writes to store (via :func:`connect`). Returns the database path.
299
+ """
300
+ connect().close()
301
+ return db_path()
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # Session / run persistence
306
+ # ---------------------------------------------------------------------------
307
+
308
+
309
+ def _get_or_create_session_on(
310
+ conn: sqlite3.Connection, pipeline: str | None, idle_gap_minutes: int = 30
311
+ ) -> int:
312
+ """Same contract as :func:`get_or_create_session`, on a caller-owned
313
+ connection/transaction instead of opening its own. Writes to store."""
314
+ if pipeline is not None:
315
+ row = conn.execute(
316
+ "SELECT session_id, created_at FROM sessions "
317
+ "WHERE pipeline = ? ORDER BY created_at DESC LIMIT 1",
318
+ (pipeline,),
319
+ ).fetchone()
320
+ else:
321
+ row = conn.execute(
322
+ "SELECT session_id, created_at FROM sessions "
323
+ "WHERE pipeline IS NULL ORDER BY created_at DESC LIMIT 1",
324
+ ).fetchone()
325
+
326
+ if row is not None:
327
+ session_id, session_created = row
328
+ last_run = conn.execute(
329
+ "SELECT created_at FROM runs WHERE session_id = ? ORDER BY created_at DESC LIMIT 1",
330
+ (session_id,),
331
+ ).fetchone()
332
+ last_time = datetime.fromisoformat(last_run[0] if last_run else session_created)
333
+ if last_time.tzinfo is None:
334
+ last_time = last_time.replace(tzinfo=timezone.utc)
335
+ now = datetime.now(timezone.utc)
336
+ if (now - last_time).total_seconds() < idle_gap_minutes * 60:
337
+ return session_id
338
+
339
+ now_iso = datetime.now(timezone.utc).isoformat()
340
+ cursor = conn.execute(
341
+ "INSERT INTO sessions (pipeline, created_at) VALUES (?, ?)",
342
+ (pipeline, now_iso),
343
+ )
344
+ return cursor.lastrowid
345
+
346
+
347
+ def get_or_create_session(pipeline: str | None, idle_gap_minutes: int = 30) -> int:
348
+ """Return the current session id for ``pipeline``, creating one if needed.
349
+
350
+ Writes to store: reuses the most recent session for ``pipeline`` when
351
+ its last activity is within ``idle_gap_minutes``, otherwise inserts a
352
+ new session row. Returns the session id.
353
+
354
+ Opens and commits its own transaction, so calling this back-to-back
355
+ with :func:`next_run_seq`/:func:`write_run` as three separate calls is
356
+ not race-free under concurrent writers — see :func:`commit_run` for
357
+ the atomic path used by ``Capture.commit()``.
358
+ """
359
+ conn = connect()
360
+ try:
361
+ session_id = _get_or_create_session_on(conn, pipeline, idle_gap_minutes)
362
+ conn.commit()
363
+ return session_id
364
+ finally:
365
+ conn.close()
366
+
367
+
368
+ def _next_run_seq_on(conn: sqlite3.Connection, session_id: int) -> int:
369
+ """Same contract as :func:`next_run_seq`, on a caller-owned connection."""
370
+ row = conn.execute(
371
+ "SELECT MAX(run_seq) FROM runs WHERE session_id = ?",
372
+ (session_id,),
373
+ ).fetchone()
374
+ return (row[0] or 0) + 1
375
+
376
+
377
+ def next_run_seq(session_id: int) -> int:
378
+ """Return the next run_seq for ``session_id`` (1 for an empty session).
379
+
380
+ Read-only query (though connecting may create/migrate the store).
381
+ Calling this and then :func:`write_run` as two separate calls has a
382
+ TOCTOU race under concurrent writers to the same session — see
383
+ :func:`commit_run` for the atomic path.
384
+ """
385
+ conn = connect()
386
+ try:
387
+ return _next_run_seq_on(conn, session_id)
388
+ finally:
389
+ conn.close()
390
+
391
+
392
+ def _write_run_on(
393
+ conn: sqlite3.Connection,
394
+ session_id: int,
395
+ run_seq: int,
396
+ record: RunRecord,
397
+ pipeline: str | None,
398
+ ) -> None:
399
+ """Same contract as :func:`write_run`, on a caller-owned connection."""
400
+ now = datetime.now(timezone.utc).isoformat()
401
+ conn.execute(
402
+ "INSERT INTO runs (session_id, run_seq, query, pipeline, created_at, run_data) "
403
+ "VALUES (?, ?, ?, ?, ?, ?)",
404
+ (
405
+ session_id,
406
+ run_seq,
407
+ record.query,
408
+ pipeline,
409
+ now,
410
+ json.dumps(record.to_json()),
411
+ ),
412
+ )
413
+
414
+
415
+ def write_run(session_id: int, run_seq: int, record: RunRecord, pipeline: str | None) -> None:
416
+ """Insert one run row for ``record``. Writes to store.
417
+
418
+ ``created_at`` is stamped with the current UTC time; ``run_data`` is
419
+ the JSON-serialized record. Raises sqlite3.IntegrityError if
420
+ (session_id, run_seq) already exists.
421
+ """
422
+ conn = connect()
423
+ try:
424
+ _write_run_on(conn, session_id, run_seq, record, pipeline)
425
+ conn.commit()
426
+ finally:
427
+ conn.close()
428
+
429
+
430
+ def commit_run(
431
+ pipeline: str | None, record: RunRecord, idle_gap_minutes: int = 30
432
+ ) -> tuple[int, int]:
433
+ """Atomically resolve/create a session, assign the next run_seq, and
434
+ insert the run row. Writes to store.
435
+
436
+ This is the race-free replacement for calling
437
+ ``get_or_create_session()`` + ``next_run_seq()`` + ``write_run()`` as
438
+ three separate connections: session resolution, run_seq assignment,
439
+ and the insert all happen inside one ``BEGIN IMMEDIATE`` transaction,
440
+ so no other writer can interleave a run insert for the same session
441
+ between "compute run_seq" and "insert the row" — the exact race that
442
+ used to raise ``sqlite3.IntegrityError`` on ``(session_id, run_seq)``
443
+ under concurrent commits and get silently swallowed by the capture
444
+ layer's fail-open contract.
445
+
446
+ ``BEGIN IMMEDIATE`` acquires SQLite's write lock up front rather than
447
+ on first write, so a concurrent caller blocks (and retries under the
448
+ connection's default busy timeout) instead of racing to the insert.
449
+ If a collision is nonetheless detected (belt-and-suspenders — this
450
+ should be unreachable given the transaction above), this raises
451
+ ``RuntimeError`` rather than silently retrying or swallowing it, since
452
+ silent loss of a colliding write is the bug this function exists to
453
+ eliminate.
454
+
455
+ Returns ``(session_id, run_seq)``. Raises ``sqlite3.OperationalError``
456
+ if the write lock can't be acquired before the connection's busy
457
+ timeout elapses, and re-raises (after rolling back) any other error —
458
+ callers (``Capture.commit()``) apply their own fail-open/strict-mode
459
+ handling on top of this.
460
+ """
461
+ conn = connect()
462
+ try:
463
+ conn.execute("BEGIN IMMEDIATE")
464
+ session_id = _get_or_create_session_on(conn, pipeline, idle_gap_minutes)
465
+ run_seq = _next_run_seq_on(conn, session_id)
466
+ try:
467
+ _write_run_on(conn, session_id, run_seq, record, pipeline)
468
+ except sqlite3.IntegrityError as e:
469
+ raise RuntimeError(
470
+ f"run_seq collision on session {session_id} seq {run_seq} inside an "
471
+ "atomic transaction — this should be unreachable; investigate rather "
472
+ "than retry."
473
+ ) from e
474
+ conn.commit()
475
+ return session_id, run_seq
476
+ except BaseException:
477
+ conn.rollback()
478
+ raise
479
+ finally:
480
+ conn.close()
481
+
482
+
483
+ def write_runs_batch(
484
+ session_id: int,
485
+ start_seq: int,
486
+ records: list[RunRecord],
487
+ pipeline: str | None,
488
+ ) -> None:
489
+ """Insert many run rows in one transaction. Writes to store.
490
+
491
+ Records get consecutive run_seq values starting at ``start_seq`` and
492
+ share one UTC ``created_at`` stamp.
493
+ """
494
+ now = datetime.now(timezone.utc).isoformat()
495
+ rows = [
496
+ (session_id, start_seq + i, record.query, pipeline, now, json.dumps(record.to_json()))
497
+ for i, record in enumerate(records)
498
+ ]
499
+ conn = connect()
500
+ try:
501
+ conn.executemany(
502
+ "INSERT INTO runs (session_id, run_seq, query, pipeline, created_at, run_data) "
503
+ "VALUES (?, ?, ?, ?, ?, ?)",
504
+ rows,
505
+ )
506
+ conn.commit()
507
+ finally:
508
+ conn.close()
509
+
510
+
511
+ _RUN_COLUMNS = (
512
+ "session_id, run_seq, query, pipeline, created_at, "
513
+ "run_data, eval_scores, risk_score, evaluated_at"
514
+ )
515
+
516
+
517
+ def get_run(session_id: int, run_seq: int) -> dict | None:
518
+ """Fetch one run row as a dict, or None if it doesn't exist.
519
+
520
+ Read-only query (though connecting may create/migrate the store).
521
+ """
522
+ conn = connect()
523
+ try:
524
+ row = conn.execute(
525
+ f"SELECT {_RUN_COLUMNS} FROM runs WHERE session_id = ? AND run_seq = ?",
526
+ (session_id, run_seq),
527
+ ).fetchone()
528
+ return dict(row) if row else None
529
+ finally:
530
+ conn.close()
531
+
532
+
533
+ def get_latest_run() -> dict | None:
534
+ """Fetch the most recently created run row, or None if the store is empty.
535
+
536
+ Read-only query (though connecting may create/migrate the store).
537
+ """
538
+ conn = connect()
539
+ try:
540
+ row = conn.execute(
541
+ f"SELECT {_RUN_COLUMNS} FROM runs ORDER BY created_at DESC LIMIT 1",
542
+ ).fetchone()
543
+ return dict(row) if row else None
544
+ finally:
545
+ conn.close()
546
+
547
+
548
+ def get_runs_in_session(session_id: int) -> list[dict]:
549
+ """Fetch all run rows in a session, newest first (empty list if none).
550
+
551
+ Read-only query (though connecting may create/migrate the store).
552
+ """
553
+ conn = connect()
554
+ try:
555
+ rows = conn.execute(
556
+ f"SELECT {_RUN_COLUMNS} FROM runs WHERE session_id = ? ORDER BY created_at DESC",
557
+ (session_id,),
558
+ ).fetchall()
559
+ return [dict(r) for r in rows]
560
+ finally:
561
+ conn.close()
562
+
563
+
564
+ # ---------------------------------------------------------------------------
565
+ # Eval-score persistence (eval_scores lives on the runs table)
566
+ # ---------------------------------------------------------------------------
567
+
568
+
569
+ def write_eval_scores(
570
+ session_id: int,
571
+ run_seq: int,
572
+ eval_scores: dict,
573
+ risk_score: float,
574
+ ) -> None:
575
+ """Persist eval scores + risk on one run row. Writes to store.
576
+
577
+ Stamps ``evaluated_at`` with the current UTC time. Silently updates
578
+ zero rows if the run doesn't exist.
579
+ """
580
+ conn = connect()
581
+ try:
582
+ now = datetime.now(timezone.utc).isoformat()
583
+ conn.execute(
584
+ "UPDATE runs SET eval_scores = ?, risk_score = ?, evaluated_at = ? "
585
+ "WHERE session_id = ? AND run_seq = ?",
586
+ (json.dumps(eval_scores), risk_score, now, session_id, run_seq),
587
+ )
588
+ conn.commit()
589
+ finally:
590
+ conn.close()
591
+
592
+
593
+ def write_eval_scores_batch(entries: list[tuple]) -> None:
594
+ """Persist eval scores for many runs in one transaction. Writes to store.
595
+
596
+ Each entry is ``(session_id, run_seq, eval_scores_dict, risk_score)``.
597
+ No-op for an empty list.
598
+ """
599
+ if not entries:
600
+ return
601
+ conn = connect()
602
+ try:
603
+ now = datetime.now(timezone.utc).isoformat()
604
+ rows = [
605
+ (json.dumps(eval_scores), risk_score, now, session_id, run_seq)
606
+ for session_id, run_seq, eval_scores, risk_score in entries
607
+ ]
608
+ conn.executemany(
609
+ "UPDATE runs SET eval_scores = ?, risk_score = ?, evaluated_at = ? "
610
+ "WHERE session_id = ? AND run_seq = ?",
611
+ rows,
612
+ )
613
+ conn.commit()
614
+ finally:
615
+ conn.close()
616
+
617
+
618
+ def get_eval_scores(session_id: int, run_seq: int) -> dict | None:
619
+ """Fetch a run's stored eval scores (with ``risk_score`` merged in).
620
+
621
+ Read-only query (though connecting may create/migrate the store).
622
+ Returns None if the run doesn't exist or was never evaluated.
623
+ """
624
+ conn = connect()
625
+ try:
626
+ row = conn.execute(
627
+ "SELECT eval_scores, risk_score FROM runs WHERE session_id = ? AND run_seq = ?",
628
+ (session_id, run_seq),
629
+ ).fetchone()
630
+ if row is None or row["eval_scores"] is None:
631
+ return None
632
+ result = json.loads(row["eval_scores"])
633
+ result["risk_score"] = row["risk_score"]
634
+ return result
635
+ finally:
636
+ conn.close()
637
+
638
+
639
+ def get_all_evaluated_runs(pipeline: str | None = None) -> list[dict]:
640
+ """Fetch every run row with non-null eval_scores, newest first.
641
+
642
+ Read-only query (though connecting may create/migrate the store).
643
+ ``pipeline=None`` means all pipelines, not the "__default" key.
644
+ """
645
+ conn = connect()
646
+ try:
647
+ sql = f"SELECT {_RUN_COLUMNS} FROM runs WHERE eval_scores IS NOT NULL"
648
+ params: list = []
649
+ if pipeline is not None:
650
+ sql += " AND pipeline = ?"
651
+ params.append(pipeline)
652
+ sql += " ORDER BY created_at DESC"
653
+ return [dict(r) for r in conn.execute(sql, params).fetchall()]
654
+ finally:
655
+ conn.close()
656
+
657
+
658
+ # ---------------------------------------------------------------------------
659
+ # Benchmark persistence
660
+ # ---------------------------------------------------------------------------
661
+
662
+
663
+ def write_benchmark_entry(
664
+ pipeline: str,
665
+ factor: str,
666
+ threshold: float | None,
667
+ correlation: float | None,
668
+ sample_count: int,
669
+ ) -> None:
670
+ """Upsert one benchmark row keyed (pipeline, factor). Writes to store."""
671
+ conn = connect()
672
+ try:
673
+ now = datetime.now(timezone.utc).isoformat()
674
+ conn.execute(
675
+ "INSERT OR REPLACE INTO benchmark "
676
+ "(pipeline, factor, threshold, correlation, sample_count, updated_at) "
677
+ "VALUES (?, ?, ?, ?, ?, ?)",
678
+ (pipeline, factor, threshold, correlation, sample_count, now),
679
+ )
680
+ conn.commit()
681
+ finally:
682
+ conn.close()
683
+
684
+
685
+ def write_benchmark_entries_batch(entries: list[tuple]) -> None:
686
+ """Upsert many benchmark rows in one transaction. Writes to store.
687
+
688
+ Each entry is ``(pipeline, factor, threshold, correlation, sample_count)``.
689
+ No-op for an empty list.
690
+ """
691
+ if not entries:
692
+ return
693
+ conn = connect()
694
+ try:
695
+ now = datetime.now(timezone.utc).isoformat()
696
+ rows = [(p, f, t, c, s, now) for p, f, t, c, s in entries]
697
+ conn.executemany(
698
+ "INSERT OR REPLACE INTO benchmark "
699
+ "(pipeline, factor, threshold, correlation, sample_count, updated_at) "
700
+ "VALUES (?, ?, ?, ?, ?, ?)",
701
+ rows,
702
+ )
703
+ conn.commit()
704
+ finally:
705
+ conn.close()
706
+
707
+
708
+ def get_benchmark(pipeline: str) -> list[dict]:
709
+ """Fetch all benchmark rows for ``pipeline`` (empty list if none).
710
+
711
+ Read-only query (though connecting may create/migrate the store).
712
+ """
713
+ conn = connect()
714
+ try:
715
+ rows = conn.execute(
716
+ "SELECT factor, threshold, correlation, sample_count, updated_at "
717
+ "FROM benchmark WHERE pipeline = ?",
718
+ (pipeline,),
719
+ ).fetchall()
720
+ return [dict(r) for r in rows]
721
+ finally:
722
+ conn.close()
723
+
724
+
725
+ # ---------------------------------------------------------------------------
726
+ # Policy persistence
727
+ # ---------------------------------------------------------------------------
728
+
729
+
730
+ def write_policy(pipeline: str, policy: dict) -> None:
731
+ """Upsert the policy dict for ``pipeline``. Writes to store."""
732
+ conn = connect()
733
+ try:
734
+ now = datetime.now(timezone.utc).isoformat()
735
+ conn.execute(
736
+ "INSERT OR REPLACE INTO policies (pipeline, policy_data, updated_at) VALUES (?, ?, ?)",
737
+ (pipeline, json.dumps(policy), now),
738
+ )
739
+ conn.commit()
740
+ finally:
741
+ conn.close()
742
+
743
+
744
+ def get_policy(pipeline: str) -> dict | None:
745
+ """Fetch the stored policy dict for ``pipeline``, or None if unset.
746
+
747
+ Read-only query (though connecting may create/migrate the store).
748
+ """
749
+ conn = connect()
750
+ try:
751
+ row = conn.execute(
752
+ "SELECT policy_data FROM policies WHERE pipeline = ?",
753
+ (pipeline,),
754
+ ).fetchone()
755
+ if row is None:
756
+ return None
757
+ return json.loads(row["policy_data"])
758
+ finally:
759
+ conn.close()
760
+
761
+
762
+ def delete_policy(pipeline: str) -> None:
763
+ """Delete the stored policy for ``pipeline`` (no-op if unset).
764
+
765
+ Writes to store. Subsequent ``get_policy`` calls return None, which
766
+ callers treat as "use defaults".
767
+ """
768
+ conn = connect()
769
+ try:
770
+ conn.execute("DELETE FROM policies WHERE pipeline = ?", (pipeline,))
771
+ conn.commit()
772
+ finally:
773
+ conn.close()
@@ -0,0 +1,26 @@
1
+ """The one sNrN run-id parser, shared by every ragradar package."""
2
+
3
+ import re
4
+
5
+ _TARGET_RE = re.compile(r"^s(\d+)r(\d+)$", re.IGNORECASE)
6
+
7
+
8
+ def parse_target_id(target: str) -> tuple[int, int]:
9
+ """Parse an sNrN run identifier into (session_id, run_seq).
10
+
11
+ Pure — no store access.
12
+
13
+ Inputs: ``target``, a run id like ``"s4r3"`` (case-insensitive).
14
+ Returns: ``(session_id, run_seq)`` as ints, e.g. ``(4, 3)``.
15
+ Errors: raises ``TypeError`` if ``target`` is not a string; raises
16
+ ``ValueError`` if it is a string but not in sNrN format.
17
+ """
18
+ if not isinstance(target, str):
19
+ raise TypeError(
20
+ f"Run id must be a string in sNrN format (e.g. 's4r3'), "
21
+ f"got {type(target).__name__}: {target!r}"
22
+ )
23
+ m = _TARGET_RE.match(target)
24
+ if not m:
25
+ raise ValueError(f"Run id must be in sNrN format (e.g. 's4r3'), got: {target!r}")
26
+ return int(m.group(1)), int(m.group(2))
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: ragradar-core
3
+ Version: 0.1.0
4
+ Summary: Shared schema, store, and target parsing for the ragradar observability system
5
+ Project-URL: Homepage, https://github.com/pleokarthik/RAGRadar
6
+ Project-URL: Repository, https://github.com/pleokarthik/RAGRadar
7
+ Project-URL: Issues, https://github.com/pleokarthik/RAGRadar/issues
8
+ Author-email: Leo Karthik Paramasivan <pleokarthik@gmail.com>
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+
19
+ # ragradar-core
20
+
21
+ Shared kernel for the ragradar observability system: the run-record schema, the
22
+ single SQLite store, and the sNrN target parser. `ragradar-capture`, `ragradar`, and
23
+ `ragradar-evaluate` all depend on it — it depends on nothing.
24
+
25
+ **You normally do not import this directly.** Instrument pipelines with
26
+ `ragradar_capture`, evaluate with `ragradar_evaluate` — both re-export the schema
27
+ dataclasses. `ragradar_core` exists so those packages share one store contract
28
+ instead of three copies of it.
29
+
30
+ ## Zero-dependency guarantee
31
+
32
+ `ragradar_core` imports only the Python standard library (`sqlite3`,
33
+ `dataclasses`, `json`, `re`, `pathlib`, `datetime`). This is enforced by a
34
+ test (`tests/test_zero_deps.py`) that imports the package in a subprocess
35
+ and asserts nothing outside the stdlib was loaded.
36
+
37
+ ## What lives here
38
+
39
+ | Module | Contents |
40
+ |---|---|
41
+ | `ragradar_core.schema` | `RunRecord` and its child dataclasses (`ChunkRecord`, `TokenBudget`, `TokenUsage`, `Turn`, `CacheEvent`, `ToolCallRecord`), all tolerant of unknown kwargs |
42
+ | `ragradar_core.store` | store location, schema + migrations, and every persistence primitive (runs, eval scores, benchmark, policies) |
43
+ | `ragradar_core.targets` | `parse_target_id("s4r3") -> (4, 3)` — the one sNrN parser |
44
+
45
+ ## Environment setup contract
46
+
47
+ `ragradar_core.store.connect()` guarantees the environment before returning a
48
+ connection:
49
+
50
+ 1. `~/.ragradar/` exists (created if missing),
51
+ 2. `~/.ragradar/runs.db` exists (created if missing),
52
+ 3. the schema is at the latest version — fresh databases are created
53
+ directly at the latest version; databases written by older package
54
+ versions are migrated in place.
55
+
56
+ Any entry point — a library call, a CLI command, an example script — works
57
+ on a fresh machine with no prior CLI invocation.
58
+
59
+ ## Schema version + migration story
60
+
61
+ One constant, `ragradar_core.store.SCHEMA_VERSION` (currently `"3"`), recorded
62
+ in the `meta` table. The migration chain walks old databases forward on
63
+ first connect:
64
+
65
+ - **v1 → v2**: adds `eval_scores` / `risk_score` / `evaluated_at` columns
66
+ to `runs`; creates the `benchmark` and `policies` tables.
67
+ - **v2 → v3**: creates the `runs_fts` FTS5 index over run queries (with
68
+ insert/update/delete sync triggers, backfilled from existing rows) and
69
+ drops the now-redundant `idx_runs_query` index.
70
+
71
+ A database reporting a version this package doesn't know raises
72
+ `RuntimeError` rather than guessing.
73
+
74
+ ## DB location and layout
75
+
76
+ The store lives at `~/.ragradar/runs.db` (SQLite, WAL mode).
77
+
78
+ | Table | Columns |
79
+ |---|---|
80
+ | `meta` | `key`, `value` — holds `schema_version` |
81
+ | `sessions` | `session_id`, `title`, `pipeline`, `created_at` |
82
+ | `runs` | `session_id`, `run_seq`, `query`, `pipeline`, `created_at`, `run_data` (JSON `RunRecord`), `eval_scores` (JSON), `risk_score`, `evaluated_at` |
83
+ | `benchmark` | `pipeline`, `factor`, `threshold`, `correlation`, `sample_count`, `updated_at` |
84
+ | `policies` | `pipeline`, `policy_data` (JSON), `updated_at` |
85
+ | `runs_fts` | FTS5 index over `runs.query` |
86
+
87
+ Runs are addressed as `s{session_id}r{run_seq}` (e.g. `s2r3`) everywhere —
88
+ "run" is the data noun; capturing is the verb, and belongs to
89
+ `ragradar-capture`.
@@ -0,0 +1,8 @@
1
+ ragradar_core/__init__.py,sha256=7j9v3FRH4qNH7I9m0-4Rg7ZU2B0I2z1AOC2A1qqM2Vk,657
2
+ ragradar_core/coerce.py,sha256=_0o71qutmAW5cNmQSaoFGuFJDg2nUM1m9mkiWrZX1dw,9145
3
+ ragradar_core/schema.py,sha256=SQH3_JfaBOEZ6TtS5uN1maTOWW6O6BKVhSJ1xQQZq7s,5960
4
+ ragradar_core/store.py,sha256=DANDgkdSPGUKkEXIMbm3NDnIcav92NeH3vdfYsBhwDQ,27587
5
+ ragradar_core/targets.py,sha256=kaAZ4C4XpxmeYjCPGD4UiBrjREe5NKNSG3BHUyKAraM,940
6
+ ragradar_core-0.1.0.dist-info/METADATA,sha256=4GthQDCvI6HiUfPbjM2QUm_7zUAD1-pix5_530OTSiU,4013
7
+ ragradar_core-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ ragradar_core-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any