Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion backend/app/api/v1/judgments.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def _summary(row: JudgmentList) -> JudgmentListSummary:
description=row.description,
query_set_id=row.query_set_id,
cluster_id=row.cluster_id,
target=row.target,
status=row.status, # str narrowed via CHECK constraint
created_at=row.created_at,
)
Expand Down Expand Up @@ -346,6 +347,7 @@ async def list_judgment_lists_endpoint(
sort: Annotated[JudgmentListSortKey | None, Query()] = None,
query_set_id: Annotated[str | None, Query(min_length=1, max_length=36)] = None,
cluster_id: Annotated[str | None, Query(min_length=1, max_length=36)] = None,
target: Annotated[str | None, Query(min_length=1, max_length=255)] = None,
) -> JudgmentListListResponse:
"""List judgment lists, newest-first with cursor pagination.

Expand All @@ -361,6 +363,13 @@ async def list_judgment_lists_endpoint(
entity integrity check then rejects at create time with a confusing
422 ``VALIDATION_ERROR: "judgment_list query_set_id does not match
study query_set_id"``).

``?target=`` filters by exact target index/collection name
(``feat_study_target_judgment_mismatch_guard`` FR-2 — pairs with the
``POST /studies`` ``JUDGMENT_TARGET_MISMATCH`` 422 so the create-study
modal can pre-filter the dropdown to only lists matching the chosen
study target). Bounded by the ES/OpenSearch index-name ceiling
(255 bytes).
"""
parsed_sort = parse_sort(sort, _JUDGMENT_LIST_SORT_COLUMNS)
decoded_cursor: tuple[object, str] | None = None
Expand All @@ -380,12 +389,18 @@ async def list_judgment_lists_endpoint(
sort=sort,
query_set_id=query_set_id,
cluster_id=cluster_id,
target=target,
)
has_more = len(rows) > limit
if has_more:
rows = rows[:limit]
total = await repo.count_judgment_lists(
db, since=since, q=q, query_set_id=query_set_id, cluster_id=cluster_id
db,
since=since,
q=q,
query_set_id=query_set_id,
cluster_id=cluster_id,
target=target,
)
response.headers["X-Total-Count"] = str(total)
next_cursor: str | None = None
Expand Down
1 change: 1 addition & 0 deletions backend/app/api/v1/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,7 @@ class JudgmentListSummary(BaseModel):
description: str | None
query_set_id: str
cluster_id: str
target: str
status: JudgmentListStatusWire
created_at: datetime

Expand Down
36 changes: 36 additions & 0 deletions backend/app/api/v1/studies.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,42 @@ async def create_study(
False,
)

# 3a. judgment_list ↔ cluster consistency (feat_study_target_judgment_mismatch_guard
# FR-1b). Doc IDs are scoped to the cluster they were authored on; same
# target name on two clusters still produces zero overlap. Fires BEFORE
# the target check (3b) because cluster mismatch is the broader failure.
if judgment_list.cluster_id != body.cluster_id:
raise _err(
422,
"JUDGMENT_CLUSTER_MISMATCH",
(
f"judgment_list cluster_id={judgment_list.cluster_id!r} does not "
f"match study cluster_id={body.cluster_id!r}; judgments are scoped "
f"to the cluster they were authored on. Pick a judgment list "
f"created against cluster {body.cluster_id!r} or change the "
f"study's cluster."
),
False,
)

# 3b. judgment_list ↔ target consistency (feat_study_target_judgment_mismatch_guard
# FR-1). When targets differ, judgment doc IDs cannot overlap with search
# results from the study's target — pytrec_eval scores 0 on every trial by
# construction. Closes the literal study2 incident (1000 trials, 0 signal).
if judgment_list.target != body.target:
raise _err(
422,
"JUDGMENT_TARGET_MISMATCH",
(
f"judgment_list target={judgment_list.target!r} does not match "
f"study target={body.target!r}; judgments would have no overlap "
f"with search results from the study's target. Use a judgment "
f"list generated against {body.target!r} or change study.target "
f"to {judgment_list.target!r}."
),
False,
)

# 4. Serialize config with exclude_none + exclude_unset (C3-F1 + Story 1.5).
config_payload = body.config.model_dump(exclude_none=True, exclude_unset=True)

