sentinelrag 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.
- sentinelrag/__init__.py +3 -0
- sentinelrag/api/__init__.py +1 -0
- sentinelrag/api/app.py +257 -0
- sentinelrag/eval/__init__.py +1 -0
- sentinelrag/eval/agreement.py +54 -0
- sentinelrag/eval/golden_io.py +38 -0
- sentinelrag/eval/judge.py +111 -0
- sentinelrag/eval/judges/v1/faithfulness.txt +17 -0
- sentinelrag/eval/judges/v1/manifest.yaml +8 -0
- sentinelrag/eval/judges/v1/relevance.txt +13 -0
- sentinelrag/eval/latency.py +15 -0
- sentinelrag/eval/metrics.py +39 -0
- sentinelrag/eval/phase1_retrieval.py +110 -0
- sentinelrag/eval/phase2_rag.py +123 -0
- sentinelrag/eval/trajectory.py +40 -0
- sentinelrag/gate/__init__.py +6 -0
- sentinelrag/gate/adapter.py +66 -0
- sentinelrag/gate/cli.py +114 -0
- sentinelrag/gate/evidence.py +105 -0
- sentinelrag/gate/policy.py +125 -0
- sentinelrag/gate/reports/__init__.py +1 -0
- sentinelrag/gate/reports/junit.py +50 -0
- sentinelrag/gate/reports/markdown.py +54 -0
- sentinelrag/gate/reports/sarif.py +62 -0
- sentinelrag/gate/runner.py +93 -0
- sentinelrag/generation/__init__.py +5 -0
- sentinelrag/generation/llm.py +190 -0
- sentinelrag/ingest/__init__.py +1 -0
- sentinelrag/ingest/chunking.py +113 -0
- sentinelrag/ingest/filters.py +21 -0
- sentinelrag/ingest/hygiene.py +90 -0
- sentinelrag/ingest/loaders.py +21 -0
- sentinelrag/ingest/pipeline.py +124 -0
- sentinelrag/ingest/provenance.py +43 -0
- sentinelrag/ingest/schema.py +23 -0
- sentinelrag/observability/__init__.py +1 -0
- sentinelrag/observability/cost.py +43 -0
- sentinelrag/observability/tracing.py +26 -0
- sentinelrag/paths.py +35 -0
- sentinelrag/redteam/__init__.py +1 -0
- sentinelrag/redteam/attacks.py +80 -0
- sentinelrag/redteam/defenses.py +28 -0
- sentinelrag/redteam/harness.py +126 -0
- sentinelrag/redteam/post_retrieval.py +58 -0
- sentinelrag/retrieval/__init__.py +1 -0
- sentinelrag/retrieval/agentic.py +68 -0
- sentinelrag/retrieval/bm25_index.py +37 -0
- sentinelrag/retrieval/embeddings.py +60 -0
- sentinelrag/retrieval/fusion.py +30 -0
- sentinelrag/retrieval/reranker.py +51 -0
- sentinelrag/retrieval/stores.py +145 -0
- sentinelrag/retrieval/strategies.py +84 -0
- sentinelrag/router/__init__.py +1 -0
- sentinelrag/router/complexity.py +19 -0
- sentinelrag/router/policy.py +42 -0
- sentinelrag/settings.py +100 -0
- sentinelrag-0.2.0.dist-info/METADATA +183 -0
- sentinelrag-0.2.0.dist-info/RECORD +61 -0
- sentinelrag-0.2.0.dist-info/WHEEL +4 -0
- sentinelrag-0.2.0.dist-info/entry_points.txt +3 -0
- sentinelrag-0.2.0.dist-info/licenses/LICENSE +21 -0
sentinelrag/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""FastAPI application package."""
|
sentinelrag/api/app.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""FastAPI entrypoint: health, ingest, and retrieval-backed query."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Literal
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, Query
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from sentinelrag import __version__
|
|
12
|
+
from sentinelrag.eval.phase2_rag import run_phase2_eval
|
|
13
|
+
from sentinelrag.generation.llm import Message, get_llm
|
|
14
|
+
from sentinelrag.ingest.pipeline import IndexState, ingest_corpus_dir, ingest_document_body
|
|
15
|
+
from sentinelrag.paths import mini_corpus_dir
|
|
16
|
+
from sentinelrag.retrieval.agentic import AgenticMultiHopRetriever
|
|
17
|
+
from sentinelrag.retrieval.embeddings import build_embedder
|
|
18
|
+
from sentinelrag.retrieval.reranker import build_reranker
|
|
19
|
+
from sentinelrag.retrieval.stores import ScoredChunk
|
|
20
|
+
from sentinelrag.retrieval.strategies import HybridRerankRetriever, NaiveRetriever, StrategyName
|
|
21
|
+
from sentinelrag.router.policy import route_query
|
|
22
|
+
from sentinelrag.settings import Settings, get_settings, settings_without_dotenv
|
|
23
|
+
|
|
24
|
+
ApiStrategy = StrategyName | Literal["agentic", "auto"]
|
|
25
|
+
|
|
26
|
+
HELLO_PASSAGE = {
|
|
27
|
+
"doc_id": "doc-hello-001",
|
|
28
|
+
"chunk_id": "chunk-hello-001",
|
|
29
|
+
"text": (
|
|
30
|
+
"SentinelRAG is an adaptive, attack-tested, eval-gated RAG platform. "
|
|
31
|
+
"Phase 0 proves a hello query path with MockLLM and citations."
|
|
32
|
+
),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HealthResponse(BaseModel):
|
|
37
|
+
status: str
|
|
38
|
+
version: str
|
|
39
|
+
env: str
|
|
40
|
+
llm_provider: str
|
|
41
|
+
index_ready: bool
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class IngestRequest(BaseModel):
|
|
45
|
+
corpus_path: str | None = Field(
|
|
46
|
+
default=None,
|
|
47
|
+
description="Filesystem path to a markdown corpus directory",
|
|
48
|
+
)
|
|
49
|
+
doc_id: str | None = None
|
|
50
|
+
title: str | None = None
|
|
51
|
+
text: str | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class IngestResponse(BaseModel):
|
|
55
|
+
chunks_indexed: int
|
|
56
|
+
corpus_path: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class QueryRequest(BaseModel):
|
|
60
|
+
query: str = Field(..., min_length=1, max_length=4000)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Citation(BaseModel):
|
|
64
|
+
doc_id: str
|
|
65
|
+
chunk_id: str
|
|
66
|
+
snippet: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class QueryResponse(BaseModel):
|
|
70
|
+
answer: str
|
|
71
|
+
citations: list[Citation]
|
|
72
|
+
strategy: str
|
|
73
|
+
provider: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class EvalRunResponse(BaseModel):
|
|
77
|
+
mean_faithfulness: float
|
|
78
|
+
mean_relevance: float
|
|
79
|
+
mean_recall_at_k: float
|
|
80
|
+
judge_prompt_version: str
|
|
81
|
+
n_golden_items: int
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
85
|
+
cfg = settings or get_settings()
|
|
86
|
+
app = FastAPI(title=cfg.app_name, version=__version__)
|
|
87
|
+
app.state.settings = cfg
|
|
88
|
+
app.state.index = IndexState()
|
|
89
|
+
|
|
90
|
+
def _maybe_bootstrap_index(current: Settings) -> None:
|
|
91
|
+
state: IndexState = app.state.index
|
|
92
|
+
if state.is_ready():
|
|
93
|
+
return
|
|
94
|
+
path = mini_corpus_dir()
|
|
95
|
+
if path.is_dir():
|
|
96
|
+
ingest_corpus_dir(path, state, settings=current)
|
|
97
|
+
|
|
98
|
+
@app.get("/health", response_model=HealthResponse)
|
|
99
|
+
def health() -> HealthResponse:
|
|
100
|
+
current = app.state.settings
|
|
101
|
+
assert isinstance(current, Settings)
|
|
102
|
+
state: IndexState = app.state.index
|
|
103
|
+
return HealthResponse(
|
|
104
|
+
status="ok",
|
|
105
|
+
version=__version__,
|
|
106
|
+
env=current.env,
|
|
107
|
+
llm_provider=current.llm_provider,
|
|
108
|
+
index_ready=state.is_ready(),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@app.post("/ingest", response_model=IngestResponse)
|
|
112
|
+
def ingest(body: IngestRequest) -> IngestResponse:
|
|
113
|
+
current = app.state.settings
|
|
114
|
+
assert isinstance(current, Settings)
|
|
115
|
+
state: IndexState = app.state.index
|
|
116
|
+
if body.text and body.doc_id:
|
|
117
|
+
count = ingest_document_body(
|
|
118
|
+
doc_id=body.doc_id,
|
|
119
|
+
title=body.title or body.doc_id,
|
|
120
|
+
text=body.text,
|
|
121
|
+
state=state,
|
|
122
|
+
settings=current,
|
|
123
|
+
)
|
|
124
|
+
return IngestResponse(chunks_indexed=count, corpus_path=None)
|
|
125
|
+
corpus = Path(body.corpus_path) if body.corpus_path else mini_corpus_dir()
|
|
126
|
+
count = ingest_corpus_dir(corpus, state, settings=current)
|
|
127
|
+
return IngestResponse(chunks_indexed=count, corpus_path=str(corpus))
|
|
128
|
+
|
|
129
|
+
@app.post("/query", response_model=QueryResponse)
|
|
130
|
+
def query_post(
|
|
131
|
+
body: QueryRequest,
|
|
132
|
+
strategy: Annotated[ApiStrategy, Query()] = "hybrid",
|
|
133
|
+
) -> QueryResponse:
|
|
134
|
+
current = app.state.settings
|
|
135
|
+
assert isinstance(current, Settings)
|
|
136
|
+
_maybe_bootstrap_index(current)
|
|
137
|
+
state: IndexState = app.state.index
|
|
138
|
+
llm = get_llm(current)
|
|
139
|
+
|
|
140
|
+
if not state.is_ready() or state.store is None or state.embedder is None:
|
|
141
|
+
prompt = (
|
|
142
|
+
f"Context: {HELLO_PASSAGE['text']}\n\n"
|
|
143
|
+
f"Question: {body.query}\n\n"
|
|
144
|
+
"Answer using only the context."
|
|
145
|
+
)
|
|
146
|
+
answer = llm.complete([Message(role="user", content=prompt)])
|
|
147
|
+
return QueryResponse(
|
|
148
|
+
answer=answer,
|
|
149
|
+
citations=[
|
|
150
|
+
Citation(
|
|
151
|
+
doc_id=HELLO_PASSAGE["doc_id"],
|
|
152
|
+
chunk_id=HELLO_PASSAGE["chunk_id"],
|
|
153
|
+
snippet=HELLO_PASSAGE["text"][:160],
|
|
154
|
+
)
|
|
155
|
+
],
|
|
156
|
+
strategy="hello_stub",
|
|
157
|
+
provider=llm.name,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
resolved = strategy
|
|
161
|
+
if strategy == "auto":
|
|
162
|
+
resolved = route_query(body.query, current).strategy
|
|
163
|
+
|
|
164
|
+
embedder = state.embedder or build_embedder(
|
|
165
|
+
current.embedding_model,
|
|
166
|
+
dim=current.embed_dim,
|
|
167
|
+
seed=current.benchmark_seed,
|
|
168
|
+
)
|
|
169
|
+
hits: list[ScoredChunk]
|
|
170
|
+
if resolved == "naive":
|
|
171
|
+
hits = NaiveRetriever(state.store, embedder).retrieve(
|
|
172
|
+
body.query, top_k=current.retrieval_top_k
|
|
173
|
+
)
|
|
174
|
+
elif resolved == "agentic":
|
|
175
|
+
hybrid = HybridRerankRetriever(
|
|
176
|
+
state.store,
|
|
177
|
+
state.bm25,
|
|
178
|
+
embedder,
|
|
179
|
+
build_reranker(current.reranker_model),
|
|
180
|
+
alpha=current.hybrid_alpha,
|
|
181
|
+
chunks_by_id=state.chunks,
|
|
182
|
+
)
|
|
183
|
+
agent = AgenticMultiHopRetriever(hybrid, max_hops=current.agent_max_hops)
|
|
184
|
+
hits = agent.retrieve(body.query, top_k=current.retrieval_top_k).chunks
|
|
185
|
+
resolved = "agentic"
|
|
186
|
+
else:
|
|
187
|
+
hits = HybridRerankRetriever(
|
|
188
|
+
state.store,
|
|
189
|
+
state.bm25,
|
|
190
|
+
embedder,
|
|
191
|
+
build_reranker(current.reranker_model),
|
|
192
|
+
alpha=current.hybrid_alpha,
|
|
193
|
+
chunks_by_id=state.chunks,
|
|
194
|
+
).retrieve(body.query, top_k=current.retrieval_top_k)
|
|
195
|
+
resolved = "hybrid"
|
|
196
|
+
context = "\n\n".join(h.text for h in hits) if hits else HELLO_PASSAGE["text"]
|
|
197
|
+
prompt = f"Context:\n{context}\n\nQuestion: {body.query}\n\nAnswer using only the context."
|
|
198
|
+
answer = llm.complete([Message(role="user", content=prompt)])
|
|
199
|
+
citations = [
|
|
200
|
+
Citation(doc_id=h.doc_id, chunk_id=h.chunk_id, snippet=h.text[:160]) for h in hits
|
|
201
|
+
]
|
|
202
|
+
if not citations:
|
|
203
|
+
citations = [
|
|
204
|
+
Citation(
|
|
205
|
+
doc_id=HELLO_PASSAGE["doc_id"],
|
|
206
|
+
chunk_id=HELLO_PASSAGE["chunk_id"],
|
|
207
|
+
snippet=HELLO_PASSAGE["text"][:160],
|
|
208
|
+
)
|
|
209
|
+
]
|
|
210
|
+
return QueryResponse(
|
|
211
|
+
answer=answer,
|
|
212
|
+
citations=citations,
|
|
213
|
+
strategy=resolved,
|
|
214
|
+
provider=llm.name,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
@app.post("/eval/run", response_model=EvalRunResponse)
|
|
218
|
+
def eval_run() -> EvalRunResponse:
|
|
219
|
+
current = app.state.settings
|
|
220
|
+
assert isinstance(current, Settings)
|
|
221
|
+
offline = settings_without_dotenv(
|
|
222
|
+
llm_provider="mock",
|
|
223
|
+
embedding_model="mock-embedder",
|
|
224
|
+
reranker_model="mock-reranker",
|
|
225
|
+
vector_store_backend="memory",
|
|
226
|
+
env=current.env,
|
|
227
|
+
)
|
|
228
|
+
report = run_phase2_eval(settings=offline)
|
|
229
|
+
return EvalRunResponse(
|
|
230
|
+
mean_faithfulness=float(report["mean_faithfulness"]),
|
|
231
|
+
mean_relevance=float(report["mean_relevance"]),
|
|
232
|
+
mean_recall_at_k=float(report["mean_recall_at_k"]),
|
|
233
|
+
judge_prompt_version=str(report["judge_prompt_version"]),
|
|
234
|
+
n_golden_items=int(report["n_golden_items"]),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return app
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
app = create_app()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main() -> None:
|
|
244
|
+
"""CLI entry for `uv run sentinelrag` / uvicorn."""
|
|
245
|
+
import uvicorn
|
|
246
|
+
|
|
247
|
+
settings = get_settings()
|
|
248
|
+
uvicorn.run(
|
|
249
|
+
"sentinelrag.api.app:app",
|
|
250
|
+
host=settings.host,
|
|
251
|
+
port=settings.port,
|
|
252
|
+
reload=False,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
if __name__ == "__main__":
|
|
257
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Golden datasets, metrics, and LLM-as-judge (Phase 2+)."""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Judge vs human agreement on a calibration set."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from sentinelrag.eval.judge import Judge, MockJudge
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class AgreementReport:
|
|
15
|
+
prompt_version: str
|
|
16
|
+
n_samples: int
|
|
17
|
+
mean_absolute_error: float
|
|
18
|
+
within_0_2: float
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_calibration(path: Path) -> tuple[str, list[dict[str, Any]]]:
|
|
22
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
23
|
+
version = str(payload.get("judge_prompt_version", "v1"))
|
|
24
|
+
samples = list(payload.get("samples", []))
|
|
25
|
+
return version, samples
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def judge_agreement(
|
|
29
|
+
path: Path,
|
|
30
|
+
*,
|
|
31
|
+
judge: Judge | None = None,
|
|
32
|
+
) -> AgreementReport:
|
|
33
|
+
version, samples = load_calibration(path)
|
|
34
|
+
j = judge or MockJudge(prompt_version=version)
|
|
35
|
+
if not samples:
|
|
36
|
+
return AgreementReport(version, 0, 0.0, 0.0)
|
|
37
|
+
abs_errors: list[float] = []
|
|
38
|
+
within: list[float] = []
|
|
39
|
+
for sample in samples:
|
|
40
|
+
human = float(sample["human_faithfulness"])
|
|
41
|
+
scored = j.faithfulness(
|
|
42
|
+
context=str(sample["context"]),
|
|
43
|
+
question=str(sample["question"]),
|
|
44
|
+
answer=str(sample["answer"]),
|
|
45
|
+
)
|
|
46
|
+
err = abs(scored.score - human)
|
|
47
|
+
abs_errors.append(err)
|
|
48
|
+
within.append(1.0 if err <= 0.2 else 0.0)
|
|
49
|
+
return AgreementReport(
|
|
50
|
+
prompt_version=version,
|
|
51
|
+
n_samples=len(samples),
|
|
52
|
+
mean_absolute_error=sum(abs_errors) / len(abs_errors),
|
|
53
|
+
within_0_2=sum(within) / len(within),
|
|
54
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Golden dataset JSONL interchange."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_jsonl(path: Path) -> list[dict[str, Any]]:
|
|
11
|
+
rows: list[dict[str, Any]] = []
|
|
12
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
13
|
+
line = line.strip()
|
|
14
|
+
if not line:
|
|
15
|
+
continue
|
|
16
|
+
rows.append(json.loads(line))
|
|
17
|
+
return rows
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def export_jsonl(items: list[dict[str, Any]], path: Path) -> None:
|
|
21
|
+
lines = [json.dumps(item, ensure_ascii=False) for item in items]
|
|
22
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def from_golden_dataset(dataset_path: Path, out_path: Path) -> None:
|
|
26
|
+
data = json.loads(dataset_path.read_text(encoding="utf-8"))
|
|
27
|
+
items = []
|
|
28
|
+
for row in data.get("items", []):
|
|
29
|
+
items.append(
|
|
30
|
+
{
|
|
31
|
+
"id": row["id"],
|
|
32
|
+
"query": row["query"],
|
|
33
|
+
"expected_chunk_ids": row.get("relevant_chunk_ids", []),
|
|
34
|
+
"kind": row.get("item_type", "single_hop"),
|
|
35
|
+
"citations_required": True,
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
export_jsonl(items, out_path)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""LLM-as-judge with versioned prompts and MockJudge for CI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from sentinelrag.generation.llm import LLMProvider, Message, get_llm
|
|
13
|
+
from sentinelrag.settings import Settings, get_settings
|
|
14
|
+
|
|
15
|
+
_JUDGE_ROOT = Path(__file__).resolve().parent / "judges"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class JudgeScore:
|
|
20
|
+
score: float
|
|
21
|
+
reason: str
|
|
22
|
+
prompt_version: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Judge(ABC):
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def faithfulness(self, *, context: str, question: str, answer: str) -> JudgeScore:
|
|
28
|
+
"""Score how well ``answer`` is supported by ``context``."""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def relevance(self, *, question: str, answer: str) -> JudgeScore:
|
|
32
|
+
"""Score how well ``answer`` addresses ``question``."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_prompt(version: str, name: str) -> str:
|
|
36
|
+
path = _JUDGE_ROOT / version / f"{name}.txt"
|
|
37
|
+
if not path.is_file():
|
|
38
|
+
msg = f"Missing judge prompt: {path}"
|
|
39
|
+
raise FileNotFoundError(msg)
|
|
40
|
+
return path.read_text(encoding="utf-8")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_judge_json(raw: str) -> tuple[float, str]:
|
|
44
|
+
match = re.search(r"\{.*\}", raw, flags=re.DOTALL)
|
|
45
|
+
payload = json.loads(match.group(0) if match else raw)
|
|
46
|
+
score = float(payload["score"])
|
|
47
|
+
reason = str(payload.get("reason", ""))
|
|
48
|
+
return max(0.0, min(1.0, score)), reason
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MockJudge(Judge):
|
|
52
|
+
"""Deterministic lexical judge for offline CI (not a human surrogate)."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, *, prompt_version: str = "v1") -> None:
|
|
55
|
+
self.prompt_version = prompt_version
|
|
56
|
+
|
|
57
|
+
def faithfulness(self, *, context: str, question: str, answer: str) -> JudgeScore:
|
|
58
|
+
_ = question
|
|
59
|
+
ctx_tokens = set(context.lower().split())
|
|
60
|
+
ans_tokens = [t for t in answer.lower().split() if len(t) > 3]
|
|
61
|
+
if not ans_tokens:
|
|
62
|
+
return JudgeScore(0.0, "empty answer", self.prompt_version)
|
|
63
|
+
supported = sum(1 for t in ans_tokens if t in ctx_tokens)
|
|
64
|
+
score = supported / len(ans_tokens)
|
|
65
|
+
# Stable tie-break from content hash for reproducibility.
|
|
66
|
+
digest = hashlib.sha256(f"{context}:{answer}".encode()).hexdigest()
|
|
67
|
+
jitter = (int(digest[:4], 16) % 7) / 100.0
|
|
68
|
+
score = min(1.0, score * 0.85 + jitter)
|
|
69
|
+
return JudgeScore(round(score, 4), "mock lexical overlap", self.prompt_version)
|
|
70
|
+
|
|
71
|
+
def relevance(self, *, question: str, answer: str) -> JudgeScore:
|
|
72
|
+
q = set(question.lower().split())
|
|
73
|
+
a = set(answer.lower().split())
|
|
74
|
+
overlap = len(q & a) / max(len(q), 1)
|
|
75
|
+
score = min(1.0, overlap + 0.5)
|
|
76
|
+
return JudgeScore(round(score, 4), "mock question overlap", self.prompt_version)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LLMJudge(Judge):
|
|
80
|
+
"""Uses configured LLM provider with versioned prompts (network in prod)."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
llm: LLMProvider | None = None,
|
|
86
|
+
prompt_version: str = "v1",
|
|
87
|
+
settings: Settings | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
self._llm = llm or get_llm(settings or get_settings())
|
|
90
|
+
self.prompt_version = prompt_version
|
|
91
|
+
|
|
92
|
+
def faithfulness(self, *, context: str, question: str, answer: str) -> JudgeScore:
|
|
93
|
+
template = load_prompt(self.prompt_version, "faithfulness")
|
|
94
|
+
prompt = template.format(context=context, question=question, answer=answer)
|
|
95
|
+
raw = self._llm.complete([Message(role="user", content=prompt)])
|
|
96
|
+
score, reason = _parse_judge_json(raw)
|
|
97
|
+
return JudgeScore(score, reason, self.prompt_version)
|
|
98
|
+
|
|
99
|
+
def relevance(self, *, question: str, answer: str) -> JudgeScore:
|
|
100
|
+
template = load_prompt(self.prompt_version, "relevance")
|
|
101
|
+
prompt = template.format(question=question, answer=answer)
|
|
102
|
+
raw = self._llm.complete([Message(role="user", content=prompt)])
|
|
103
|
+
score, reason = _parse_judge_json(raw)
|
|
104
|
+
return JudgeScore(score, reason, self.prompt_version)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_judge(settings: Settings | None = None) -> Judge:
|
|
108
|
+
cfg = settings or get_settings()
|
|
109
|
+
if cfg.llm_provider == "mock":
|
|
110
|
+
return MockJudge()
|
|
111
|
+
return LLMJudge(settings=cfg)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
You are evaluating whether an assistant answer is faithful to the provided context.
|
|
2
|
+
|
|
3
|
+
Rules:
|
|
4
|
+
- Score 1.0 only if every factual claim in the answer is supported by the context.
|
|
5
|
+
- Score 0.0 if the answer contradicts the context or invents major facts not in the context.
|
|
6
|
+
- Partial support → score between 0.0 and 1.0.
|
|
7
|
+
|
|
8
|
+
Respond with JSON only: {"score": <number>, "reason": "<short explanation>"}
|
|
9
|
+
|
|
10
|
+
Context:
|
|
11
|
+
{context}
|
|
12
|
+
|
|
13
|
+
Question:
|
|
14
|
+
{question}
|
|
15
|
+
|
|
16
|
+
Answer:
|
|
17
|
+
{answer}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
You are evaluating whether an assistant answer addresses the user question.
|
|
2
|
+
|
|
3
|
+
Rules:
|
|
4
|
+
- Score 1.0 if the answer directly addresses the question.
|
|
5
|
+
- Score 0.0 if it is off-topic or refuses without cause.
|
|
6
|
+
|
|
7
|
+
Respond with JSON only: {"score": <number>, "reason": "<short explanation>"}
|
|
8
|
+
|
|
9
|
+
Question:
|
|
10
|
+
{question}
|
|
11
|
+
|
|
12
|
+
Answer:
|
|
13
|
+
{answer}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Lightweight latency helpers for gate evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def timed_ms(fn: Callable[[], T]) -> tuple[T, float]:
|
|
13
|
+
t0 = time.perf_counter()
|
|
14
|
+
value = fn()
|
|
15
|
+
return value, (time.perf_counter() - t0) * 1000.0
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Retrieval and grounding evaluation metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def recall_at_k(retrieved_ids: list[str], relevant_ids: set[str], *, k: int) -> float:
|
|
9
|
+
if not relevant_ids:
|
|
10
|
+
return 0.0
|
|
11
|
+
top = set(retrieved_ids[:k])
|
|
12
|
+
return len(top & relevant_ids) / len(relevant_ids)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def hit_rate_at_k(retrieved_ids: list[str], relevant_ids: set[str], *, k: int) -> float:
|
|
16
|
+
"""1.0 if at least one relevant id appears in top-k, else 0.0."""
|
|
17
|
+
if not relevant_ids:
|
|
18
|
+
return 0.0
|
|
19
|
+
return 1.0 if set(retrieved_ids[:k]) & relevant_ids else 0.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def mrr(retrieved_ids: list[str], relevant_ids: set[str]) -> float:
|
|
23
|
+
for rank, cid in enumerate(retrieved_ids, start=1):
|
|
24
|
+
if cid in relevant_ids:
|
|
25
|
+
return 1.0 / rank
|
|
26
|
+
return 0.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_CITATION = re.compile(r"\[(?:cite|source|doc|chunk)[:\s]?([^\]]+)\]", re.IGNORECASE)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def citation_coverage(answer: str, retrieved_ids: list[str]) -> float:
|
|
33
|
+
"""Share of citation markers that point at retrieved chunk ids (0–1)."""
|
|
34
|
+
marks = _CITATION.findall(answer)
|
|
35
|
+
if not marks:
|
|
36
|
+
return 0.0
|
|
37
|
+
allowed = set(retrieved_ids)
|
|
38
|
+
hits = sum(1 for m in marks if m.strip() in allowed)
|
|
39
|
+
return hits / len(marks)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Phase 1 retrieval benchmark (library entry for scripts and tests)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from sentinelrag.eval.metrics import hit_rate_at_k, mrr, recall_at_k
|
|
13
|
+
from sentinelrag.ingest.pipeline import IndexState, ingest_corpus_dir
|
|
14
|
+
from sentinelrag.paths import mini_corpus_dir, mini_qrels_path
|
|
15
|
+
from sentinelrag.retrieval.reranker import build_reranker
|
|
16
|
+
from sentinelrag.retrieval.strategies import HybridRerankRetriever, NaiveRetriever, StrategyName
|
|
17
|
+
from sentinelrag.settings import Settings, settings_without_dotenv
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _default_benchmark_settings() -> Settings:
|
|
21
|
+
"""Offline benchmark defaults (mock embedder; ignores ``.env`` file only)."""
|
|
22
|
+
return settings_without_dotenv(
|
|
23
|
+
embedding_model="mock-embedder",
|
|
24
|
+
reranker_model="mock-reranker",
|
|
25
|
+
vector_store_backend="memory",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def corpus_content_hash(corpus_dir: Path) -> str:
|
|
30
|
+
digests: list[str] = []
|
|
31
|
+
for path in sorted(corpus_dir.glob("*.md")):
|
|
32
|
+
digests.append(hashlib.sha256(path.read_bytes()).hexdigest())
|
|
33
|
+
joined = "|".join(digests)
|
|
34
|
+
return hashlib.sha256(joined.encode()).hexdigest()[:16]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_qrels(path: Path) -> tuple[int, list[dict[str, Any]]]:
|
|
38
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
39
|
+
seed = int(payload.get("seed", 42))
|
|
40
|
+
queries = list(payload.get("queries", []))
|
|
41
|
+
return seed, queries
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_benchmark(
|
|
45
|
+
*,
|
|
46
|
+
settings: Settings | None = None,
|
|
47
|
+
strategies: tuple[StrategyName, ...] = ("naive", "hybrid"),
|
|
48
|
+
corpus_dir: Path | None = None,
|
|
49
|
+
qrels_path: Path | None = None,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
cfg = settings if settings is not None else _default_benchmark_settings()
|
|
52
|
+
corpus_dir = corpus_dir if corpus_dir is not None else mini_corpus_dir()
|
|
53
|
+
qrels_path = qrels_path if qrels_path is not None else mini_qrels_path()
|
|
54
|
+
seed, qrels = load_qrels(qrels_path)
|
|
55
|
+
|
|
56
|
+
state = IndexState()
|
|
57
|
+
ingest_corpus_dir(corpus_dir, state, settings=cfg)
|
|
58
|
+
assert state.store is not None and state.embedder is not None
|
|
59
|
+
|
|
60
|
+
embedder = state.embedder
|
|
61
|
+
k = cfg.retrieval_top_k
|
|
62
|
+
rows: list[dict[str, Any]] = []
|
|
63
|
+
|
|
64
|
+
for strategy in strategies:
|
|
65
|
+
retriever: NaiveRetriever | HybridRerankRetriever
|
|
66
|
+
if strategy == "naive":
|
|
67
|
+
retriever = NaiveRetriever(state.store, embedder)
|
|
68
|
+
else:
|
|
69
|
+
retriever = HybridRerankRetriever(
|
|
70
|
+
state.store,
|
|
71
|
+
state.bm25,
|
|
72
|
+
embedder,
|
|
73
|
+
build_reranker(cfg.reranker_model),
|
|
74
|
+
alpha=cfg.hybrid_alpha,
|
|
75
|
+
chunks_by_id=state.chunks,
|
|
76
|
+
)
|
|
77
|
+
recalls: list[float] = []
|
|
78
|
+
hits_rate: list[float] = []
|
|
79
|
+
mrrs: list[float] = []
|
|
80
|
+
latencies: list[float] = []
|
|
81
|
+
for item in qrels:
|
|
82
|
+
query = str(item["query"])
|
|
83
|
+
relevant = set(item["relevant_chunk_ids"])
|
|
84
|
+
|
|
85
|
+
t0 = time.perf_counter()
|
|
86
|
+
retrieved_ids = [h.chunk_id for h in retriever.retrieve(query, top_k=k)]
|
|
87
|
+
latencies.append((time.perf_counter() - t0) * 1000.0)
|
|
88
|
+
recalls.append(recall_at_k(retrieved_ids, relevant, k=k))
|
|
89
|
+
hits_rate.append(hit_rate_at_k(retrieved_ids, relevant, k=k))
|
|
90
|
+
mrrs.append(mrr(retrieved_ids, relevant))
|
|
91
|
+
rows.append(
|
|
92
|
+
{
|
|
93
|
+
"strategy": strategy,
|
|
94
|
+
"recall_at_k": sum(recalls) / len(recalls) if recalls else 0.0,
|
|
95
|
+
"hit_rate_at_k": sum(hits_rate) / len(hits_rate) if hits_rate else 0.0,
|
|
96
|
+
"mrr": sum(mrrs) / len(mrrs) if mrrs else 0.0,
|
|
97
|
+
"latency_ms_mean": sum(latencies) / len(latencies) if latencies else 0.0,
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"generated_at": datetime.now(tz=UTC).isoformat(),
|
|
103
|
+
"embedding_model": cfg.embedding_model,
|
|
104
|
+
"reranker_model": cfg.reranker_model,
|
|
105
|
+
"corpus_hash": corpus_content_hash(corpus_dir),
|
|
106
|
+
"seed": seed,
|
|
107
|
+
"retrieval_top_k": k,
|
|
108
|
+
"hybrid_alpha": cfg.hybrid_alpha,
|
|
109
|
+
"results": rows,
|
|
110
|
+
}
|