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.
- ragradar_core/__init__.py +25 -0
- ragradar_core/coerce.py +229 -0
- ragradar_core/schema.py +183 -0
- ragradar_core/store.py +773 -0
- ragradar_core/targets.py +26 -0
- ragradar_core-0.1.0.dist-info/METADATA +89 -0
- ragradar_core-0.1.0.dist-info/RECORD +8 -0
- ragradar_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|
+
]
|
ragradar_core/coerce.py
ADDED
|
@@ -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
|
+
)
|
ragradar_core/schema.py
ADDED
|
@@ -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()
|
ragradar_core/targets.py
ADDED
|
@@ -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,,
|