Skip to content

Commit 2898d88

Browse files
authored
Merge branch 'main' into fix/834-status-code-consistency
2 parents c946b90 + e0c64fc commit 2898d88

32 files changed

Lines changed: 3931 additions & 209 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Add is_judge_run to evaluation_run
2+
3+
Revision ID: 075
4+
Revises: 074
5+
Create Date: 2026-07-16 00:00:00.000000
6+
7+
The chunked aggregate task only knows an eval_run_id, so the judge intent has to be
8+
durable on the run row for it to read at judge time.
9+
"""
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
revision = "075"
15+
down_revision = "074"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
op.add_column(
22+
"evaluation_run",
23+
sa.Column(
24+
"is_judge_run",
25+
sa.Boolean(),
26+
nullable=True,
27+
comment=(
28+
"True for v2 runs that run the native LLM-as-judge (and skip the "
29+
"Langfuse score sync). NULL/False = v1 run, cosine-only, Langfuse-synced"
30+
),
31+
),
32+
)
33+
34+
35+
def downgrade():
36+
op.drop_column("evaluation_run", "is_judge_run")
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
Upload a CSV of golden Q&A pairs for evaluation, stored natively by Kaapi with no
2+
Langfuse dataset (v2).
3+
4+
Same multipart shape as the v1 dataset upload (`file`, `dataset_name`,
5+
`description`, `duplication_factor`), but the dataset is stored in object storage
6+
(S3) only: the row is created with `langfuse_dataset_id` null and the CSV holds
7+
exactly the **original** rows — no physical duplication. The `duplication_factor`
8+
is recorded in `dataset_metadata` (with a run-time-duplication marker) and applied
9+
when the run executes, so an 8-row CSV with factor 5 evaluates 40 rows.
10+
11+
**Response:** `APIResponse[DatasetUploadResponse]`, same shape as v1 with
12+
`langfuse_dataset_id` null. `original_items` is the stored row count and
13+
`total_items` is `original_items × duplication_factor` (the count the run will
14+
produce, not stored rows). `eligible_for_fast` reflects only the unique-row size
15+
cap (`EVAL_FAST_MAX_UNIQUE_ROWS`).
16+
17+
**CSV format:** required columns `question`, `answer`; optional `category`. Rows
18+
with a missing question or answer are skipped. Maximum upload size is 1 MB.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
Start a v2 evaluation run. Replicates the v1 `POST /api/v1/evaluations` request
2+
body with Kaapi's native LLM-as-Judge built in — v1 is left unchanged.
3+
4+
v2 runs are **always fast** and always judged (there is no `run_mode`; batch is
5+
deferred to a later phase). Every scoreable row is automatically judged (no opt-in
6+
flag) by one combined LLM-judge call scoring each applicable metric in [0, 1] with
7+
reasoning: **Adherence to Ground Truth** (answer conveys the same correct
8+
information as the golden answer), **Adherence to Prompt** (answer obeys the
9+
assistant's configured instructions; applies only when the run resolves a config
10+
prompt), and **Adherence to Knowledge Base** (groundedness of the answer against
11+
the retrieved chunks; applies only to rows that retrieved chunks). Per-row scores +
12+
reasoning are stored natively by Kaapi in the `score_trace_url` trace unit; the
13+
single `judge` cost stage covers the one combined call. v2 runs compute **no cosine
14+
similarity** and do **not** touch Langfuse.
15+
16+
Judging is system-config only: the judge always uses the configured model
17+
(`EVAL_JUDGE_MODEL`, default `gpt-5-mini`) and the built-in per-metric prompts.
18+
There is no per-run or ad-hoc judge configuration.
19+
20+
## Example
21+
22+
```json
23+
{
24+
"dataset_id": 123,
25+
"experiment_name": "judge-smoke-1",
26+
"config_id": "f54f0d67-4817-4103-9fdf-b74b3d46733e",
27+
"config_version": 1
28+
}
29+
```
30+
31+
## Error responses
32+
33+
| Status | Code | When |
34+
| --- | --- | --- |
35+
| 409 | `run_name_already_exists` | A run with the same `experiment_name` already exists for this (organization, project) |
36+
| 422 | — | An unsupported config type / oversized dataset |

‎backend/app/api/main.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from app.api.routes import (
44
analytics,
55
api_keys,
6-
assessment as assessment_routes,
76
assistants,
87
auth,
98
collection_job,
@@ -35,7 +34,13 @@
3534
users,
3635
utils,
3736
)
38-
from app.core.config import settings
37+
from app.api.routes import (
38+
assessment as assessment_routes,
39+
)
40+
from app.api.routes.evaluations.dataset_v2 import (
41+
router as evaluations_dataset_v2_router,
42+
)
43+
from app.api.routes.evaluations.evaluation_v2 import router as evaluations_v2_router
3944

4045
api_router = APIRouter()
4146
api_router.include_router(analytics.router)
@@ -73,3 +78,10 @@
7378
api_router.include_router(private.router)
7479
# if settings.ENVIRONMENT in ["development", "testing"]:
7580
# api_router.include_router(private.router)
81+
82+
83+
# v2 API surface (mounted at settings.API_V2_STR). Only the endpoints that differ
84+
# from v1 live here — currently the judged run trigger. Everything else stays v1.
85+
api_v2_router = APIRouter()
86+
api_v2_router.include_router(evaluations_v2_router)
87+
api_v2_router.include_router(evaluations_dataset_v2_router)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""v2 Langfuse-free evaluation dataset upload route.
2+
3+
Same multipart shape and response, but no Langfuse dataset is created and only
4+
the original items are stored — duplication is metadata, applied at run time.
5+
"""
6+
7+
import logging
8+
9+
from fastapi import APIRouter, Depends, File, Form, UploadFile
10+
11+
from app.api.deps import AuthContextDep, SessionDep
12+
from app.api.permissions import Permission, require_permission
13+
from app.api.routes.evaluations.dataset import _dataset_to_response
14+
from app.core.rate_monitor import monitor_rate
15+
from app.models.evaluation import DatasetUploadResponse
16+
from app.services.evaluations import upload_dataset_v2, validate_csv_file
17+
from app.utils import APIResponse, load_description
18+
19+
logger = logging.getLogger(__name__)
20+
21+
router = APIRouter(prefix="/evaluations/datasets", tags=["Evaluation v2"])
22+
23+
24+
@router.post(
25+
"",
26+
description=load_description("evaluation/create_evaluation_dataset_v2.md"),
27+
response_model=APIResponse[DatasetUploadResponse],
28+
dependencies=[
29+
Depends(require_permission(Permission.REQUIRE_PROJECT)),
30+
Depends(monitor_rate("evaluations")),
31+
],
32+
)
33+
async def upload_dataset_v2_route(
34+
session: SessionDep,
35+
auth_context: AuthContextDep,
36+
file: UploadFile = File(
37+
..., description="CSV file with 'question' and 'answer' columns"
38+
),
39+
dataset_name: str = Form(..., description="Name for the dataset"),
40+
description: str | None = Form(None, description="Optional dataset description"),
41+
duplication_factor: int = Form(
42+
default=1,
43+
ge=1,
44+
le=5,
45+
description="Run-time duplication per item (min: 1, max: 5)",
46+
),
47+
) -> APIResponse[DatasetUploadResponse]:
48+
"""Upload a Langfuse-free evaluation dataset (v2)."""
49+
csv_content = await validate_csv_file(file)
50+
51+
dataset = upload_dataset_v2(
52+
session=session,
53+
csv_content=csv_content,
54+
dataset_name=dataset_name,
55+
description=description,
56+
duplication_factor=duplication_factor,
57+
organization_id=auth_context.organization_.id,
58+
project_id=auth_context.project_.id,
59+
)
60+
61+
return APIResponse.success_response(data=_dataset_to_response(dataset))
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""v2 evaluation run trigger — replica of the v1 trigger plus native judging."""
2+
3+
import logging
4+
from uuid import UUID
5+
6+
from asgi_correlation_id import correlation_id
7+
from fastapi import APIRouter, Body, Depends
8+
9+
from app.api.deps import AuthContextDep, SessionDep
10+
from app.api.permissions import Permission, require_permission
11+
from app.core.rate_monitor import monitor_rate
12+
from app.models.evaluation import EvaluationRunPublic
13+
from app.services.evaluations.fast import validate_and_start_fast_evaluation
14+
from app.utils import APIResponse, load_description
15+
16+
logger = logging.getLogger(__name__)
17+
18+
router = APIRouter(prefix="/evaluations", tags=["Evaluation v2"])
19+
20+
21+
@router.post(
22+
"",
23+
description=load_description("evaluation/create_evaluation_v2.md"),
24+
response_model=APIResponse[EvaluationRunPublic],
25+
dependencies=[
26+
Depends(require_permission(Permission.REQUIRE_PROJECT)),
27+
Depends(monitor_rate("evaluations")),
28+
],
29+
)
30+
def evaluate_v2(
31+
session: SessionDep,
32+
auth_context: AuthContextDep,
33+
dataset_id: int = Body(..., description="ID of the evaluation dataset"),
34+
experiment_name: str = Body(
35+
..., description="Name for this evaluation experiment/run"
36+
),
37+
config_id: UUID = Body(..., description="Stored config ID"),
38+
config_version: int = Body(..., ge=1, description="Stored config version"),
39+
) -> APIResponse[EvaluationRunPublic]:
40+
"""Start a v2 evaluation run.
41+
42+
Always fast and judged; there is no `run_mode` — batch judging is deferred.
43+
Judging always runs (`is_judge_run=True`); there is no per-run judge config.
44+
"""
45+
46+
eval_run = validate_and_start_fast_evaluation(
47+
session=session,
48+
dataset_id=dataset_id,
49+
run_name=experiment_name,
50+
config_id=config_id,
51+
config_version=config_version,
52+
organization_id=auth_context.organization_.id,
53+
project_id=auth_context.project_.id,
54+
trace_id=correlation_id.get() or "N/A",
55+
is_judge_run=True,
56+
)
57+
return APIResponse.success_response(data=eval_run)

‎backend/app/core/config.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ class Settings(BaseSettings):
3333
)
3434

3535
API_V1_STR: str = "/api/v1"
36+
# v2 hosts
37+
API_V2_STR: str = "/api/v2"
3638
SECRET_KEY: str = secrets.token_urlsafe(32)
3739
# 60 minutes * 24 hours * 1 days = 1 days
3840
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 1
@@ -209,6 +211,18 @@ def AWS_S3_BUCKET(self) -> str:
209211
# task well under CELERY_TASK_SOFT_TIME_LIMIT.
210212
EVAL_FAST_CHUNK_SIZE: int = 50
211213

214+
EVAL_JUDGE_MODEL: str = "gpt-5-mini"
215+
216+
# Reasoning effort for the judge model. "medium":
217+
# One of: none | minimal | low | medium | high | xhigh.
218+
EVAL_JUDGE_REASONING_EFFORT: str = "medium"
219+
220+
# Judge runs alone in the single aggregate task (no response calls competing),
221+
# so it uses a larger pool than the response stage to finish the max dataset
222+
# (EVAL_FAST_MAX_UNIQUE_ROWS x duplication) well under CELERY_TASK_SOFT_TIME_LIMIT.
223+
# Threads are network-bound (idle-waiting on OpenAI), so a high count is cheap.
224+
EVAL_JUDGE_CONCURRENCY: int = 50
225+
212226
@computed_field # type: ignore[prop-decorator]
213227
@property
214228
def COMPUTED_CELERY_WORKER_CONCURRENCY(self) -> int:

‎backend/app/core/providers.py‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,8 @@ class ProviderConfig:
5252
Provider.GOOGLE: ProviderConfig(
5353
required_fields=[
5454
"api_key",
55-
"project_id",
56-
"location",
57-
"sa_key",
58-
"gcs_bucket",
5955
],
60-
sensitive_fields=["api_key", "sa_key"],
56+
sensitive_fields=["api_key"],
6157
),
6258
Provider.WEBHOOK_SECRET: ProviderConfig(
6359
required_fields=["webhook_secret"], sensitive_fields=["webhook_secret"]

‎backend/app/crud/evaluations/cost.py‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@
99
Persisted shape on `eval_run.cost`:
1010
1111
{
12-
"response": {model, input_tokens, output_tokens, total_tokens, cost_usd},
13-
"embedding": {model, input_tokens, output_tokens, total_tokens, cost_usd},
12+
"response": {model, input_tokens, output_tokens, total_tokens, cost_usd},
13+
"embedding": {model, input_tokens, output_tokens, total_tokens, cost_usd},
14+
"judge": {model, input_tokens, output_tokens, total_tokens, cost_usd},
1415
"total_cost_usd": float,
1516
}
1617
17-
Either stage entry is optional. Embedding entries use output_tokens=0.
18+
Any stage entry is optional. Embedding entries use output_tokens=0. One combined call
19+
grades every metric, so judge tokens can't be split per metric and form a single stage,
20+
priced from per-row usage like the response stage.
1821
"""
1922

