Skip to content

Commit cf2f238

Browse files
committed
feat(voice): ground STT analyzer prompt on the live VC roster + emit addressed_seat_no
The roster grounding fixes two compounding failure modes that surfaced when sakura said "ラキオが怪しい" in voicetest and the analyzer returned ``addressed_name=null, stance={}, vote_target_seat=null`` — i.e. nothing routable to seat 1 — even though the speaker's intent was unambiguous. 1) Whisper renders less-common Japanese names with phonetic drift ("ラキオ" → "ラッキーオ"). The static analyzer prompt only saw the mistranscription, had no anchor to the actual participant list, and could not resolve the name back to a seat. Now the STT call threads a ``roster: Sequence[(seat_no, display_name)]`` derived from the *live* VC member list (via ``bot.get_guild().get_member().display_name``, cross-referenced with NpcRegistry for NPC-bot user IDs since their seats carry ``discord_user_id=NULL``). The analyzer prompt instructs the LLM to collapse phonetic / orthographic / honorific variants onto a roster entry and to also resolve ``vote_target_seat`` against the same list. xAI Grok dry-run on the canonical failure case confirms the fix: INPUT : "ラッキーオさんが怪しいと思います" BEFORE : addressed_name=null, stance={}, vote_target_seat=null AFTER : addressed_name="🦋ラキオ", stance={"1":"negative"}, summary normalised to canonical name 2) The legacy resolver only matched against ``Seat.display_name`` (set once at backfill = the persona's canonical handle, e.g. "🦋ラキオ"). When an operator renamed an NPC bot in the guild ("Lucky"), the live VC nickname diverged from the persona handle and downstream ``resolve_seat_by_name("Lucky", seats)`` returned None — so even with a roster grounding, the analyzer's literal string answer would not survive Master-side resolution. Add an ``addressed_seat_no`` field analogous to ``vote_target_seat`` that the analyzer pre-resolves from the roster directly. Plumb it through ``SttResult`` → ``SpeechEventPayload`` and have ``MasterIngestService.ingest_voice`` prefer it when it points at an alive seat, falling back to the name path on miss / hallucinated value. Verified against Grok with a renamed-bot roster: INPUT : "Luckyさん何か言いたそうですね" (seat 1 = "Lucky") OUTPUT : addressed_name="Lucky", addressed_seat_no=1 INPUT : "ラッキーオさんに投票します" OUTPUT : vote_target_seat=1, addressed_seat_no=1 Voicetest mirrors the production resolver — the roster lookup pulls from ``vc_channel.members`` so an operator can A/B with the same nicknames the speaker actually sees on their VC overlay. A startup log line dumps the resolved roster snapshot so the prompt grounding is auditable without crawling the JSONL trace.
1 parent 70e6061 commit cf2f238

9 files changed

Lines changed: 521 additions & 19 deletions

File tree

src/wolfbot/domain/ws_messages.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,18 @@ class SpeechEventPayload(BaseEnvelope):
538538
"the analyzer didn't detect a named address."
539539
),
540540
)
541+
addressed_seat_no: int | None = Field(
542+
default=None,
543+
description=(
544+
"Seat number the analyzer pre-resolved ``addressed_name`` to "
545+
"when its prompt was grounded with a roster. Master prefers "
546+
"this over running ``resolve_seat_by_name(addressed_name, ...)`` "
547+
"which only matches against the persona-canonical "
548+
"``Seat.display_name`` and fails when the bot's live VC "
549+
"nickname diverges from the persona handle. None when the "
550+
"analyzer wasn't grounded or couldn't pick a seat."
551+
),
552+
)
541553

542554

543555
class SttFailed(BaseEnvelope):

src/wolfbot/main.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,23 @@ async def post_public(self, game_id: str, text: str, kind: str) -> None:
103103
# voice-ingest seat/phase caches (populated when integrated ingest is active)
104104
_vc_seat_map: dict[str, int] = {}
105105
_vc_phase_cache: list[tuple[str, str] | None] = [None]
106+
# Alive seats as ``(seat_no, display_name)`` so the analyzer LLM
107+
# can resolve mistranscribed names to a canonical participant.
108+
_vc_roster: list[tuple[int, str]] = []
106109

107110
async def _refresh_voice_ingest_cache(game_id: str) -> None:
108-
"""Update seat map and phase cache for the integrated voice-ingest."""
111+
"""Update seat map and phase cache for the integrated voice-ingest.
112+
113+
``_vc_roster`` holds the alive seats in ``(seat_no, display_name)``
114+
form for the analyzer LLM's name-resolution prompt. The
115+
``display_name`` here is the **live VC nickname** as Discord
116+
renders it for that participant - i.e. what the human speaker
117+
actually sees on their VC overlay - not the stored
118+
``Seat.display_name`` (which for NPC seats is the persona's
119+
canonical handle and may differ from the Discord bot's
120+
guild-side nickname). Falls back to the stored value when the
121+
member is uncached or the bot user_id isn't known yet.
122+
"""
109123
game = await repo.load_game(game_id)
110124
if game is None or game.ended_at is not None:
111125
_vc_phase_cache[0] = None
@@ -115,11 +129,42 @@ async def _refresh_voice_ingest_cache(game_id: str) -> None:
115129
_vc_phase_cache[0] = (game.id, phase_id)
116130
seats = await repo.load_seats(game_id)
117131
players = await repo.load_players(game_id)
132+
alive_seats = {p.seat_no for p in players if p.alive}
118133
_vc_seat_map.clear()
119134
for s in seats:
120-
if s.discord_user_id and any(p.seat_no == s.seat_no and p.alive for p in players):
135+
if s.discord_user_id and s.seat_no in alive_seats:
121136
_vc_seat_map[s.discord_user_id] = s.seat_no
122137

