rag-jev 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rag_jev/__init__.py +15 -0
- rag_jev/calibration.py +353 -0
- rag_jev/cli.py +206 -0
- rag_jev/demo.py +76 -0
- rag_jev/evaluation.py +275 -0
- rag_jev/generation.py +223 -0
- rag_jev/integrations/__init__.py +1 -0
- rag_jev/integrations/langchain.py +124 -0
- rag_jev/models.py +142 -0
- rag_jev/provider.py +167 -0
- rag_jev/py.typed +0 -0
- rag_jev/review.py +57 -0
- rag_jev/selector.py +256 -0
- rag_jev/server.py +253 -0
- rag_jev/static/app.js +481 -0
- rag_jev/static/benchmark-results.svg +2115 -0
- rag_jev/static/benchmarks.html +38 -0
- rag_jev/static/fonts.css +1 -0
- rag_jev/static/index.html +236 -0
- rag_jev/static/replays/MUSIQUE_LICENSE +396 -0
- rag_jev/static/replays/catalog.json +14 -0
- rag_jev/static/replays/improvement.json +406 -0
- rag_jev/static/replays/regression.json +407 -0
- rag_jev/static/research-v2-protocol.json +74 -0
- rag_jev/static/research-v2-results.json +206 -0
- rag_jev/static/research-v2-results.svg +2401 -0
- rag_jev/static/research-v2.html +19 -0
- rag_jev/static/research-v3-protocol.json +186 -0
- rag_jev/static/research-v3-results.json +641 -0
- rag_jev/static/research-v3-results.svg +2323 -0
- rag_jev/static/research-v3-verification.json +16 -0
- rag_jev/static/research-v3.html +20 -0
- rag_jev/static/retrieval-v1-protocol.json +48 -0
- rag_jev/static/retrieval-v1-results.json +46 -0
- rag_jev/static/retrieval-v1-verification.json +15 -0
- rag_jev/static/retrieval-v1.html +12 -0
- rag_jev/static/review.html +16 -0
- rag_jev/static/review.js +51 -0
- rag_jev/static/style.css +538 -0
- rag_jev/tokens.py +16 -0
- rag_jev/workbench.py +281 -0
- rag_jev-0.2.0.dist-info/METADATA +340 -0
- rag_jev-0.2.0.dist-info/RECORD +47 -0
- rag_jev-0.2.0.dist-info/WHEEL +4 -0
- rag_jev-0.2.0.dist-info/entry_points.txt +2 -0
- rag_jev-0.2.0.dist-info/licenses/LICENSE +21 -0
- rag_jev-0.2.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +24 -0
rag_jev/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from rag_jev.models import Document, Judgment, Policy, SelectionResult, SelectRequest
|
|
2
|
+
from rag_jev.provider import Jev, ProviderError
|
|
3
|
+
from rag_jev.selector import ContextSelector, SelectOptions
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ContextSelector",
|
|
7
|
+
"Document",
|
|
8
|
+
"Jev",
|
|
9
|
+
"Judgment",
|
|
10
|
+
"Policy",
|
|
11
|
+
"ProviderError",
|
|
12
|
+
"SelectRequest",
|
|
13
|
+
"SelectionResult",
|
|
14
|
+
"SelectOptions",
|
|
15
|
+
]
|
rag_jev/calibration.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Development-only policy selection with a frozen, separate held-out assessment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import random
|
|
11
|
+
import re
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from statistics import mean
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from rag_jev.evaluation import EvalCase, Trace, collect_trace, fingerprint, measure
|
|
19
|
+
from rag_jev.models import Policy, ScoringStrategy, SelectRequest
|
|
20
|
+
from rag_jev.provider import Jev
|
|
21
|
+
from rag_jev.selector import ContextSelector, apply_policy
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def digest(value: Any) -> str:
|
|
25
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_splits(development: list[EvalCase], evaluation: list[EvalCase]) -> None:
|
|
29
|
+
if len(development) < 2 or len(evaluation) < 2:
|
|
30
|
+
raise ValueError("Each split needs at least two questions")
|
|
31
|
+
ids: set[str] = set()
|
|
32
|
+
questions: set[str] = set()
|
|
33
|
+
for case in development + evaluation:
|
|
34
|
+
question = re.sub(r"\W+", " ", case.query.casefold()).strip()
|
|
35
|
+
if not question or case.id in ids or question in questions:
|
|
36
|
+
raise ValueError("Duplicate case ID or normalized question across calibration inputs")
|
|
37
|
+
ids.add(case.id)
|
|
38
|
+
questions.add(question)
|
|
39
|
+
if {c.family_id for c in development if c.family_id} & {
|
|
40
|
+
c.family_id for c in evaluation if c.family_id
|
|
41
|
+
}:
|
|
42
|
+
raise ValueError("Related question families cross development/evaluation splits")
|
|
43
|
+
if not any(c.relevant_ids for c in development):
|
|
44
|
+
raise ValueError("Development requires labeled relevant evidence for recall calibration")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def candidate_policies(
|
|
48
|
+
cutoffs: Sequence[float], top_ns: Sequence[int], budget: int | None
|
|
49
|
+
) -> list[Policy]:
|
|
50
|
+
policies = [
|
|
51
|
+
Policy(mode=mode, min_relevance=cutoff, max_context_tokens=budget)
|
|
52
|
+
for mode in ("filter", "filter_and_rerank")
|
|
53
|
+
for cutoff in sorted(set(cutoffs))
|
|
54
|
+
]
|
|
55
|
+
policies += [
|
|
56
|
+
Policy(mode="rerank", top_n=k, max_context_tokens=budget) for k in sorted(set(top_ns))
|
|
57
|
+
]
|
|
58
|
+
if not policies:
|
|
59
|
+
raise ValueError("At least one candidate policy is required")
|
|
60
|
+
return policies
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def assess(
|
|
64
|
+
cases: list[EvalCase], traces: list[Trace], policy: Policy | None, strategy: ScoringStrategy
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
by_id = {t.case_id: t for t in traces}
|
|
67
|
+
if len(by_id) != len(traces) or set(by_id) != {c.id for c in cases}:
|
|
68
|
+
raise ValueError("Trace IDs must match this split exactly")
|
|
69
|
+
rows = []
|
|
70
|
+
for case in cases:
|
|
71
|
+
trace = by_id[case.id]
|
|
72
|
+
request = SelectRequest(
|
|
73
|
+
query=case.query,
|
|
74
|
+
retrieval_query=case.retrieval_query,
|
|
75
|
+
documents=case.documents,
|
|
76
|
+
scoring_strategy=strategy,
|
|
77
|
+
**(policy or Policy(mode="rerank")).model_dump(),
|
|
78
|
+
)
|
|
79
|
+
if (
|
|
80
|
+
trace.input_hash != fingerprint(case)
|
|
81
|
+
or trace.scoring_strategy != strategy
|
|
82
|
+
or trace.prompt_version != request.prompt_version
|
|
83
|
+
or trace.relevance_guidance is not None
|
|
84
|
+
):
|
|
85
|
+
raise ValueError("Trace inputs, strategy, prompt or guidance do not match calibration")
|
|
86
|
+
if len(trace.judgments) != len(case.documents):
|
|
87
|
+
raise ValueError("Incomplete trace judgments")
|
|
88
|
+
result = apply_policy(request, trace.judgments, usage=trace.scoring_usage)
|
|
89
|
+
by_doc = {d.id: d for d in case.documents}
|
|
90
|
+
selected = (
|
|
91
|
+
result.documents
|
|
92
|
+
if policy
|
|
93
|
+
else [by_doc[i] for i in case.baseline_ids]
|
|
94
|
+
if case.baseline_ids is not None
|
|
95
|
+
else case.documents
|
|
96
|
+
)
|
|
97
|
+
row = measure(case, selected).model_dump()
|
|
98
|
+
row.update(
|
|
99
|
+
{
|
|
100
|
+
"case_id": case.id,
|
|
101
|
+
"selected_ids": [d.id for d in selected],
|
|
102
|
+
"budget_exceeded": result.context_token_budget_exceeded if policy else None,
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
rows.append(row)
|
|
106
|
+
keys = [
|
|
107
|
+
"evidence_recall",
|
|
108
|
+
"precision",
|
|
109
|
+
"ndcg",
|
|
110
|
+
"context_tokens",
|
|
111
|
+
"documents",
|
|
112
|
+
"correctly_empty",
|
|
113
|
+
"all_evidence_lost",
|
|
114
|
+
]
|
|
115
|
+
summary = {}
|
|
116
|
+
for key in keys:
|
|
117
|
+
values = [float(r[key]) for r in rows if r[key] is not None]
|
|
118
|
+
summary[key] = mean(values) if values else None
|
|
119
|
+
return {"summary": summary, "rows": rows}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def choose(
|
|
123
|
+
cases: list[EvalCase],
|
|
124
|
+
traces: list[Trace],
|
|
125
|
+
policies: list[Policy],
|
|
126
|
+
strategy: ScoringStrategy,
|
|
127
|
+
min_recall: float,
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
if not math.isfinite(min_recall) or not 0 <= min_recall <= 1:
|
|
130
|
+
raise ValueError("min_recall must be finite and between zero and one")
|
|
131
|
+
results = {p.version: assess(cases, traces, p, strategy) for p in policies}
|
|
132
|
+
eligible = [
|
|
133
|
+
p
|
|
134
|
+
for p in policies
|
|
135
|
+
if results[p.version]["summary"]["evidence_recall"] is not None
|
|
136
|
+
and results[p.version]["summary"]["evidence_recall"] >= min_recall
|
|
137
|
+
]
|
|
138
|
+
if not eligible:
|
|
139
|
+
return {
|
|
140
|
+
"eligible": False,
|
|
141
|
+
"policy": None,
|
|
142
|
+
"development": results,
|
|
143
|
+
"reason": "No development policy meets the requested observed evidence recall",
|
|
144
|
+
}
|
|
145
|
+
selected = min(
|
|
146
|
+
eligible,
|
|
147
|
+
key=lambda p: (
|
|
148
|
+
results[p.version]["summary"]["context_tokens"],
|
|
149
|
+
-(results[p.version]["summary"]["ndcg"] or 0),
|
|
150
|
+
p.version,
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
return {"eligible": True, "policy": selected.model_dump(), "development": results}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def paired_interval(
|
|
157
|
+
baseline: list[float], selected: list[float], families: list[str] | None = None
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
differences = [b - a for a, b in zip(baseline, selected, strict=True)]
|
|
160
|
+
grouped: dict[str, list[float]] = defaultdict(list)
|
|
161
|
+
for family, difference in zip(
|
|
162
|
+
families or [str(i) for i in range(len(differences))], differences, strict=True
|
|
163
|
+
):
|
|
164
|
+
grouped[family].append(difference)
|
|
165
|
+
groups = list(grouped.values())
|
|
166
|
+
if len(groups) < 2:
|
|
167
|
+
return {
|
|
168
|
+
"delta": mean(differences),
|
|
169
|
+
"ci95": None,
|
|
170
|
+
"questions": len(differences),
|
|
171
|
+
"independent_units": len(groups),
|
|
172
|
+
"scope": "Too few independent families for an interval",
|
|
173
|
+
}
|
|
174
|
+
rng = random.Random(20260919)
|
|
175
|
+
draws = sorted(
|
|
176
|
+
mean(v for group in rng.choices(groups, k=len(groups)) for v in group) for _ in range(2000)
|
|
177
|
+
)
|
|
178
|
+
return {
|
|
179
|
+
"delta": mean(differences),
|
|
180
|
+
"ci95": [draws[49], draws[1949]],
|
|
181
|
+
"questions": len(differences),
|
|
182
|
+
"independent_units": len(groups),
|
|
183
|
+
"scope": "Descriptive paired bootstrap of supplied families (else questions); "
|
|
184
|
+
"query-weighted mean, 2,000 draws",
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def held_out(
|
|
189
|
+
cases: list[EvalCase],
|
|
190
|
+
traces: list[Trace],
|
|
191
|
+
policies: list[Policy],
|
|
192
|
+
strategy: ScoringStrategy,
|
|
193
|
+
frozen: dict[str, Any],
|
|
194
|
+
) -> dict[str, Any]:
|
|
195
|
+
baseline = assess(cases, traces, None, strategy)
|
|
196
|
+
results = {
|
|
197
|
+
p.version: {"policy": p.model_dump(), **assess(cases, traces, p, strategy)}
|
|
198
|
+
for p in policies
|
|
199
|
+
}
|
|
200
|
+
report: dict[str, Any] = {
|
|
201
|
+
"baseline": baseline,
|
|
202
|
+
"policies": results,
|
|
203
|
+
"frozen_policy": frozen["policy"],
|
|
204
|
+
"eligible": frozen["eligible"],
|
|
205
|
+
"answer_quality": "not_evaluated",
|
|
206
|
+
"selection_rule": "Minimize development context tokens subject to observed recall; "
|
|
207
|
+
"ties higher NDCG then policy ID",
|
|
208
|
+
"scope": "Evidence already in candidate sets. No semantic accuracy or total-cost "
|
|
209
|
+
"claim. Held-out policies are descriptive; never reselect from this table.",
|
|
210
|
+
}
|
|
211
|
+
if frozen["eligible"]:
|
|
212
|
+
policy = Policy.model_validate(frozen["policy"])
|
|
213
|
+
selected = results[policy.version]
|
|
214
|
+
report["frozen_policy_held_out"] = selected
|
|
215
|
+
report["context_token_difference"] = paired_interval(
|
|
216
|
+
[r["context_tokens"] for r in baseline["rows"]],
|
|
217
|
+
[r["context_tokens"] for r in selected["rows"]],
|
|
218
|
+
[
|
|
219
|
+
"family:" + c.family_id if c.family_id is not None else "question:" + c.id
|
|
220
|
+
for c in cases
|
|
221
|
+
],
|
|
222
|
+
)
|
|
223
|
+
return report
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def run_calibration(args: argparse.Namespace) -> None:
|
|
227
|
+
if not math.isfinite(args.min_recall) or not 0 <= args.min_recall <= 1:
|
|
228
|
+
raise ValueError("min_recall must be finite and between zero and one")
|
|
229
|
+
development = [
|
|
230
|
+
EvalCase.model_validate(r)
|
|
231
|
+
for r in json.loads(await asyncio.to_thread(Path(args.development).read_text))
|
|
232
|
+
]
|
|
233
|
+
evaluation = [
|
|
234
|
+
EvalCase.model_validate(r)
|
|
235
|
+
for r in json.loads(await asyncio.to_thread(Path(args.evaluation).read_text))
|
|
236
|
+
]
|
|
237
|
+
validate_splits(development, evaluation)
|
|
238
|
+
policies = candidate_policies(args.cutoffs, args.top_ns, args.max_context_tokens)
|
|
239
|
+
output = Path(args.output)
|
|
240
|
+
frozen_path = output.with_suffix(".frozen.json")
|
|
241
|
+
config_path = output.with_suffix(".policy.json")
|
|
242
|
+
traces_path = output.with_suffix(".traces.json")
|
|
243
|
+
if any(p.exists() for p in [output, frozen_path, config_path, traces_path]):
|
|
244
|
+
raise ValueError(
|
|
245
|
+
"Use a fresh output path; previous calibration artifacts are never overwritten"
|
|
246
|
+
)
|
|
247
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
cached = (
|
|
249
|
+
[
|
|
250
|
+
Trace.model_validate(r)
|
|
251
|
+
for r in json.loads(await asyncio.to_thread(Path(args.replay).read_text))
|
|
252
|
+
]
|
|
253
|
+
if args.replay
|
|
254
|
+
else []
|
|
255
|
+
)
|
|
256
|
+
all_ids = {c.id for c in development + evaluation}
|
|
257
|
+
if args.replay and (
|
|
258
|
+
len({t.case_id for t in cached}) != len(cached) or {t.case_id for t in cached} != all_ids
|
|
259
|
+
):
|
|
260
|
+
raise ValueError("Replay traces must cover both splits exactly")
|
|
261
|
+
|
|
262
|
+
def save(path: Path, value: Any) -> None:
|
|
263
|
+
path.write_text(json.dumps(value, indent=2, allow_nan=False) + "\n")
|
|
264
|
+
|
|
265
|
+
collected: list[Trace] = []
|
|
266
|
+
|
|
267
|
+
async def traces(cases: list[EvalCase], selector: ContextSelector | None) -> list[Trace]:
|
|
268
|
+
if args.replay:
|
|
269
|
+
ids = {c.id for c in cases}
|
|
270
|
+
return [t for t in cached if t.case_id in ids]
|
|
271
|
+
assert selector is not None
|
|
272
|
+
subset = []
|
|
273
|
+
for case in cases:
|
|
274
|
+
trace = await collect_trace(case, selector, scoring_strategy=args.scoring_strategy)
|
|
275
|
+
subset.append(trace)
|
|
276
|
+
collected.append(trace)
|
|
277
|
+
save(traces_path, [t.model_dump(mode="json") for t in collected])
|
|
278
|
+
return subset
|
|
279
|
+
|
|
280
|
+
async def execute(selector: ContextSelector | None) -> None:
|
|
281
|
+
dev_traces = await traces(development, selector)
|
|
282
|
+
frozen = choose(development, dev_traces, policies, args.scoring_strategy, args.min_recall)
|
|
283
|
+
frozen.update(
|
|
284
|
+
{
|
|
285
|
+
"scoring_strategy": args.scoring_strategy,
|
|
286
|
+
"min_recall": args.min_recall,
|
|
287
|
+
"development_hash": digest([c.model_dump() for c in development]),
|
|
288
|
+
"evaluation_hash": digest([c.model_dump() for c in evaluation]),
|
|
289
|
+
"candidate_policies": [p.model_dump() for p in policies],
|
|
290
|
+
"sources": sorted({t.source for t in dev_traces}),
|
|
291
|
+
"models": sorted({j.model for t in dev_traces for j in t.judgments}),
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
# Persist the choice before inspecting held-out metrics or making held-out calls.
|
|
295
|
+
save(frozen_path, frozen)
|
|
296
|
+
eval_traces = await traces(evaluation, selector)
|
|
297
|
+
report = held_out(evaluation, eval_traces, policies, args.scoring_strategy, frozen)
|
|
298
|
+
report.update(
|
|
299
|
+
{
|
|
300
|
+
"freeze_hash": digest(frozen),
|
|
301
|
+
"development_questions": len(development),
|
|
302
|
+
"evaluation_questions": len(evaluation),
|
|
303
|
+
"minimum_development_recall": args.min_recall,
|
|
304
|
+
"sources": sorted({t.source for t in dev_traces + eval_traces}),
|
|
305
|
+
"models": sorted({j.model for t in dev_traces + eval_traces for j in t.judgments}),
|
|
306
|
+
"scoring_usage": {
|
|
307
|
+
"input_tokens": sum(
|
|
308
|
+
t.scoring_usage.input_tokens
|
|
309
|
+
if t.scoring_usage
|
|
310
|
+
else sum(j.input_tokens for j in t.judgments)
|
|
311
|
+
for t in dev_traces + eval_traces
|
|
312
|
+
),
|
|
313
|
+
"output_tokens": sum(
|
|
314
|
+
t.scoring_usage.output_tokens
|
|
315
|
+
if t.scoring_usage
|
|
316
|
+
else sum(j.output_tokens for j in t.judgments)
|
|
317
|
+
for t in dev_traces + eval_traces
|
|
318
|
+
),
|
|
319
|
+
},
|
|
320
|
+
"data_warning": "Artifacts include query and passage text; "
|
|
321
|
+
"keep private inputs local.",
|
|
322
|
+
}
|
|
323
|
+
)
|
|
324
|
+
save(output, report)
|
|
325
|
+
if frozen["eligible"]:
|
|
326
|
+
save(
|
|
327
|
+
config_path,
|
|
328
|
+
{**frozen["policy"], "scoring_strategy": args.scoring_strategy, "shadow": True},
|
|
329
|
+
)
|
|
330
|
+
print(
|
|
331
|
+
json.dumps(
|
|
332
|
+
{
|
|
333
|
+
"report": str(output),
|
|
334
|
+
"frozen": str(frozen_path),
|
|
335
|
+
"policy": str(config_path) if frozen["eligible"] else None,
|
|
336
|
+
"eligible": frozen["eligible"],
|
|
337
|
+
"answer_quality": "not_evaluated",
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
if args.replay:
|
|
343
|
+
await execute(None)
|
|
344
|
+
else:
|
|
345
|
+
async with Jev(model=args.model) as provider:
|
|
346
|
+
await execute(
|
|
347
|
+
ContextSelector(
|
|
348
|
+
provider,
|
|
349
|
+
timeout_ms=args.timeout_ms,
|
|
350
|
+
max_concurrency=args.max_concurrency,
|
|
351
|
+
on_error="raise",
|
|
352
|
+
)
|
|
353
|
+
)
|
rag_jev/cli.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from dotenv import load_dotenv
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
from typesafe_sdk import TypeSafeError
|
|
14
|
+
|
|
15
|
+
from rag_jev.demo import CASES, FixtureScorer, fixture_traces
|
|
16
|
+
from rag_jev.evaluation import EvalCase, Trace, collect_trace, evaluate
|
|
17
|
+
from rag_jev.models import SelectRequest
|
|
18
|
+
from rag_jev.provider import Jev, ProviderError
|
|
19
|
+
from rag_jev.selector import ContextSelector
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def emit(value: Any, path: str | None = None) -> None:
|
|
23
|
+
output = json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False) + "\n"
|
|
24
|
+
if path:
|
|
25
|
+
Path(path).write_text(output)
|
|
26
|
+
else:
|
|
27
|
+
sys.stdout.write(output)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def live_selector(provider: Jev, args: argparse.Namespace) -> ContextSelector:
|
|
31
|
+
return ContextSelector(
|
|
32
|
+
provider,
|
|
33
|
+
timeout_ms=args.timeout_ms,
|
|
34
|
+
max_concurrency=args.max_concurrency,
|
|
35
|
+
on_error="raise",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def run(args: argparse.Namespace) -> None:
|
|
40
|
+
if args.command == "review-results":
|
|
41
|
+
from rag_jev.review import summarize_review
|
|
42
|
+
|
|
43
|
+
emit(
|
|
44
|
+
await asyncio.to_thread(summarize_review, args.packet, args.labels, args.key),
|
|
45
|
+
args.output,
|
|
46
|
+
)
|
|
47
|
+
return
|
|
48
|
+
if args.command == "calibrate":
|
|
49
|
+
from rag_jev.calibration import run_calibration
|
|
50
|
+
|
|
51
|
+
await run_calibration(args)
|
|
52
|
+
return
|
|
53
|
+
if args.command == "demo":
|
|
54
|
+
selector = ContextSelector(FixtureScorer())
|
|
55
|
+
case = CASES[0]
|
|
56
|
+
selected = await selector.select(
|
|
57
|
+
query=case.query, documents=case.documents, min_relevance=0.2
|
|
58
|
+
)
|
|
59
|
+
report = await evaluate(CASES, fixture_traces(), min_relevance=0.2)
|
|
60
|
+
emit(
|
|
61
|
+
{
|
|
62
|
+
"notice": "OFFLINE FIXTURE DEMO: scores are hand-authored, not Jev predictions",
|
|
63
|
+
"selection": selected.model_dump(),
|
|
64
|
+
"evaluation": report,
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
return
|
|
68
|
+
if args.command == "select":
|
|
69
|
+
raw = await asyncio.to_thread(
|
|
70
|
+
sys.stdin.read if args.input == "-" else Path(args.input).read_text
|
|
71
|
+
)
|
|
72
|
+
request = SelectRequest.model_validate_json(raw)
|
|
73
|
+
async with Jev(model=args.model) as provider:
|
|
74
|
+
result = await live_selector(provider, args).select_request(request)
|
|
75
|
+
emit(result.model_dump(), args.output)
|
|
76
|
+
return
|
|
77
|
+
if args.command == "eval":
|
|
78
|
+
raw = await asyncio.to_thread(Path(args.input).read_text)
|
|
79
|
+
cases = [EvalCase.model_validate(x) for x in json.loads(raw)]
|
|
80
|
+
if args.replay:
|
|
81
|
+
raw = await asyncio.to_thread(Path(args.replay).read_text)
|
|
82
|
+
traces = [Trace.model_validate(x) for x in json.loads(raw)]
|
|
83
|
+
else:
|
|
84
|
+
async with Jev(model=args.model) as provider:
|
|
85
|
+
selector = live_selector(provider, args)
|
|
86
|
+
traces = [
|
|
87
|
+
await collect_trace(case, selector, scoring_strategy=args.scoring_strategy)
|
|
88
|
+
for case in cases
|
|
89
|
+
]
|
|
90
|
+
if args.save_traces:
|
|
91
|
+
emit([t.model_dump() for t in traces], args.save_traces)
|
|
92
|
+
if any(t.scoring_strategy != args.scoring_strategy for t in traces):
|
|
93
|
+
raise ValueError("Replay strategy differs; pass the matching --scoring-strategy")
|
|
94
|
+
report = await evaluate(cases, traces, min_relevance=args.min_relevance, top_n=args.top_n)
|
|
95
|
+
emit(report, args.output)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main() -> None:
|
|
99
|
+
parser = argparse.ArgumentParser(description="Select RAG context with TypeSafe Jev")
|
|
100
|
+
parser.add_argument("--env-file", default=".env", help="Loaded without overriding environment")
|
|
101
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
102
|
+
commands.add_parser("demo", help="Offline, hand-authored fixture demonstration")
|
|
103
|
+
commands.add_parser("schema", help="Print the service OpenAPI schema")
|
|
104
|
+
review = commands.add_parser(
|
|
105
|
+
"review-results", help="Validate supplied human labels and report completeness"
|
|
106
|
+
)
|
|
107
|
+
review.add_argument("packet")
|
|
108
|
+
review.add_argument("labels")
|
|
109
|
+
review.add_argument("--key", help="Unblind only after independent review")
|
|
110
|
+
review.add_argument("--output")
|
|
111
|
+
serve = commands.add_parser("serve", help="Start the real Jev selection service")
|
|
112
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
113
|
+
serve.add_argument("--port", type=int, default=8000)
|
|
114
|
+
serve.add_argument(
|
|
115
|
+
"--replay-only",
|
|
116
|
+
action="store_true",
|
|
117
|
+
help="Browse saved runs without keys or inference calls",
|
|
118
|
+
)
|
|
119
|
+
select = commands.add_parser("select", help="Select using a JSON request file, or - for stdin")
|
|
120
|
+
select.add_argument("input")
|
|
121
|
+
evaluation = commands.add_parser(
|
|
122
|
+
"eval", help="Compare policies on labeled retrieved candidates"
|
|
123
|
+
)
|
|
124
|
+
evaluation.add_argument("input", help="JSON array of evaluation cases")
|
|
125
|
+
evaluation.add_argument("--replay", help="Use saved traces; no API requests")
|
|
126
|
+
evaluation.add_argument("--save-traces", help="Save live judgments for offline policy tuning")
|
|
127
|
+
evaluation.add_argument("--min-relevance", type=float, required=True)
|
|
128
|
+
evaluation.add_argument("--top-n", type=int)
|
|
129
|
+
evaluation.add_argument(
|
|
130
|
+
"--scoring-strategy", choices=["independent", "contextual"], default="independent"
|
|
131
|
+
)
|
|
132
|
+
calibration = commands.add_parser(
|
|
133
|
+
"calibrate",
|
|
134
|
+
help="Choose a policy on development questions, then assess a separate held-out set",
|
|
135
|
+
)
|
|
136
|
+
calibration.add_argument("development")
|
|
137
|
+
calibration.add_argument("evaluation")
|
|
138
|
+
calibration.add_argument("--replay", help="Saved traces covering both splits; no API calls")
|
|
139
|
+
calibration.add_argument(
|
|
140
|
+
"--scoring-strategy", choices=["independent", "contextual"], default="contextual"
|
|
141
|
+
)
|
|
142
|
+
calibration.add_argument("--cutoffs", type=float, nargs="+", default=[0.2, 0.35, 0.5])
|
|
143
|
+
calibration.add_argument("--top-ns", type=int, nargs="+", default=[3, 5, 8])
|
|
144
|
+
calibration.add_argument("--max-context-tokens", type=int)
|
|
145
|
+
calibration.add_argument("--min-recall", type=float, default=0.98)
|
|
146
|
+
for command in (select, evaluation, calibration):
|
|
147
|
+
command.add_argument("--model")
|
|
148
|
+
command.add_argument("--timeout-ms", type=float)
|
|
149
|
+
command.add_argument("--max-concurrency", type=int)
|
|
150
|
+
command.add_argument("--output", required=command is calibration)
|
|
151
|
+
args = parser.parse_args()
|
|
152
|
+
load_dotenv(args.env_file, override=False)
|
|
153
|
+
try:
|
|
154
|
+
if args.command in ("select", "eval", "calibrate"):
|
|
155
|
+
args.model = args.model or os.getenv("RAG_JEV_MODEL", "jev-latest")
|
|
156
|
+
if args.timeout_ms is None:
|
|
157
|
+
args.timeout_ms = float(os.getenv("RAG_JEV_TIMEOUT_MS", "5000"))
|
|
158
|
+
if args.max_concurrency is None:
|
|
159
|
+
args.max_concurrency = int(os.getenv("RAG_JEV_MAX_CONCURRENCY", "8"))
|
|
160
|
+
if args.command == "serve":
|
|
161
|
+
import uvicorn
|
|
162
|
+
|
|
163
|
+
from rag_jev.server import create_app
|
|
164
|
+
|
|
165
|
+
if not args.replay_only and not os.getenv("TYPESAFE_API_KEY", "").strip():
|
|
166
|
+
parser.error("set TYPESAFE_API_KEY in the environment or .env before serving")
|
|
167
|
+
if args.host not in ("127.0.0.1", "localhost", "::1") and not os.getenv(
|
|
168
|
+
"RAG_JEV_API_TOKEN"
|
|
169
|
+
):
|
|
170
|
+
parser.error("set RAG_JEV_API_TOKEN before binding outside localhost")
|
|
171
|
+
uvicorn.run(
|
|
172
|
+
create_app(replay_only=args.replay_only),
|
|
173
|
+
host=args.host,
|
|
174
|
+
port=args.port,
|
|
175
|
+
access_log=False,
|
|
176
|
+
)
|
|
177
|
+
elif args.command == "schema":
|
|
178
|
+
from rag_jev.server import create_app
|
|
179
|
+
|
|
180
|
+
emit(create_app().openapi())
|
|
181
|
+
else:
|
|
182
|
+
asyncio.run(run(args))
|
|
183
|
+
except ValidationError as exc:
|
|
184
|
+
emit(
|
|
185
|
+
{
|
|
186
|
+
"error": "invalid_input",
|
|
187
|
+
"issues": [
|
|
188
|
+
{"loc": list(e["loc"]), "message": e["msg"]}
|
|
189
|
+
for e in exc.errors(include_input=False)
|
|
190
|
+
],
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
raise SystemExit(2) from None
|
|
194
|
+
except ProviderError as exc:
|
|
195
|
+
print(f"Jev request failed: {exc.code}", file=sys.stderr)
|
|
196
|
+
raise SystemExit(1) from None
|
|
197
|
+
except TypeSafeError:
|
|
198
|
+
print("TypeSafe client failed; check API key and configuration.", file=sys.stderr)
|
|
199
|
+
raise SystemExit(1) from None
|
|
200
|
+
except (ValueError, OSError) as exc:
|
|
201
|
+
print(f"Invalid input or configuration ({type(exc).__name__}).", file=sys.stderr)
|
|
202
|
+
raise SystemExit(2) from None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
main()
|
rag_jev/demo.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Fixed examples with hand-authored scores. This is NOT a Jev benchmark or model."""
|
|
2
|
+
|
|
3
|
+
from rag_jev.evaluation import EvalCase, Trace, fingerprint
|
|
4
|
+
from rag_jev.models import Document, Judgment
|
|
5
|
+
from rag_jev.provider import ProviderError
|
|
6
|
+
|
|
7
|
+
CASES = [
|
|
8
|
+
EvalCase(
|
|
9
|
+
id="refund",
|
|
10
|
+
query="How many days do I have to request a refund?",
|
|
11
|
+
documents=[
|
|
12
|
+
Document(
|
|
13
|
+
id="shipping",
|
|
14
|
+
text="Orders ship within 5 business days.",
|
|
15
|
+
metadata={"source": "shipping.md"},
|
|
16
|
+
),
|
|
17
|
+
Document(
|
|
18
|
+
id="refund",
|
|
19
|
+
text="You may request a refund within 30 days of purchase.",
|
|
20
|
+
metadata={"source": "refunds.md", "page": 2},
|
|
21
|
+
),
|
|
22
|
+
Document(
|
|
23
|
+
id="receipt",
|
|
24
|
+
text="Keep your receipt; it is required for refund requests.",
|
|
25
|
+
metadata={"source": "refunds.md", "page": 3},
|
|
26
|
+
),
|
|
27
|
+
],
|
|
28
|
+
relevant_ids=["refund", "receipt"],
|
|
29
|
+
reference_answer="30 days",
|
|
30
|
+
),
|
|
31
|
+
EvalCase(
|
|
32
|
+
id="correction",
|
|
33
|
+
query="How do I renew my subscription every 30 days?",
|
|
34
|
+
documents=[
|
|
35
|
+
Document(id="annual", text="Subscriptions renew annually, not every 30 days."),
|
|
36
|
+
Document(id="renew", text="Annual subscriptions renew automatically unless canceled."),
|
|
37
|
+
Document(id="returns", text="Return physical products within 30 days."),
|
|
38
|
+
],
|
|
39
|
+
relevant_ids=["annual", "renew"],
|
|
40
|
+
reference_answer="annually",
|
|
41
|
+
),
|
|
42
|
+
EvalCase(
|
|
43
|
+
id="no-answer",
|
|
44
|
+
query="What is the office Wi-Fi password?",
|
|
45
|
+
documents=[
|
|
46
|
+
Document(id="hours", text="The office opens at 9 AM."),
|
|
47
|
+
Document(id="wifi", text="Wi-Fi is available in the office lobby."),
|
|
48
|
+
],
|
|
49
|
+
relevant_ids=[],
|
|
50
|
+
reference_answer="insufficient evidence",
|
|
51
|
+
),
|
|
52
|
+
]
|
|
53
|
+
SCORES = [[0.03, 0.98, 0.60], [0.97, 0.85, 0.02], [0.01, 0.08]]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fixture_traces() -> list[Trace]:
|
|
57
|
+
return [
|
|
58
|
+
Trace(
|
|
59
|
+
case_id=case.id,
|
|
60
|
+
input_hash=fingerprint(case),
|
|
61
|
+
elapsed_ms=0,
|
|
62
|
+
source="hand_authored_fixture_NOT_a_model_benchmark",
|
|
63
|
+
judgments=[Judgment(relevance=p, model="fixture-not-jev") for p in scores],
|
|
64
|
+
)
|
|
65
|
+
for case, scores in zip(CASES, SCORES, strict=True)
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class FixtureScorer:
|
|
70
|
+
async def score(self, query: str, document: Document, guidance: str | None) -> Judgment:
|
|
71
|
+
for case, trace in zip(CASES, fixture_traces(), strict=True):
|
|
72
|
+
if query == case.query:
|
|
73
|
+
for candidate, judgment in zip(case.documents, trace.judgments, strict=True):
|
|
74
|
+
if candidate.id == document.id and candidate.text == document.text:
|
|
75
|
+
return judgment
|
|
76
|
+
raise ProviderError("unknown_demo_fixture")
|