Skip to content

Commit b12bfc8

Browse files
EnjoyBacon7aditykris
authored andcommitted
feat(evaluation): admin API routes, and promptfoo in both images
Six admin-only routes under `/evaluation`, plus the response schemas and the router registration. Every route is admin-gated: a run indexes a corpus, spends grader tokens and occupies the single runner slot, so it is not something a partition editor should be able to trigger. `create_dataset` passes the open `UploadFile.file` streams to the service rather than reading them. Large uploads are already spooled to disk by Starlette; reading them in the router would pull them back into memory unbounded. Node 22 and a pinned promptfoo are installed in both images. It goes in api.Dockerfile as well as ray.Dockerfile because compose runs Ray inside the API container; a deployment with a separate Ray cluster needs the ray image. Pinned rather than `npx promptfoo@latest` so a run never depends on npm reachability or on CLI behaviour changing under a deployment that was not rebuilt. Node comes from NodeSource because the distro package predates promptfoo's floor.
1 parent a475b79 commit b12bfc8

5 files changed

Lines changed: 275 additions & 2 deletions

File tree

infra/docker/api.Dockerfile

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,21 @@ RUN apt-get update && apt-get install -y \
1818

1919
# install ffmpeg
2020
RUN apt update && \
21-
apt install -y ffmpeg
21+
apt install -y ffmpeg
22+
23+
# Node + promptfoo back the admin evaluation page, where EvalRunner shells out
24+
# to `promptfoo eval`. Also installed in ray.Dockerfile: Ray runs inside this
25+
# container unless the deployment uses a separate cluster. Pinned rather than
26+
# resolved at run time so a run never depends on npm reachability.
27+
ARG PROMPTFOO_VERSION=0.121.19
28+
# Node comes from NodeSource: the distro package predates promptfoo's floor.
29+
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
30+
&& apt-get install -y --no-install-recommends nodejs \
31+
&& npm install -g promptfoo@${PROMPTFOO_VERSION} \
32+
&& npm cache clean --force \
33+
&& rm -rf /var/lib/apt/lists/*
34+
ENV PROMPTFOO_DISABLE_TELEMETRY=1 \
35+
PROMPTFOO_DISABLE_UPDATE=1
2236

2337
# Set environment variables for Hugging Face cache location
2438
ENV XDG_CACHE_HOME=${XDG_CACHE_HOME:-/app/model_weights}

infra/docker/ray.Dockerfile

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,20 @@ RUN apt-get update && apt-get install -y \
1919

2020
# install ffmpeg
2121
RUN apt update && \
22-
apt install -y ffmpeg
22+
apt install -y ffmpeg
23+
24+
# Node + promptfoo back the admin evaluation page, where EvalRunner shells out
25+
# to `promptfoo eval`. Pinned rather than resolved at run time so a run never
26+
# depends on npm reachability.
27+
ARG PROMPTFOO_VERSION=0.121.19
28+
# Node comes from NodeSource: the distro package predates promptfoo's floor.
29+
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
30+
&& apt-get install -y --no-install-recommends nodejs \
31+
&& npm install -g promptfoo@${PROMPTFOO_VERSION} \
32+
&& npm cache clean --force \
33+
&& rm -rf /var/lib/apt/lists/*
34+
ENV PROMPTFOO_DISABLE_TELEMETRY=1 \
35+
PROMPTFOO_DISABLE_UPDATE=1
2336

2437

2538
# Set environment variables for Hugging Face cache location

openrag/api/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
SecurityHeadersMiddleware,
4141
)
4242
from api.routers.admin.cluster import router as actors_router
43+
from api.routers.admin.evaluation import router as evaluation_router
4344
from api.routers.admin.indexing import router as indexer_router
4445
from api.routers.admin.jobs import router as queue_router
4546
from api.routers.admin.model_endpoints import router as model_endpoints_router
@@ -117,6 +118,7 @@ class Tags(Enum):
117118
PARTITION = "Partitions & files"
118119
MODEL_ENDPOINTS = "Model Endpoints"
119120
PRESETS = "Presets"
121+
EVALUATION = "Evaluation"
120122
QUEUE = "Queue management"
121123
ACTORS = "Ray Actors"
122124
USERS = "User management"
@@ -357,6 +359,7 @@ def get_config():
357359
app.include_router(partition_router, prefix="/partition", tags=[Tags.PARTITION])
358360
app.include_router(model_endpoints_router, prefix="/model-endpoints", tags=[Tags.MODEL_ENDPOINTS])
359361
app.include_router(presets_router, prefix="/presets", tags=[Tags.PRESETS])
362+
app.include_router(evaluation_router, prefix="/evaluation", tags=[Tags.EVALUATION])
360363
app.include_router(queue_router, prefix="/queue", tags=[Tags.QUEUE])
361364
app.include_router(actors_router, prefix="/actors", tags=[Tags.ACTORS])
362365
app.include_router(users_router, prefix="/users", tags=[Tags.USERS])
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Admin routes for the evaluation page.
2+
3+
Datasets are uploaded once and replayed by runs. Every route is admin-only:
4+
a run indexes a corpus, spends grader tokens, and occupies the single runner
5+
slot, so it is not something a partition editor should be able to trigger.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from dataclasses import asdict
11+
from typing import Any
12+
13+
from api.dependencies.auth import current_user, require_admin
14+
from api.schemas.admin.evaluation_schemas import (
15+
EvalDatasetResponse,
16+
EvalRunResponse,
17+
EvalRunSummaryResponse,
18+
StartRunRequest,
19+
)
20+
from di.providers import get_evaluation_service
21+
from fastapi import APIRouter, Depends, File, Form, UploadFile, status
22+
23+
router = APIRouter(dependencies=[Depends(require_admin)])
24+
25+
26+
def _run_summary(run: Any) -> EvalRunSummaryResponse:
27+
return EvalRunSummaryResponse(
28+
id=run.id,
29+
dataset_id=run.dataset_id,
30+
status=run.status.value,
31+
started_at=run.started_at,
32+
finished_at=run.finished_at,
33+
hit_rate=run.retrieval.hit_rate if run.retrieval else None,
34+
mrr=run.retrieval.mrr if run.retrieval else None,
35+
answer_pass_rate=run.answer.pass_rate if run.answer else None,
36+
files_per_minute=run.indexing.files_per_minute if run.indexing else None,
37+
error=run.error,
38+
)
39+
40+
41+
def _run_detail(run: Any) -> EvalRunResponse:
42+
return EvalRunResponse(
43+
id=run.id,
44+
dataset_id=run.dataset_id,
45+
status=run.status.value,
46+
started_at=run.started_at,
47+
finished_at=run.finished_at,
48+
indexing=asdict(run.indexing) if run.indexing else None,
49+
retrieval=asdict(run.retrieval) if run.retrieval else None,
50+
answer=asdict(run.answer) if run.answer else None,
51+
cases=[asdict(case) for case in run.cases],
52+
error=run.error,
53+
created_by=run.created_by,
54+
)
55+
56+
57+
@router.get("/datasets", response_model=list[EvalDatasetResponse])
58+
async def list_datasets(service=Depends(get_evaluation_service)):
59+
"""List stored evaluation datasets, newest first."""
60+
return [asdict(dataset) for dataset in await service.list_datasets()]
61+
62+
63+
@router.post(
64+
"/datasets",
65+
response_model=EvalDatasetResponse,
66+
status_code=status.HTTP_201_CREATED,
67+
)
68+
async def create_dataset(
69+
name: str = Form(..., description="Human-readable dataset name"),
70+
testset: UploadFile = File(..., description="CSV: question,expected_answer,expected_file_ids"),
71+
corpus: list[UploadFile] = File(..., description="Documents to index for the run"),
72+
user=Depends(current_user),
73+
service=Depends(get_evaluation_service),
74+
):
75+
"""Upload a corpus and its test set.
76+
77+
The CSV is validated here, so a bad test set fails now rather than after a
78+
run has already indexed the corpus.
79+
"""
80+
# Pass the open streams, not the bytes: large uploads are already spooled
81+
# to disk, and reading them here would pull them into memory unbounded.
82+
dataset = await service.create_dataset(
83+
name=name,
84+
corpus=[(upload.filename or "unnamed", upload.file) for upload in corpus],
85+
testset=testset.file,
86+
user_id=user.get("id") if isinstance(user, dict) else None,
87+
)
88+
return asdict(dataset)
89+
90+
91+
@router.delete("/datasets/{dataset_id}", status_code=status.HTTP_204_NO_CONTENT)
92+
async def delete_dataset(dataset_id: str, service=Depends(get_evaluation_service)):
93+
"""Delete a dataset and its stored files."""
94+
await service.delete_dataset(dataset_id)
95+
96+
97+
@router.get("/runs", response_model=list[EvalRunSummaryResponse])
98+
async def list_runs(limit: int = 50, service=Depends(get_evaluation_service)):
99+
"""Run history, newest first."""
100+
return [_run_summary(run) for run in await service.list_runs(limit)]
101+
102+
103+
@router.post("/runs", response_model=EvalRunResponse, status_code=status.HTTP_202_ACCEPTED)
104+
async def start_run(
105+
body: StartRunRequest,
106+
user=Depends(current_user),
107+
service=Depends(get_evaluation_service),
108+
):
109+
"""Queue a run against a dataset.
110+
111+
Returns ``409`` when a run is already in flight — runs execute one at a
112+
time so that indexing timings stay comparable between them.
113+
"""
114+
run = await service.start_run(
115+
body.dataset_id,
116+
user.get("id") if isinstance(user, dict) else None,
117+
)
118+
return _run_detail(run)
119+
120+
121+
@router.get("/runs/{run_id}", response_model=EvalRunResponse)
122+
async def get_run(run_id: str, service=Depends(get_evaluation_service)):
123+
"""One run with its metrics and per-question detail."""
124+
return _run_detail(await service.get_run(run_id))
125+
126+
127+
@router.post("/runs/{run_id}/cancel", response_model=EvalRunResponse)
128+
async def cancel_run(run_id: str, service=Depends(get_evaluation_service)):
129+
"""Ask the runner to abandon an in-flight run."""
130+
return _run_detail(await service.cancel_run(run_id))
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Response models for the admin evaluation endpoints."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import datetime
6+
7+
from pydantic import BaseModel, Field
8+
9+
10+
class EvalDatasetResponse(BaseModel):
11+
"""A stored corpus + test set."""
12+
13+
id: str
14+
name: str
15+
corpus_file_count: int
16+
testset_row_count: int
17+
created_at: datetime | None = None
18+
created_by: int | None = None
19+
20+
21+
class FileIndexingSampleResponse(BaseModel):
22+
filename: str
23+
size_bytes: int
24+
duration_seconds: float
25+
failed: bool = False
26+
27+
28+
class IndexingMetricsResponse(BaseModel):
29+
files_total: int
30+
files_failed: int
31+
bytes_total: int
32+
wall_seconds: float
33+
files_per_minute: float
34+
megabytes_per_second: float
35+
p50_seconds: float
36+
p95_seconds: float
37+
by_extension: dict[str, dict[str, float]] = Field(default_factory=dict)
38+
samples: list[FileIndexingSampleResponse] = Field(default_factory=list)
39+
40+
41+
class RetrievalMetricsResponse(BaseModel):
42+
scored_cases: int
43+
skipped_cases: int
44+
hit_rate: float
45+
mrr: float
46+
recall: float
47+
context_relevance: float | None = None
48+
49+
50+
class AnswerMetricsResponse(BaseModel):
51+
scored_cases: int
52+
pass_rate: float
53+
factuality: float | None = None
54+
rubric_score: float | None = None
55+
56+
57+
class EvalCaseResponse(BaseModel):
58+
query: str
59+
retrieved_file_ids: list[str] = Field(default_factory=list)
60+
expected_file_ids: list[str] = Field(default_factory=list)
61+
hit: bool | None = None
62+
reciprocal_rank: float | None = None
63+
answer: str | None = None
64+
answer_passed: bool | None = None
65+
grader_reason: str | None = None
66+
67+
68+
class EvalRunResponse(BaseModel):
69+
"""A run, with metrics once it has finished."""
70+
71+
id: str
72+
dataset_id: str
73+
status: str
74+
started_at: datetime | None = None
75+
finished_at: datetime | None = None
76+
indexing: IndexingMetricsResponse | None = None
77+
retrieval: RetrievalMetricsResponse | None = None
78+
answer: AnswerMetricsResponse | None = None
79+
cases: list[EvalCaseResponse] = Field(default_factory=list)
80+
error: str | None = None
81+
created_by: int | None = None
82+
83+
84+
class EvalRunSummaryResponse(BaseModel):
85+
"""Run history row — metrics headline only, no per-case detail."""
86+
87+
id: str
88+
dataset_id: str
89+
status: str
90+
started_at: datetime | None = None
91+
finished_at: datetime | None = None
92+
hit_rate: float | None = None
93+
mrr: float | None = None
94+
answer_pass_rate: float | None = None
95+
files_per_minute: float | None = None
96+
error: str | None = None
97+
98+
99+
class StartRunRequest(BaseModel):
100+
dataset_id: str
101+
102+
103+
__all__ = [
104+
"AnswerMetricsResponse",
105+
"EvalCaseResponse",
106+
"EvalDatasetResponse",
107+
"EvalRunResponse",
108+
"EvalRunSummaryResponse",
109+
"FileIndexingSampleResponse",
110+
"IndexingMetricsResponse",
111+
"RetrievalMetricsResponse",
112+
"StartRunRequest",
113+
]

0 commit comments

Comments
 (0)