[Backend] Disambiguate speaker labels — nicknames, collisions, and the snowflake fallback #344

Closed
opened 2026-08-25 20:39:01 +00:00 by claude-bot · 1 comment
Contributor

Severity: MEDIUM. Found in the August 2026 session lifecycle review (#319).

The defect

Three ways a speaker label goes wrong before the LLM ever sees the transcript:

  1. Guild nicknames are ignored. recording.py:717-723 resolves names via bot.get_user(...).display_name, which is global_name or name — the guild nickname is never consulted. Players whose server nick is their character name lose that identity. Worse, the live recording dashboard does prefer the guild member (services/recording_status.py:28), so the name the GM sees during the session differs from the one in the transcript.
  2. Identical display names collapse. Two guild members can legally share a display name. Both tracks carry the same label, and character_map is keyed by display name (reminder_tasks.py:2077-2113), so both map to whichever entry won a last-write-wins dict insert. Two players merge into one speaker.
  3. Cache miss falls back to a raw snowflake. If the user is not in the bot's cache, the speaker is labelled with a bare Discord id, and the transcript contains lines like [00:12:01] 184623...: text which the LLM will merge into, or guess as, a named player. reminder_tasks.py:2059 does the same for a WAV missing from speakers.json.

Proposed fix

Resolve names from the guild member first, falling back to the global user. Key the character map by Discord user id, not by display name — the id is already in the filename. On a display-name collision, disambiguate in the rendered label. Never emit a bare snowflake: fall back to a stable readable form and flag the session so the GM can correct it.

Acceptance criteria

  • Speaker names prefer the guild nickname, matching the live dashboard
  • The character map is keyed by user id
  • Colliding display names are disambiguated in the transcript
  • A cache miss never produces a bare snowflake label, and flags the session
  • Tests cover nickname vs global name, two members sharing a name, and a cache miss
**Severity: MEDIUM.** Found in the August 2026 session lifecycle review (#319). ## The defect Three ways a speaker label goes wrong before the LLM ever sees the transcript: 1. **Guild nicknames are ignored.** `recording.py:717-723` resolves names via `bot.get_user(...).display_name`, which is `global_name or name` — the **guild nickname is never consulted**. Players whose server nick is their character name lose that identity. Worse, the live recording dashboard *does* prefer the guild member (`services/recording_status.py:28`), so the name the GM sees during the session differs from the one in the transcript. 2. **Identical display names collapse.** Two guild members can legally share a display name. Both tracks carry the same label, and `character_map` is keyed by display name (`reminder_tasks.py:2077-2113`), so both map to whichever entry won a last-write-wins dict insert. Two players merge into one speaker. 3. **Cache miss falls back to a raw snowflake.** If the user is not in the bot's cache, the speaker is labelled with a bare Discord id, and the transcript contains lines like `[00:12:01] 184623...: text` which the LLM will merge into, or guess as, a named player. `reminder_tasks.py:2059` does the same for a WAV missing from `speakers.json`. ## Proposed fix Resolve names from the **guild member** first, falling back to the global user. Key the character map by Discord user id, not by display name — the id is already in the filename. On a display-name collision, disambiguate in the rendered label. Never emit a bare snowflake: fall back to a stable readable form and flag the session so the GM can correct it. ## Acceptance criteria - [ ] Speaker names prefer the guild nickname, matching the live dashboard - [ ] The character map is keyed by user id - [ ] Colliding display names are disambiguated in the transcript - [ ] A cache miss never produces a bare snowflake label, and flags the session - [ ] Tests cover nickname vs global name, two members sharing a name, and a cache miss
Author
Contributor

Verified; the two remaining gaps fixed in dd8f305 and 7a689a9.

Criteria

  • Speaker names prefer the guild nickname, matching the live dashboard — met, and tested against the pre-fix behaviour.
  • The character map is keyed by user id — met by deletion: character_map and _apply_character_names no longer exist. resolve_speakers is keyed on owner_id throughout, and persistence carries the id.
  • Colliding display names are disambiguated in the transcript_disambiguate qualifies with an id suffix and loops on a second-order collision.
  • A cache miss never produces a bare snowflake label, and flags the session — first half was fixed at the producer only; second half handled differently, below.
  • Tests cover nickname vs global name, two members sharing a name, and a cache miss — all three exist and fail under pre-fix code.

The snowflake guard was fixed at the producer only

2114f42 stopped the bot writing a bare id, and its own message notes that speakers.get(stem) or default catches only a missing key. The backend consumer kept exactly that or, so a snowflake present as the value is truthy and walked straight through into the transcript — where the LLM reads it as a person's name and merges it into a real player, which is this issue's original symptom.

That is a live path, not a leftover. Every speakers.json written before that commit has exactly that shape; CLAUDE.md states the per-session audio directory is never deleted automatically; and reprocess is one of four documented routes back into process_audio. An old recording reprocessed on a new backend reproduces the bug in full.

resolved_speaker_name now checks symmetrically against the stem and mirrors the bot's unknown_speaker_label, so a track handled by either side reads the same to a GM.

"Flags the session" — recovered instead

Looking at where the gap actually is, flagging turned out to be the weaker answer.

  • A linked member with a character was never affected — the character name wins over anything the bot wrote.
  • An unlinked speaker has nothing to recover, and already surfaces to the GM through session.attendance_unmatched_speakers — the attendance screen, which is exactly where someone goes to say who an unrecognised speaker was. That is the flag, and it already existed.
  • That leaves one combination: a linked member with no character set. Their placeholder became the transcript label while the database knew precisely who they were.

So the linked account's display name now fills in — but only when the bot produced a placeholder, because the bot reads Discord directly and prefers the guild nickname, which is normally what a table calls each other. Its answer wins whenever it has one.

Recovering the name is strictly better than flagging that it is missing, so the criterion is met in substance rather than in form. If you would still like an explicit session-level marker on top of this, that is a small follow-up and worth its own issue rather than being assumed.

One residual the sweep missed

recording_status.py still had getattr(member, "display_name", str(member_id)) for uncaptured_members — the last bare-snowflake fallback. Lower stakes than the transcript path (no LLM reads it as a person) but the same unreadable string in front of the same GM, and there was already a helper for it.

Closing.

**Verified; the two remaining gaps fixed in `dd8f305` and `7a689a9`.** ## Criteria - [x] **Speaker names prefer the guild nickname, matching the live dashboard** — met, and tested against the pre-fix behaviour. - [x] **The character map is keyed by user id** — met **by deletion**: `character_map` and `_apply_character_names` no longer exist. `resolve_speakers` is keyed on `owner_id` throughout, and persistence carries the id. - [x] **Colliding display names are disambiguated in the transcript** — `_disambiguate` qualifies with an id suffix and loops on a second-order collision. - [x] **A cache miss never produces a bare snowflake label, and flags the session** — first half was fixed at the *producer only*; second half handled differently, below. - [x] **Tests cover nickname vs global name, two members sharing a name, and a cache miss** — all three exist and fail under pre-fix code. ## The snowflake guard was fixed at the producer only `2114f42` stopped the bot **writing** a bare id, and its own message notes that `speakers.get(stem) or default` catches only a *missing* key. The backend consumer kept exactly that `or`, so a snowflake **present as the value** is truthy and walked straight through into the transcript — where the LLM reads it as a person's name and merges it into a real player, which is this issue's original symptom. That is a live path, not a leftover. Every `speakers.json` written before that commit has exactly that shape; CLAUDE.md states the per-session audio directory is never deleted automatically; and reprocess is one of four documented routes back into `process_audio`. **An old recording reprocessed on a new backend reproduces the bug in full.** `resolved_speaker_name` now checks symmetrically against the stem and mirrors the bot's `unknown_speaker_label`, so a track handled by either side reads the same to a GM. ## "Flags the session" — recovered instead Looking at where the gap actually is, flagging turned out to be the weaker answer. - A linked member **with** a character was never affected — the character name wins over anything the bot wrote. - An **unlinked** speaker has nothing to recover, and already surfaces to the GM through `session.attendance_unmatched_speakers` — the attendance screen, which is exactly where someone goes to say who an unrecognised speaker was. That *is* the flag, and it already existed. - That leaves one combination: **a linked member with no character set.** Their placeholder became the transcript label while the database knew precisely who they were. So the linked account's display name now fills in — but **only** when the bot produced a placeholder, because the bot reads Discord directly and prefers the guild nickname, which is normally what a table calls each other. Its answer wins whenever it has one. Recovering the name is strictly better than flagging that it is missing, so the criterion is met in substance rather than in form. If you would still like an explicit session-level marker on top of this, that is a small follow-up and worth its own issue rather than being assumed. ## One residual the sweep missed `recording_status.py` still had `getattr(member, "display_name", str(member_id))` for `uncaptured_members` — the last bare-snowflake fallback. Lower stakes than the transcript path (no LLM reads it as a person) but the same unreadable string in front of the same GM, and there was already a helper for it. Closing.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#344
No description provided.