138+
# Resolve each seat's live VC display name. NPC seats have
139+
# ``discord_user_id=NULL`` in the seats table, so cross-
140+
# reference NpcRegistry to pick up the bot's actual user id
141+
# (each NPC bot logs in as its own Discord user).
142+
try:
143+
guild = bot.get_guild(int(game.guild_id)) if game.guild_id else None
144+
except (TypeError, ValueError):
145+
guild = None
146+
npc_user_by_seat: dict[int, str] = {}
147+
if _npc_registry_ref:
148+
registry = _npc_registry_ref[0]
149+
for entry in registry.assigned_to_game(game.id):
150+
if entry.assigned_seat is not None:
151+
npc_user_by_seat[entry.assigned_seat] = entry.discord_bot_user_id
152+
153+
_vc_roster.clear()
154+
for s in sorted(seats, key=lambda x: x.seat_no):
155+
if s.seat_no not in alive_seats:
156+
continue
157+
user_id_str = s.discord_user_id or npc_user_by_seat.get(s.seat_no)
158+
live_name: str | None = None
159+
if guild is not None and user_id_str:
160+
try:
161+
member = guild.get_member(int(user_id_str))
162+
except (TypeError, ValueError):
163+
member = None
164+
if member is not None:
165+
live_name = member.display_name
166+
_vc_roster.append((s.seat_no, live_name or s.display_name))
167+
123168
# ---- Master VC join lifecycle ---------------------------------------
124169
# Single VC connection; one Master = one guild = at most one active
125170
# reactive_voice game at a time. Held in a list so closures can rebind.
@@ -1103,6 +1148,15 @@ def _seat_lookup(discord_user_id: str) -> int | None:
11031148
def _phase_lookup() -> tuple[str, str] | None:
11041149
return _vc_phase_cache[0]
11051150

1151+
def _roster_lookup() -> list[tuple[int, str]]:
1152+
"""Snapshot of alive seats for grounding the STT analyzer.
1153+
1154+
Read out of the ``_vc_roster`` cache populated by
1155+
``_refresh_voice_ingest_cache`` so we don't hit the DB
1156+
on every speech segment.
1157+
"""
1158+
return list(_vc_roster)
1159+
11061160
voice_ingest = VoiceIngestService(
11071161
registry_view=_RegistryViewAdapter(),
11081162
master_client=direct_client,
@@ -1113,6 +1167,7 @@ def _phase_lookup() -> tuple[str, str] | None:
11131167
pre_stt_min_rms=settings.VOICE_PRE_STT_MIN_RMS,
11141168
pre_stt_min_duration_ms=settings.VOICE_PRE_STT_MIN_DURATION_MS,
11151169
),
1170+
roster_lookup=_roster_lookup,
11161171
)
11171172
if settings.VOICE_STT_PROVIDER == "groq":
11181173
log.info(

src/wolfbot/master/ingest_service.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,23 @@ async def ingest_voice(
232232
alive_seat_nos=alive_seat_nos,
233233
)
234234

235+
# Prefer the analyzer's pre-resolved seat number when its
236+
# prompt was grounded with a roster - that path bypasses the
237+
# legacy ``resolve_seat_by_name`` string match, which only
238+
# compares against ``Seat.display_name`` (=persona handle)
239+
# and silently drops the address when the live VC nickname
240+
# diverges. Validate the seat is actually alive in this
241+
# phase before trusting it; fall back to the name-resolution
242+
# path otherwise so a buggy / hallucinated seat number
243+
# doesn't poison the routing.
235244
addressed_seat_no: int | None = None
236-
if payload.addressed_name:
245+
alive_set = set(alive_seat_nos)
246+
if (
247+
payload.addressed_seat_no is not None
248+
and payload.addressed_seat_no in alive_set
249+
):
250+
addressed_seat_no = payload.addressed_seat_no
251+
elif payload.addressed_name:
237252
try:
238253
addressed_seat_no = await self.phase_lookup.resolve_addressed_seat(
239254
payload.game_id, payload.addressed_name
@@ -245,9 +260,9 @@ async def ingest_voice(
245260
payload.addressed_name,
246261
)
247262
addressed_seat_no = None
248-
# Self-address never needs a routed reply.
249-
if addressed_seat_no is not None and addressed_seat_no == payload.seat_no:
250-
addressed_seat_no = None
263+
# Self-address never needs a routed reply.
264+
if addressed_seat_no is not None and addressed_seat_no == payload.seat_no:
265+
addressed_seat_no = None
251266

252267
event = SpeechEvent(
253268
event_id=new_event_id(),

0 commit comments

Comments
 (0)