[Backend] Resolve every speaker label to a character in code, and delete the prompt-time rename #329
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: HIGH. Found in the August 2026 session lifecycle review (#319). Prerequisite for the rest of this milestone — it shrinks the attribution error surface under every design that follows, at zero token cost.
The defect
Speaker-to-character resolution happens twice, half in code and half in the prompt, and the two halves disagree.
_apply_character_names(reminder_tasks.py:2121-2131) relabels segments for linked players who have acharacter_nameset — before the transcript is built. Unlinked players and players without a character keep their Discord display names; GM lines keep the GM's display name. The result is a mixed transcript.The prompt then still lists "X is playing Y" and instructs "Use character names (not player names) when referring to player actions" (
audio_service.py:927) — an instruction that is redundant where the code already renamed, and where it is not redundant forces the model to perform a global rename across thousands of lines. Separately,_format_gm_context(audio_service.py:1103-1115) tells the model "The GM may also voice NPCs, so use context to distinguish narration from roleplay" — an explicit invitation to reassign the speaker of GM-labelled lines by guesswork.There is also a trap: if the GM's own member row has a
character_name,_apply_character_namesrelabels all GM narration as that character whilegm_namesstill lists the display name — so the GM-context block points at a name that no longer appears in the transcript.Proposed fix
Resolve every label in code, and take naming out of the model's hands entirely:
GMlabel, always, regardless of whether the GM row has a characterThen delete the "use character names" instruction and replace the GM-guessing invitation with a hard rule:
Emit a machine-readable legend (speaker label → role, character, user id) alongside the transcript. The beat validator in this milestone depends on it.
Acceptance criteria
GM, and a GM with a character set does not break the legendVerified. Four of five criteria were genuinely met; the fifth was half-done and is fixed in
152048e.Criteria
resolve_speakersproduces the label,process_audiobuilds tracks from it, andtranscribe_trackstampsspeaker/track_owner_idonto every segment. As of #342 the server's echoed label is not consulted on any path, so this is now structural rather than a convention.GM, and a GM with a character set does not break the legend — a GM's characters can never enter the legend.The legend was persisted only for runs that produced beats
_persist_summarisation_runreturned early whenever a run had no beats, so a run that fell back to prose stored no legend at all — and those are exactly the sessions where attribution was trusted rather than checked, i.e. the ones whose legend is most worth having.Every run now gets a row (#333), so the legend is persisted unconditionally.
used_beatsdistinguishes the two cases, which is what made the empty row unambiguous enough to write in the first place.One thing worth noting for later
The legend lives on
summarisation_runs.speaker_legend— persisted with the run, not with the transcript.transcript_segmentscarries(track_owner_id, speaker_label)per line, which is the label↔owner half only: no role, no character list. Nothing stores a legend againstsessionsitself.That satisfies the criterion as written — the legend is produced and persisted, stored as it was used rather than recomputed, which is the property #330 makes matter. But if something later wants "the legend for this transcript" without going through a summarisation run, it does not exist yet. Recording it rather than leaving it to be rediscovered.
Also strengthened here
_best_display_name(#344) means a speaker the bot could not name is now resolved from the linked account rather than carrying a placeholder into the transcript — one more label decided in code instead of left as a stand-in.Closing.