Expand Down
27 changes: 18 additions & 9 deletions backend/app/db/repo/judgment_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,20 +65,23 @@ async def list_judgment_lists(
sort: str | None = None,
query_set_id: str | None = None,
cluster_id: str | None = None,
target: str | None = None,
) -> list[JudgmentList]:
"""Cursor-paginated list of judgment lists, sort-aware (Story 1.3).

Default ordering ``created_at DESC, id DESC``. ``?sort=`` switches
to ``<col>:<dir>`` with explicit NULLS handling. ``since`` filters
by ``created_at >= since``. ``q`` is FTS match against
``search_vector`` (name + target). ``query_set_id`` / ``cluster_id``
filter to judgment lists that belong to the supplied parent
(``bug_judgment_lists_listing_ignores_query_set_filter`` —
the create-study modal's Step-2 dropdown relies on these to
surface only valid judgment-list ↔ query-set pairs; without the
filters the modal lets the user pick a mismatched pair, which the
``POST /api/v1/studies`` cross-entity integrity check then rejects
at create time with a confusing 422).
/ ``target`` filter to judgment lists that belong to the supplied
parent and/or share the exact target index name
(``bug_judgment_lists_listing_ignores_query_set_filter`` for the
first two; ``feat_study_target_judgment_mismatch_guard`` FR-2 for
``target``). The create-study modal's Step-2 dropdown relies on
all three so it surfaces only the judgment lists valid for the
chosen study cluster + query set + target — without these filters
the modal lets the user pick a mismatched pair and ``POST
/api/v1/studies`` then rejects at create time with a confusing 422.
"""
parsed_sort: ParsedSort | None = parse_sort(sort, _JUDGMENT_LIST_SORT_COLUMNS)
stmt = select(JudgmentList)
Expand All @@ -88,6 +91,8 @@ async def list_judgment_lists(
stmt = stmt.where(JudgmentList.query_set_id == query_set_id)
if cluster_id is not None:
stmt = stmt.where(JudgmentList.cluster_id == cluster_id)
if target is not None:
stmt = stmt.where(JudgmentList.target == target)
fts = fts_predicate(q)
if fts is not None:
stmt = stmt.where(fts)
Expand Down Expand Up @@ -119,11 +124,13 @@ async def count_judgment_lists(
q: str | None = None,
query_set_id: str | None = None,
cluster_id: str | None = None,
target: str | None = None,
) -> int:
"""Total count for ``X-Total-Count`` header on ``GET /api/v1/judgment-lists``.

``query_set_id`` / ``cluster_id`` mirror :func:`list_judgment_lists` so the
header count and the row count stay consistent under the same filters.
``query_set_id`` / ``cluster_id`` / ``target`` mirror
:func:`list_judgment_lists` so the header count and the row count stay
consistent under the same filters.
"""
from sqlalchemy import func as _func

Expand All @@ -134,6 +141,8 @@ async def count_judgment_lists(
stmt = stmt.where(JudgmentList.query_set_id == query_set_id)
if cluster_id is not None:
stmt = stmt.where(JudgmentList.cluster_id == cluster_id)
if target is not None:
stmt = stmt.where(JudgmentList.target == target)
fts = fts_predicate(q)
if fts is not None:
stmt = stmt.where(fts)
Expand Down
46 changes: 45 additions & 1 deletion backend/tests/contract/test_judgments_api_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,11 @@ async def test_list_judgment_lists_endpoint_declares_filter_query_params(
assert "cluster_id" in by_name, (
f"GET /judgment-lists missing `cluster_id` query param; got {sorted(by_name)}"
)
# Both should be optional strings (UUIDv7 → max 36 chars).
# feat_study_target_judgment_mismatch_guard FR-2: new ?target= wire param.
assert "target" in by_name, (
f"GET /judgment-lists missing `target` query param; got {sorted(by_name)}"
)
# All three should be optional strings.
for name in ("query_set_id", "cluster_id"):
spec = by_name[name]["schema"]
# Optional → anyOf [string, null] or `nullable: true` depending on
Expand All @@ -163,6 +167,46 @@ async def test_list_judgment_lists_endpoint_declares_filter_query_params(
else:
assert spec.get("type") == "string", f"{name}: not a string"
assert spec.get("maxLength") == 36, f"{name}: maxLength != 36"
# target is free-form (not UUIDv7) — bound by the ES/OpenSearch index-name
# ceiling (255 bytes) per feat_study_target_judgment_mismatch_guard FR-2.
target_spec = by_name["target"]["schema"]
if "anyOf" in target_spec:
target_string = next((s for s in target_spec["anyOf"] if s.get("type") == "string"), None)
assert target_string is not None, "target: no string branch in anyOf"
assert target_string.get("maxLength") == 255, (
f"target: maxLength != 255 (got {target_string.get('maxLength')})"
)
assert target_string.get("minLength") == 1, (
f"target: minLength != 1 (got {target_string.get('minLength')})"
)
else:
assert target_spec.get("type") == "string", "target: not a string"
assert target_spec.get("maxLength") == 255, "target: maxLength != 255"
assert target_spec.get("minLength") == 1, "target: minLength != 1"


@_skip_if_no_pg
async def test_judgment_list_summary_includes_target_field(
async_client: httpx.AsyncClient,
) -> None:
"""``feat_study_target_judgment_mismatch_guard`` FR-3 — the OpenAPI
schema for ``JudgmentListSummary`` declares ``target`` as a required
string field (additive, non-breaking from a tolerant-JSON-client
perspective, but the generated TS types and OpenAPI snapshot must
update or `pnpm typecheck` breaks).
"""
response = await async_client.get("/openapi.json")
schema = response.json()
summary = schema["components"]["schemas"]["JudgmentListSummary"]
assert "target" in summary["properties"], (
f"JudgmentListSummary missing `target` property; got {sorted(summary['properties'])}"
)
target_spec = summary["properties"]["target"]
assert target_spec.get("type") == "string", target_spec
assert "target" in summary.get("required", []), (
"`target` is not in JudgmentListSummary.required — the column is "
"Text NOT NULL, so the Pydantic field must be required."
)


def test_all_spec_error_codes_referenced_in_router_source() -> None:
Expand Down
34 changes: 34 additions & 0 deletions backend/tests/contract/test_studies_api_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,37 @@ def test_bulk_queries_request_caps_at_10k() -> None:
def test_bulk_queries_request_requires_min_one() -> None:
with pytest.raises(ValidationError):
BulkQueriesJsonRequest(queries=[])


def test_studies_router_declares_judgment_mismatch_error_codes() -> None:
"""``feat_study_target_judgment_mismatch_guard`` FR-1 + FR-1b — source-
presence guard that both new error codes appear as literals in the
studies router AND fire in the expected order.

Hermetic (no DB needed). Catches a rename of the error_code strings
without an accompanying spec/contract update — the integration tests
at ``test_studies_api.py`` exercise the runtime envelope shape; this
test exists so a refactor that renames ``JUDGMENT_TARGET_MISMATCH`` →
something else fails CI even if integration coverage was the
refactor's own test (chicken-and-egg).
"""
from pathlib import Path

source = Path("backend/app/api/v1/studies.py").read_text(encoding="utf-8")
assert '"JUDGMENT_CLUSTER_MISMATCH"' in source, (
"JUDGMENT_CLUSTER_MISMATCH literal missing from backend/app/api/v1/studies.py"
)
assert '"JUDGMENT_TARGET_MISMATCH"' in source, (
"JUDGMENT_TARGET_MISMATCH literal missing from backend/app/api/v1/studies.py"
)
# Lock the firing order: cluster check must appear BEFORE the target
# check in the source (handler executes top-down). This catches a
# refactor that accidentally swaps the two blocks.
cluster_pos = source.index('"JUDGMENT_CLUSTER_MISMATCH"')
target_pos = source.index('"JUDGMENT_TARGET_MISMATCH"')
assert cluster_pos < target_pos, (
"FR-1b ordering violation: JUDGMENT_CLUSTER_MISMATCH must appear in "
"studies.py BEFORE JUDGMENT_TARGET_MISMATCH (cluster check fires "
"first). Got cluster_pos="
f"{cluster_pos}, target_pos={target_pos}."
)
Comment on lines +182 to +213

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The test test_studies_router_declares_judgment_mismatch_error_codes is brittle and unconventional for a contract test. It attempts to verify logic (firing order) by parsing the source code as a string and searching for specific literals. This approach is sensitive to quoting styles, comments, and formatting changes, which increases maintenance overhead. Since the firing order and error code presence are already verified by runtime integration tests (e.g., test_post_study_cluster_mismatch_fires_before_target in test_studies_api.py), this static analysis test is redundant. Consider relying on the integration suite for this verification.

Loading
Loading