Skip to content

fix(sessions): grade artist guesses with the shared judge, not a local comma-split - #24

Merged
Privex-chat merged 2 commits into
mainfrom
fix/artist-matching-authority
Jul 31, 2026
Merged

fix(sessions): grade artist guesses with the shared judge, not a local comma-split#24
Privex-chat merged 2 commits into
mainfrom
fix/artist-matching-authority

Conversation

@Privex-chat

@Privex-chat Privex-chat commented Jul 29, 2026

Copy link
Copy Markdown
Owner

The bug

matching.py describes itself as "the authoritative judge for scoring" and warns to keep it in lock-step with the frontend. scoring.py uses it. The session guess path — the one every current client actually hits — did not:

# backend/sessions.py:291-294 (before)
submitted = (req.artist or "").lower().strip()
artist_parts = [p.strip() for p in entry["artist"].lower().split(",")]
correct = bool(submitted) and any(submitted == p for p in artist_parts)

That only understands comma-separated credits. But the guess dropdown is built with splitArtistsRaw, which splits on the full separator set (, & / + × feat ft featuring with x) and submits one of those split-out names. So for any credit using a non-comma separator, every option the dropdown offered was graded wrong — and since the dropdown is the only submission path, the track could not be answered at all.

Severity, measured

I want to correct an overstatement from my earlier triage. I'd implied every feat./& collab was broken; the real number is smaller, because Spotify's API joins collaborators with commas (", ".join(artists[].name)), so feat. usually lives in the track title, not the artist field. The non-comma separators mostly turn up inside band names.

Over all 419,688 playable tracks:

tracks share
unanswerable in artist mode (no dropdown option accepted) 5,678 1.4%
partially broken (some options mis-graded) 3,076 0.7%
combined affected 8,754 2.1%

Real examples that were unanswerable: Jeckyll & Hyde, Heavy D & The Boyz, Bob & Earl, Michel Fugain & Le Big Bazar, aivi & surasshu, Allie X.

The fix

Correctness delegates to matches_artist through a small pure helper, so the decision is testable without a database and there is one judge again instead of two that can drift.

def _is_guess_correct(guess_mode, req, entry, tid) -> bool:
    if req.skip:
        return False
    if guess_mode == "artist":
        return matches_artist(req.artist or "", entry.get("artist") or "")
    return bool(req.track_id) and req.track_id == tid

Two incidental hardening changes in the same helper: song mode now requires a non-empty track_id rather than comparing None, and a missing artist field no longer raises — the old code called .lower() on entry["artist"] directly.

Verification

Is it a strict superset? That was the main risk — a more permissive judge must not start rejecting anything. Checked against 20,000 distinct real artist strings from the production DB (38,231 track/dropdown-option pairs), running the actual _is_guess_correct:

superset violations (old accepted, new rejects) : 0
unanswerable under OLD judge                   : 178
unanswerable under NEW judge                   : 0