2023
import logging
@@ -112,9 +115,22 @@ def _build_embedding_cost_entry(
112115
return _build_cost_entry(session=session, model=model, totals=totals)
113116

114117

118+
def _build_judge_cost_entry(
119+
session: Session, model: str, results: list[dict[str, Any]]
120+
) -> dict[str, Any]:
121+
"""Build a judge-stage cost entry from per-row judge usage results."""
122+
totals = _sum_tokens(
123+
items=results,
124+
usage_extractor=lambda r: r.get("usage"),
125+
input_key="input_tokens",
126+
)
127+
return _build_cost_entry(session=session, model=model, totals=totals)
128+
129+
115130
def _build_cost_dict(
116131
response_entry: dict[str, Any] | None,
117132
embedding_entry: dict[str, Any] | None,
133+
judge_entry: dict[str, Any] | None,
118134
) -> dict[str, Any]:
119135
"""Combine per-stage entries into the `eval_run.cost` payload with a grand total."""
120136
cost: dict[str, Any] = {}
@@ -128,6 +144,10 @@ def _build_cost_dict(
128144
cost["embedding"] = embedding_entry
129145
total += embedding_entry.get("cost_usd", 0.0)
130146

147+
if judge_entry:
148+
cost["judge"] = judge_entry
149+
total += judge_entry.get("cost_usd", 0.0)
150+
131151
cost["total_cost_usd"] = round(total, COST_USD_DECIMALS)
132152
return cost
133153

@@ -141,10 +161,12 @@ def attach_cost(
141161
response_results: list[dict[str, Any]] | None = None,
142162
embedding_model: str | None = None,
143163
embedding_raw_results: list[dict[str, Any]] | None = None,
164+
judge_model: str | None = None,
165+
judge_results: list[dict[str, Any]] | None = None,
144166
) -> None:
145167
"""Compute cost for the given stage(s) and attach to `eval_run.cost`, never raising.
146168
147-
Caller is responsible for persisting `eval_run` afterwards. Either stage's
169+
Caller is responsible for persisting `eval_run` afterwards. Any stage's
148170
previously-computed entry on `eval_run.cost` is preserved when that stage's
149171
inputs are not supplied, so partial updates never clobber prior data.
150172
"""
@@ -167,9 +189,17 @@ def attach_cost(
167189
else:
168190
embedding_entry = existing_cost.get("embedding")
169191

192+
if judge_model is not None and judge_results is not None:
193+
judge_entry = _build_judge_cost_entry(
194+
session=session, model=judge_model, results=judge_results
195+
)
196+
else:
197+
judge_entry = existing_cost.get("judge")
198+
170199
eval_run.cost = _build_cost_dict(
171200
response_entry=response_entry,
172201
embedding_entry=embedding_entry,
202+
judge_entry=judge_entry,
173203
)
174204
except Exception as cost_err:
175205
logger.warning(

‎backend/app/crud/evaluations/dataset.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@
2929
logger = logging.getLogger(__name__)
3030

3131

32+
# dataset_metadata keys, shared by the upload services and the run-time loader so
33+
DATASET_META_ORIGINAL_ITEMS = "original_items_count"
34+
DATASET_META_TOTAL_ITEMS = "total_items_count"
35+
DATASET_META_DUPLICATION_FACTOR = "duplication_factor"
36+
# v2 marker: the stored CSV holds only the original rows and duplication is applied
37+
# at run time. Absent/false means the S3 data is already physically duplicated (v1),
38+
# so the run reads it as-is and must not multiply again.
39+
DATASET_META_DUPLICATE_AT_RUNTIME = "duplicate_at_runtime"
40+
41+
3242
def create_evaluation_dataset(
3343
session: Session,
3444
name: str,

0 commit comments

Comments
 (0)