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.
Files changed (47) hide show
  1. rag_jev/__init__.py +15 -0
  2. rag_jev/calibration.py +353 -0
  3. rag_jev/cli.py +206 -0
  4. rag_jev/demo.py +76 -0
  5. rag_jev/evaluation.py +275 -0
  6. rag_jev/generation.py +223 -0
  7. rag_jev/integrations/__init__.py +1 -0
  8. rag_jev/integrations/langchain.py +124 -0
  9. rag_jev/models.py +142 -0
  10. rag_jev/provider.py +167 -0
  11. rag_jev/py.typed +0 -0
  12. rag_jev/review.py +57 -0
  13. rag_jev/selector.py +256 -0
  14. rag_jev/server.py +253 -0
  15. rag_jev/static/app.js +481 -0
  16. rag_jev/static/benchmark-results.svg +2115 -0
  17. rag_jev/static/benchmarks.html +38 -0
  18. rag_jev/static/fonts.css +1 -0
  19. rag_jev/static/index.html +236 -0
  20. rag_jev/static/replays/MUSIQUE_LICENSE +396 -0
  21. rag_jev/static/replays/catalog.json +14 -0
  22. rag_jev/static/replays/improvement.json +406 -0
  23. rag_jev/static/replays/regression.json +407 -0
  24. rag_jev/static/research-v2-protocol.json +74 -0
  25. rag_jev/static/research-v2-results.json +206 -0
  26. rag_jev/static/research-v2-results.svg +2401 -0
  27. rag_jev/static/research-v2.html +19 -0
  28. rag_jev/static/research-v3-protocol.json +186 -0
  29. rag_jev/static/research-v3-results.json +641 -0
  30. rag_jev/static/research-v3-results.svg +2323 -0
  31. rag_jev/static/research-v3-verification.json +16 -0
  32. rag_jev/static/research-v3.html +20 -0
  33. rag_jev/static/retrieval-v1-protocol.json +48 -0
  34. rag_jev/static/retrieval-v1-results.json +46 -0
  35. rag_jev/static/retrieval-v1-verification.json +15 -0
  36. rag_jev/static/retrieval-v1.html +12 -0
  37. rag_jev/static/review.html +16 -0
  38. rag_jev/static/review.js +51 -0
  39. rag_jev/static/style.css +538 -0
  40. rag_jev/tokens.py +16 -0
  41. rag_jev/workbench.py +281 -0
  42. rag_jev-0.2.0.dist-info/METADATA +340 -0
  43. rag_jev-0.2.0.dist-info/RECORD +47 -0
  44. rag_jev-0.2.0.dist-info/WHEEL +4 -0
  45. rag_jev-0.2.0.dist-info/entry_points.txt +2 -0
  46. rag_jev-0.2.0.dist-info/licenses/LICENSE +21 -0
  47. 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")