Does the check have teeth? I reintroduced the old local judge and confirmed python sessions.py fails on the first artist assertion, then passes again once restored. (Batch 1 taught me not to trust a green test I haven't tried to break.)

Adds a python sessions.py self-check in the same style as matching.py — no pytest in requirements.txt, so assert-based __main__ checks are the existing convention. Covers each separator form, accent folding, a single act whose name contains a comma (Tyler, The Creator), and the strictness cases: wrong artist, partial name, blank, and skip in both modes.

Notes for review

  • Backend-only, one file. Deliberately does not touch GamePage.js so it can't conflict with fix(playlist): decouple guess autocomplete pool from track playability (closes #22) #23, which edits the same import line.
  • GamePage.js:400 checkGuessCorrect is a third, now-dead artist judge (defined, never called — a leftover from before server-side grading). Removing it belongs with the dead-code batch, and it's harmless meanwhile since it cannot be invoked.
  • The game gets slightly easier for duo acts: Bob & Earl now accepts Bob. That matches the documented policy ("naming ANY credited artist is correct") and what the dropdown offers, so it's the intended behaviour rather than a side effect.

Separate bug this surfaced (not fixed here)

The dropdown itself shreds band names, and the root cause is upstream of any judge:

# server.py:411 — the authoritative structure is discarded here
artist_name = ", ".join(a.get("name", "") for a in artists)

Spotify hands us artists[] as an array. We flatten it to a string, then downstream code tries to reverse-engineer the boundaries with separator heuristics. The heuristics cannot win:

artist field dropdown offers
In Love With a Ghost In Love, a Ghost
Allie X Allie
Dion (With The Del-Satins) Dion (, The Del-Satins)
Jeckyll & Hyde Jeckyll, Hyde

After this PR those are all gradeable (picking Jeckyll is correct), so it's no longer a blocker — but players are being shown artists that don't exist, and one option has a stray (. The real fix is to stop discarding the array: persist the artist list as text[] and split on the authoritative boundary, keeping the fuzzy separators only for the embed-only path where subtitle genuinely is free text. Worth its own PR.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved answer validation for artist guesses, including consistent handling of artist separators.
    • Ensured song answers are judged only by matching the correct track.
    • Confirmed that skipped guesses are always marked incorrect.
    • Improved resilience when track details are unavailable, ensuring reveals continue to display safely.
    • Standardized retry behavior when starting a session.

matching.py documents itself as "the authoritative judge for scoring", and
scoring.py uses it. The session guess path — the one every current client
actually hits — did not: it decided artist correctness locally with

    artist_parts = [p.strip() for p in entry["artist"].lower().split(",")]

which only understands comma-separated credits. The guess dropdown splits on
the full separator set (, & / + × feat ft featuring with x) and submits one of
those split-out names, so whenever a credit used a non-comma separator every
option the dropdown offered was graded wrong and the track could not be
answered at all.

Measured over 419,688 playable tracks: 1.4% were unanswerable in artist mode
and a further 0.7% had some options mis-graded (2.1% affected). Lower than a
first read of the separator list suggests, because Spotify's API joins
collaborators with commas — the non-comma separators mostly turn up inside band
names ("Jeckyll & Hyde", "Heavy D & The Boyz", "Allie X").

Correctness now delegates to matches_artist via a small pure helper, so the
decision is testable without a database and there is one judge again rather
than two that can drift.

Verified against 20,000 distinct real artist strings (38,231 track/dropdown-
option pairs): 0 cases where the shared judge rejects something the local one
accepted (strict superset), and unanswerable strings go 178 -> 0.

Two incidental hardening changes in the same helper: song mode now requires a
non-empty track_id rather than comparing None, and a missing artist field no
longer raises (the old code called .lower() on entry["artist"] directly).

Adds a python sessions.py self-check in the same style as matching.py, covering
each separator form, accent folding, a single act whose name contains a comma,
and the strictness cases (wrong artist, partial name, blank, skip). Confirmed
the check fails if the old local judge is reintroduced.

Co-Authored-By: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
audyn Ready Ready Preview Jul 31, 2026 12:15pm

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cf575244-d339-4e66-8ef6-aeea657db471

📥 Commits

Reviewing files that changed from the base of the PR and between 30614f5 and abbde3d.

📒 Files selected for processing (1)
  • backend/sessions.py

📝 Walkthrough

Walkthrough

Session guess validation now uses centralized artist, song, and skip rules. Reveal serialization handles missing track fields. Preview retries use the shared limit. Executable assertions cover the updated behavior.

Changes

Guess correctness

Layer / File(s) Summary
Centralize correctness and reveal rules
backend/sessions.py
Imports shared artist matching and the preview retry limit. Adds _is_guess_correct and _reveal_for for guess validation and sparse reveal data.
Wire validation and retry configuration
backend/sessions.py
Uses MAX_PREVIEW_RETRIES for pending-track queries. Updates guess submission to use the centralized validation and reveal helpers.
Validate session rules
backend/sessions.py
Adds executable assertions for artist matching, song and skip rules, missing artist data, and defensive reveal serialization.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: session artist guesses now use the shared judge instead of local comma-split logic.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/artist-matching-authority

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Privex-chat added a commit that referenced this pull request Jul 29, 2026
…a fill

Addresses CodeRabbit review on #23.

The nested `.get()` chain over the embed payload could raise AttributeError,
and because it sits inside an asyncio.gather fan-out without
return_exceptions, that raise discarded the whole chunk's results: none of
mark_recovered/mark_unavailable/mark_failed ran, so the offending track's retry
counter never advanced and it could knock out the same playlist's fill on every
subsequent attempt. Credit to the review for tracing that consequence.

The suggested patch (guard `if not next_data`) closes only part of it. Falsy
payloads were already safe — `{}` chains fine. The actual vectors are:

  - truthy non-dict from json.loads: `"str"`, `42`, `true`
  - an explicit null at an intermediate level, e.g. `{"props": null}`, because
    `.get(k, {})` returns the default only for a MISSING key, not a null value
  - a non-dict `entity`, which survives the chain and then raises downstream in
    `.get("audioPreview")` / _extract_cover_art

Of ten payload shapes, the current chain raises on six; the suggested guard
fixes two of those six.

So the fix is type-checked at every hop instead:

- _embed_entity() walks to the entity node and always returns a dict.
- _extract_cover_art() uses `or {}` / `or []` and skips non-dict sources, since
  a null coverArt or sources would otherwise raise on the next access.
- The parse/extract in _scrape_one_preview is wrapped so the function is total
  rather than total-by-inspection.
- gather() takes return_exceptions=True with the exceptions filtered and
  logged, per the reviewer's offer. Redundant with a total scraper, but this is
  the exact point where a raise turns into silent loss of persisted work, so
  the structural guard is worth its three lines.

Verified: a raising task now costs one track instead of all 48 in its chunk.

Also from the review: /playlist-status and /playlist/{id}/wait-for-previews
hard-coded `preview_retry_count < 5` while load_playlist uses
MAX_PREVIEW_RETRIES. #23 made that consequential — the client now starts
polling off load_playlist's pending count and reads progress off
/playlist-status's, so a change to the constant would leave the progress bar
unable to finish. Both now take the shared constant. (sessions.py has the same
literal; left alone to avoid churn against the open #24.)

Adds a `python server.py` self-check covering all ten payload shapes and the
cover-art edge cases, in the same style as matching.py and sessions.py. uvicorn
imports the module and never runs __main__. Confirmed the check fails when the
falsiness-only guard is substituted.

Declined the negative-cache nitpick for /tracks/art: #23 makes that path cold
(album art now ships inline with /playlist, so the endpoint is only hit for
tracks art hasn't reached yet), a DB miss is one indexed lookup, and a negative
sentinel would actively delay art that the preview fill is concurrently
backfilling into art_cache.

Co-Authored-By: Claude <noreply@anthropic.com>
@Privex-chat

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/sessions.py`:
- Line 80: Update the docstring text near the split-out separator description to
avoid the ambiguous literal “×” flagged by RUF002. Spell out “Unicode
multiplication sign” or equivalent wording while preserving the documented
separator semantics.
- Line 313: Update submit_guess’s reveal construction to safely handle entries
without an "artist" field, matching _is_guess_correct’s behavior and preventing
a KeyError response failure. Add an endpoint-level regression test that submits
a track missing "artist" and verifies a normal response.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 303fb7e5-b760-4e22-bdfe-c9f14cc7fdea

📥 Commits

Reviewing files that changed from the base of the PR and between 9e36621 and 30614f5.

📒 Files selected for processing (1)
  • backend/sessions.py

Comment thread backend/sessions.py Outdated
Comment thread backend/sessions.py
…or docs

Addresses CodeRabbit review on #24.

reveal construction (line 313)
------------------------------
_is_guess_correct reads `entry.get("artist") or ""`, then seven lines later the
same dict was subscripted directly as entry["name"] / entry["artist"]. Two
functions reading one dict disagreeing about whether its keys are guaranteed is
the inconsistency worth removing — my own diff introduced it.

To be accurate about severity: the KeyError is not reachable today. Every entry
start_session writes carries name and artist, sourced from tracks columns that
are NOT NULL DEFAULT '', and sessions expire after 2h so no older JSONB shape
survives. This is consistency, not a live fix. It is still worth doing: `entry`
is deserialized JSONB whose shape has changed before (see _META_KEYS), and the
failure mode would be a 500 on a guess the player already committed to.

Extracted _reveal_for(tid, entry) rather than inlining .get() calls, so the
logic is testable without a database — same reasoning that produced
_is_guess_correct in this PR. Response contract is unchanged: the same four
keys, in the same shape the frontend reads.

The review asked for an endpoint-level test. Declining that specific form:
there is no pytest, no test database and no CI in this repo, so it would mean
standing up the first backend HTTP test harness inside a review fixup, which is
a scope decision for the owner rather than something to smuggle in here. The
extracted helper is covered by the existing python sessions.py self-check
instead, at the layer the bug would live in — full entry, empty entry,
all-null-values entry, and partial entry. Confirmed the check fails with
KeyError: 'name' when the direct subscripts are restored.

separator docstring (line 80)
-----------------------------
The list rendered `×` and `x` adjacent to each other. They are two different
rules in _ARTIST_SEP — the multiplication sign splits anywhere, the letter x
only at word boundaries — so a reader who cannot tell them apart loses the
point of the sentence. Spelled the symbols out in words and pointed at
matching._ARTIST_SEP for the exact pattern. The regex itself keeps the literal
`×`; it is functional.

also
----
Followed through on the MAX_PREVIEW_RETRIES consistency I said I would pick up
in this batch when it came up on #23: start_session's pending_count was the
third site hard-coding `preview_retry_count < 5`. It feeds the client's
"waiting for previews" retry, so it has to mean the same thing as the preview
store and /playlist-status.

Co-Authored-By: Claude <noreply@anthropic.com>
@Privex-chat

Copy link
Copy Markdown
Owner Author

Both addressed in abbde3d.


1. reveal construction (line 313) — fixed, though I want to be accurate about severity

You're right that this is inconsistent, and it's inconsistent within my own diff: _is_guess_correct reads entry.get("artist") or "", then seven lines later the same dict was subscripted directly. Two functions reading one dict and disagreeing about whether its keys are guaranteed is exactly the kind of drift this PR is about.

But I checked whether the KeyError is actually reachable, and it isn't:

  • start_session writes name and artist unconditionally for every entry
  • both come from tracks columns declared NOT NULL DEFAULT ''
  • sessions expire after 2h, so no older JSONB shape survives a deploy

So this is a consistency fix, not a live crash — I'd rather say that than overstate it. It's still worth doing: entry is deserialized JSONB whose shape has changed before (_META_KEYS exists for exactly that reason), and the failure mode would be a 500 on a guess the player has already committed to.

Extracted _reveal_for(tid, entry) rather than inlining .get() calls, so the logic is testable without a database — same reasoning that produced _is_guess_correct in this PR. The response contract is unchanged: same four keys, same shape the frontend reads.

On the endpoint-level test — declining that specific form, with a reason. There's no pytest, no test database and no CI in this repo (requirements.txt has no test deps, .github/workflows doesn't exist). An endpoint test needs TestClient + a seeded game_sessions row + a require_user dependency override, which means standing up the repo's first backend HTTP test harness inside a review fixup. That's a scope decision for the maintainer, not something I should smuggle into this PR.

What I did instead: the extracted helper is covered by the existing python sessions.py self-check — full entry, empty entry, all-null-values entry, partial entry. And I verified it has teeth by restoring the direct subscripts:

File "backend/sessions.py", line 108, in _reveal_for
    "name": entry["name"],
KeyError: 'name'

If you'd like a real HTTP-level suite, I'd rather propose it as its own PR — it's a genuinely useful addition and there's more than this one case worth covering.

2. Ambiguous × in the docstring (line 80) — fixed, and it was a real readability bug

Worth more than the lint tag suggests. The list rendered × and x adjacent to each other, and they're two different rules in _ARTIST_SEP:

r"\s*(?:,|&|/|\+|×|\b(?:feat|ft|featuring|with|x)\b\.?)\s*"
#              ^ splits anywhere        ^ word-bounded only

A reader who can't visually distinguish them loses the entire point of the sentence. Now spelled out in words, with a pointer to matching._ARTIST_SEP for the exact pattern. The regex keeps the literal × — that one's functional.


Also in this push

Followed through on something I flagged on #23: start_session's pending_count was the third site hard-coding preview_retry_count < 5 while preview_store and preview_worker parameterise it. I said there I'd pick it up in this batch to avoid cross-PR churn, so it's here now. That count feeds the client's "waiting for previews" retry, so it needs to mean the same thing the other two do.

Validation

python sessions.py and python matching.py self-checks pass; full app module imports clean (confirming sessions -> preview_store adds no cycle); _reveal_for output verified JSON-serialisable with the same four keys as before.

@Privex-chat
Privex-chat merged commit a1003f7 into main Jul 31, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant