feat(reactive_voice): リアルタイムチャットボット — 席ごとの NPC 音声 + 構造化された arbiter#1
Open
skr19930617 wants to merge 134 commits into
Open
feat(reactive_voice): リアルタイムチャットボット — 席ごとの NPC 音声 + 構造化された arbiter#1skr19930617 wants to merge 134 commits into
skr19930617 wants to merge 134 commits into
Conversation
Implements the `add-master-npc-voice-stt` OpenSpec change. Splits the bot into Master / per-NPC voice bot / voice-ingest worker components driven by a unified `SpeechEvent` bus. New capabilities (additive): - speech-event-bus: canonical `SpeechEvent` model + `speech_events` table, phase_baseline sentinel, deterministic `PublicDiscussionState` fold. - voice-ingest: human-only VAD/STT worker with NPC packet filtering and fail-closed registry view. - npc-voice-pipeline: Master ↔ NPC localhost WebSocket protocol, NPC registry with heartbeat, `MasterLogicBuilder` / `SpeakArbiter`, three audit tables (`npc_speak_requests` / `npc_speak_results` / `npc_playback_events`), recovery sweep with master_restart semantics, per-NPC bot worker with TTS + playback gating. Modified capabilities: - day-discussion: `LLM_DISCUSSION_MODE` setting (rounds default, reactive_voice opt-in), mode fixed per-game via `Game.discussion_mode`, `discussion_phase_summary` structured log event. - llm-seats: rounds-mode SpeechEvent backfill via optional `DiscussionService` on `LLMAdapter`; reactive_voice routes through `SpeakArbiter` instead of fixed-round batches. - discord-integration: NPC bots and voice-ingest share `MAIN_VOICE_CHANNEL_ID`, NPC playback gated on `PlaybackAuthorized`. New code modules: `domain/discussion.py`, `domain/ws_messages.py`, `services/discussion_service.py`, `services/master_logic_service.py`, `services/master_ws_server.py`, `services/master_ingest_service.py`, `services/npc_registry.py`, `services/speak_arbiter.py`, `services/npc_client.py`, `services/npc_speech_service.py`, `services/tts_service.py`, `services/voice_playback_service.py`, `services/voice_ingest_service.py`, `services/voice_ingest_client.py`, `services/stt_service.py`, `services/structured_logging.py`, `services/discussion_phase_summary.py`, `npc_bot_main.py`. Persistence: additive migrations only — `speech_events`, `npc_speak_requests`, `npc_speak_results`, `npc_playback_events`, plus `games.discussion_mode` column. Configuration: new env vars `LLM_DISCUSSION_MODE`, `MASTER_WS_LISTEN`, `MASTER_NPC_PSK`. Existing env vars unchanged. Default behaviour (rounds mode, no NPC bot processes) preserves observable behaviour for existing games. Testing: 681 tests pass (up from 599). All external surfaces (STT, TTS, NPC ↔ Master WS, Discord VC, voice playback) reached via Protocols with Fakes — no live STT/TTS/Discord/xAI traffic in the suite. Strict mypy clean, ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…ation - npc_bot_main.py: rewrite from stub to production wiring (Discord VC join → Master WS → GrokNpcGenerator → VOICEVOX TTS → VC playback) - npc_generator_grok.py: xAI Grok-backed NpcGenerator with structured JSON output - audio_sink.py: VoiceRecvClient AudioSink bridging PCM to VoiceIngestService - main.py: integrate voice-ingest (VoiceRecvClient + GeminiAudioAnalyzer), add NPC seat assignment in _on_reactive_phase_enter - stt_service.py: GeminiAudioAnalyzer for speech-to-text + structured analysis - tts_service.py: VoicevoxTtsService with two-step audio_query/synthesis API - voice_ingest_client.py: DirectMasterIngestionClient for in-process ingest - speak_arbiter.py: STT gate, playback deadline, recovery re-dispatch - pyproject.toml: add wolfbot-npc entry point, discord-ext-voice-recv dep - .env.example: align NPC env var names with code, add missing vars - CLAUDE.md: document wolfbot-npc command and reactive voice architecture - test_npc_seat_assignment.py: 8 tests for seat assignment + prompt building
Reorganizes the reactive-voice pipeline so each NPC bot is a self-
contained worker bound to one persona, and gives the Master process
and NPC bot processes clearly separated env files + LLM credentials
that are named after their *role*, not their backend.
Package split
-------------
- `wolfbot/master/` (new): Master-side reactive_voice pipeline
(ws_server, ingest_service, logic_service, speak_arbiter,
npc_registry, voice_ingest_*, audio_sink, stt_service).
- `wolfbot/npc/` (new): NPC bot worker package — `main` (was
`npc_bot_main`), `client`, `speech_service`, `playback`, `tts`,
`openai_compatible_generator` (was `npc_generator_grok`),
`config` (NpcSettings), `personas` (the 14 Gnosia characters).
- `wolfbot/master/personas.py`: 3 simple GM narrator voices
(`stoic_gm` / `theatrical_gm` / `warm_gm`) — tone only, game
flow unchanged.
- `wolfbot/llm/persona_base.py` (renamed from `personas.py`):
shared `Persona`, `SpeechProfile`, and pool-agnostic pickers.
- `wolfbot/services/` now hosts only shared core orchestration.
Persona binding model
---------------------
Each NPC bot process is bound to one persona at startup via
`NPC_PERSONA_KEY`; on `npc_register` the bot tells Master its
persona, and Master fills LLM seats from the online registry in
reactive_voice mode (drops the previous "Master rolls personas"
step that left every NPC silently speaking as Setsu).
Generator renames + backend swap
--------------------------------
- `GrokNpcGenerator` -> `OpenAICompatibleNpcGenerator`, with
`base_url` configurable so the NPC LLM provider is a one-env-var
swap (xAI / OpenAI / Groq / Together / vLLM / Ollama / ...).
- `set_persona()` is now mandatory and validated — generating
before binding raises instead of falling back to a default.
Settings + env split
--------------------
- `Settings` -> `MasterSettings`, reads `.env.master`.
- New `NpcSettings` reads the file pointed to by `WOLFBOT_NPC_ENV`.
- LLM env vars renamed by responsibility (no backend names):
* Master: XAI_API_KEY/XAI_MODEL -> GAMEPLAY_LLM_*
GEMINI_API_KEY/GEMINI_MODEL -> VOICE_LLM_*
* NPC: XAI_API_KEY/XAI_MODEL -> NPC_LLM_*
LLM_BASE_URL -> NPC_LLM_BASE_URL
- `.env.example` -> `.env.master.example` at repo root.
- 14 per-persona templates committed at
`envs/npc/.env.<persona>.example` plus `envs/npc/README.md`
with the setup workflow and persona / TTS_VOICE_ID table.
- `pyproject.toml`: `wolfbot-npc` entry now points at
`wolfbot.npc.main:main`.
Tests + docs
------------
All 689 tests updated and passing. CLAUDE.md, README.md, AGENTS.md
rewritten to reflect the new layout, env split, and reactive_voice
seat-assignment behaviour.
scripts/run-bots.sh creates a tmux session with one window per bot (Master + auto-detected NPC personas from envs/npc/.env.<persona>), streams each bot's output to logs/<persona>.log, and keeps windows open after a process exits so failures stay readable. Validates prerequisites (tmux/uv/.env.master) and surfaces friendly errors. scripts/stop-bots.sh tears the session down in one tmux kill-session. Why: 11 separate terminals for "master + 9 NPC bots" runs is the main friction in observing reactive_voice games. A single launch script with window-per-bot makes the multi-process workflow practical to iterate on. README documents the new flow (Step 5) including the human-0 / NPC-9 spectator setup. logs/ is gitignored.
Hoist VOICEVOX speaker ID from per-persona env templates into the Persona dataclass, so wolfbot.npc.personas is the single source of truth for the persona → voice mapping. Field is optional (defaults to None); NPC personas carry explicit values, Master narrator personas leave it unset. Why: the upcoming env-template consolidation needs a programmatic mapping from persona key to TTS voice id. Embedding it in Persona keeps the data co-located with style_guide / speech_profile / judgment_profile and lets the env generator read it directly without a parallel TSV. Voice IDs match the README mapping unchanged (8/9/2/13/4/11/6/1/12/0/7/5/10/3).
Replaces 14 per-persona templates (envs/npc/.env.<persona>.example) with
a single envs/npc/.env.npc.example carrying {{NPC_ID}} / {{NPC_DISCORD_TOKEN}}
/ {{NPC_PERSONA_KEY}} / {{DISCORD_GUILD_ID}} / {{MAIN_VOICE_CHANNEL_ID}}
/ {{MASTER_NPC_PSK}} / {{NPC_LLM_API_KEY}} / {{TTS_VOICE_ID}} placeholders.
scripts/generate_npc_envs.py reads:
- tokens.txt (one line per persona: '<key>: <discord-bot-token>')
- .env.master (DISCORD_GUILD_ID / MAIN_VOICE_CHANNEL_ID
/ MASTER_NPC_PSK / GAMEPLAY_LLM_API_KEY)
- the unified template above
- wolfbot.npc.personas (NPC_PERSONA_KEY + tts_voice_id)
and writes envs/npc/.env.<persona> for each persona present in tokens.txt.
Why: 14 hand-edited templates with mostly-shared values were error-prone
(PSK mismatches and copy-paste typos in DISCORD_GUILD_ID hit during the
first reactive_voice setup). Single source + generator lets a token-list
update propagate to all NPCs in one re-run.
Features:
- --dry-run / --no-overwrite / --tokens / --master / --template / --out-dir
- PERSONA_ALIASES auto-corrects common typos (e.g. 'setu' → 'setsu')
- 'master' line in tokens.txt is skipped with a note (Master token
belongs in .env.master, not envs/npc/)
- Helpful errors on missing files, empty .env.master keys, unknown
persona keys, or missing tts_voice_id
tokens.txt added to .gitignore (per-persona Discord secrets); .env.*.example
glob un-ignore narrowed to .env.npc.example. README + envs/npc/README.md
rewritten around the new flow.
Two production issues caught during the first 9-NPC dry run: 1. **uv environment leak**: outer shell often has UV_PYTHON or VIRTUAL_ENV pointing at a different project's Python (3.12/3.14), which makes `uv run wolfbot` refuse to launch with "incompatible interpreter" inside the tmux pane. Switched to invoking .venv/bin/wolfbot and .venv/bin/wolfbot-npc directly — the entry-point shebangs already point at the project's pinned 3.11 Python, so uv's interpreter resolution is bypassed entirely. Pre-flight check now demands the entry points exist (i.e. `uv sync` has been run at least once). 2. **NPC race against Master WS bind**: tmux send-keys returns immediately, so all 9 NPC processes were starting in parallel with Master's Discord login + VC join (~6 s) before the WS server bound port 8800. NPCs that hit this window died with ConnectionRefusedError (no auto-retry in the client). Added a log-tail wait loop that watches for `master_ws_listening` in master.log before launching the first NPC. Polling via /dev/tcp probe was rejected because writing a stray newline to the websockets server logs spurious EOFError tracebacks; tail-grep is non-intrusive. Verified end-to-end: 9/9 NPCs joined VC and registered on the Master WS (no ConnectionRefusedError, no stale entry-point ImportError).
Take origin/main's xAI / DeepSeek / Vertex Gemini backend switching and extend it so Master (GAMEPLAY_LLM_*) and each NPC bot (NPC_LLM_*) both expose the same provider menu under symmetrical env-var prefixes. Core abstraction: new LLMDeciderConfig (wolfbot.llm.decider_config) — a frozen dataclass holding every provider-specific knob (api_key, model, base_url, thinking, reasoning_effort, vertex_project, vertex_location, thinking_level). MasterSettings.gameplay_decider_config() and NpcSettings.npc_decider_config() project each Settings instance onto this shared dataclass; make_llm_decider(cfg) and make_npc_generator(cfg, persona_key) are role-blind factories that consume it. NPC generator changes: - OpenAICompatibleNpcGenerator gains mode="json_object" so DeepSeek's extra_body / reasoning_effort path works with the existing class. - New GeminiNpcGenerator (Vertex AI ADC) + generator_factory mirror the gameplay decider factory so the NPC schema stays separate. Settings validators reject misconfig at boot: - xai/deepseek require *_LLM_API_KEY - gemini requires *_LLM_VERTEX_PROJECT (empty string rejected) .env.master.example, envs/npc/.env.npc.example, README.md, CLAUDE.md, and pyproject.toml updated to document the unified switch. uv.lock regenerated to add google-genai >=1.69. Tests: 796 passed (added 20 around provider switching). ruff + mypy clean.
The previous picker sorted online NPCs by `assigned_seat` ascending and
returned the first eligible match. In pure-NPC games where no human
speech triggers rotation, this meant the lowest-seat NPC monopolized
every dispatch — every `try_dispatch_next` call after `playback_finished`
would re-pick the same NPC.
The original code comment ("Prefer silent seats, but fall back to any
alive NPC") signaled the intended behavior, but the implementation never
consulted `PublicDiscussionState.silent_seats`. This commit makes the
sort key two-stage — silent seats first, then by seat number — so every
alive NPC speaks at least once before any repeats. Once `silent_seats`
is empty (everyone has spoken), the bucket becomes a no-op and ordering
falls back to the historical seat-ascending behavior.
Adds a fourth LLMProvider value `mock` alongside xai/deepseek/gemini that wires both the Master Gameplay LLM and the per-NPC speech LLM to deterministic in-process stubs — no network, no API key, no Vertex project required. `MockLLMActionDecider` (services/llm_service.py) classifies the prompt by phrases unique to each `task_*` generator in prompt_builder: - vote prompts → `intent=vote, target_name=None` so LLMAdapter's existing _resolve_target falls back to a uniform-random legal seat - night-action prompts → same null-target fallback - wolf-chat / daytime speech → round-robin canned phrases `MockNpcGenerator` (npc/mock_generator.py) ships per-persona scripted phrase pools for every persona key in NPC_PERSONAS_BY_KEY. Unknown personas drop to a generic fallback so the offline rig keeps working when a new persona is added without a mock script. Both factories (`make_llm_decider`, `make_npc_generator`) gain a `mock` branch; the Settings model_validators in Master/NPC configs pass mock through without requiring credentials. Designed so the full Master + NPC reactive_voice pipeline (WS, SpeakArbiter, VOICEVOX TTS, Discord VC playback) runs end-to-end against canned data — useful when verifying the orchestration layer without burning real LLM tokens.
Introduces wolfbot.domain.durations as the single source of truth for all phase deadlines. Previously the values were module-level int constants in state_machine.py and a function in rules.py, hardcoded at 60/60/90/30/30 and 300/240/180. They are now fields on a frozen PhaseDurations dataclass held in a process-wide singleton. Read API: current_phase_durations() returns the live instance; every plan_*() in state_machine.py now reads through it instead of bare constants. day_discussion_duration() in rules.py delegates to PhaseDurations.discussion_for_day(). Write API: set_phase_durations(d) swaps the slot atomically. Called once at boot from MasterSettings.apply_phase_durations(), which builds a PhaseDurations from env vars (WOLFBOT_PHASE_DURATION_FACTOR for a global multiplier, plus per-phase WOLFBOT_*_DURATION absolutes that override the factor result). The factor path clamps each value to a 1-second minimum so a 0.001 factor doesn't produce a 0-second deadline that the engine's deadline-watcher would advance through immediately. Backwards compat: state_machine.VOTE_DURATION (and the other four historical names) is preserved via a PEP 562 module-level __getattr__ that proxies to the singleton. `state_machine.NIGHT_DURATION` returns the current value at access time. `from state_machine import NIGHT_DURATION` still binds the local name at import time, which is fine for the existing tests that assert against the default value; new code that needs runtime tracking should import current_phase_durations directly. This refactor is the prerequisite for a future /wolf settings duration_factor 0.5 slash command — the slash handler will call set_phase_durations(...) on the same singleton, and the change will take effect on the next phase transition (current phase deadlines already in the DB are not retroactively shortened).
Adds a --mock flag that injects three OS env vars before spawning Master and every NPC bot: - GAMEPLAY_LLM_PROVIDER=mock - NPC_LLM_PROVIDER=mock - WOLFBOT_PHASE_DURATION_FACTOR=0.1 (10x faster phases) pydantic-settings reads OS env before the .env file, so the user keeps their real .env.master / envs/npc/.env.<persona> intact and a single flag flips the LLM and pacing layers to deterministic test stand-ins. Useful for verifying the WS arbiter, VOICEVOX TTS, and Discord VC playback chain without burning real LLM credits. Also probes the VOICEVOX engine at $WOLFBOT_VOICEVOX_URL (default localhost:50021) before launching bots. NPCs would otherwise spin up, register on the WS, then fail silently the moment a SpeakRequest arrives. The probe is a warning, not a hard error, since the engine might come up after the launcher returns. Flag parsing rejects unknown -* arguments early so a typo fails fast rather than landing in the persona arg list.
Adds the data signal a human uses when calling out a specific NPC
("セツさん、どう思う?") so downstream pickers can route the reply.
- Gemini audio analyzer prompt asks for `addressed_name` and parses it
into `SttResult.addressed_name`.
- `SpeechEventPayload.addressed_name` carries the literal handle on the
voice-ingest → Master wire.
- `SpeechEvent.addressed_seat_no` (and a new `speech_events.addressed_seat_no`
column with PRAGMA migration) records the resolved seat.
- `PublicDiscussionState` gains `last_addressed_seat` /
`last_addressed_speaker_seat` / `last_addressed_text`, populated by the
fold and cleared the next time an NPC speaks.
When a human says "セツさん、どう思う?" the seat-2 NPC must reply first, with the actual question forwarded so its LLM has on-topic context. - `MasterIngestService.ingest_voice` resolves `addressed_name` to a seat via the new `resolve_seat_by_name` helper (NFKC + emoji-strip + honorific-tolerant matching against `display_name` / `persona_key` / numeric "席X"/"X番"/"seat X" patterns; alive-only; ambiguity → None; self-address suppressed). - `PhaseLookup` Protocol grows a `resolve_addressed_seat` method; the in-process `_RepoPhase` adapter wires it to repo-loaded seats + alive set. - `SpeakArbiter.try_dispatch_next` adds `is_addressed` as the highest- priority sort key (addressed > silent > lowest-seat). Falls back to silent rotation when the addressed NPC is offline. - `build_logic_packet` appends `last_address=席N from=席M text="…"` to `public_state_summary` so even a fallback pick sees the human's actual question. - 15 new tests in `test_addressed_npc_routing.py` cover analyzer parsing, name resolution, ingest resolution, fold semantics, picker priority, fallback, packet summary, and an end-to-end flow.
…hannel Text-channel utterances now extract `addressed_name` + `co_declaration` via a dedicated Gemini call, the same way voice does, so a typed "〜さん、どう思う" routes the reply to the named NPC seat. - New `wolfbot.master.text_analyzer` with `TextAnalyzer` Protocol, `GeminiTextAnalyzer` (httpx, lazy-imported), and `FakeTextAnalyzer` for tests. The Gemini schema mirrors `GeminiAudioAnalyzer`'s output for the two fields the text path needs. - `WolfCog.on_message` calls the analyzer when wired and resolves `addressed_name` to a seat via the existing `resolve_seat_by_name` helper. Self-address is suppressed; analyzer failures are caught and the SpeechEvent is still written with `addressed_seat_no=None`. - `make_human_text_event` accepts `addressed_seat_no` so the text fold feeds the same `PublicDiscussionState.last_addressed_seat` path used by voice — SpeakArbiter picker + LogicPacket summary then work identically for both modalities. - `main.py` constructs `GeminiTextAnalyzer` only when reactive_voice + PSK + voice LLM key are all set; otherwise text falls back to plain raw capture (historical behavior preserved). - 8 new tests in `test_text_analyzer.py` (parser + Fake stub) and 2 new WolfCog integration tests in `test_addressed_npc_routing.py` (analyzer wiring + analyzer-failure fallback).
Previously every NPC bot joined the voice channel the moment its process became Discord-ready, so /wolf start saw a packed VC even before any seat assignment. Now NPCs idle (Discord + WS connected, not in VC) until Master sends a `seat_assigned`; on game end / host_abort Master sends `seat_released` and the bot leaves VC. Master side: - New WS messages `SeatAssigned` / `SeatReleased`. - `_on_reactive_phase_enter` sends `SeatAssigned` to each picked NPC bot after `npc_registry.assign(...)` so unselected bots stay out of VC. - New `_on_reactive_game_end` sweep iterates registry entries attached to the ending game, sends `SeatReleased`, then unassigns. - `GameService` gains an `on_reactive_game_end` callback fired at natural end and host_abort. `NpcRegistry` gains `unassign()` and `assigned_to_game()` for the sweep. NPC side: - `on_ready` no longer auto-connects to VC; just signals Discord ready. - Lazy `_ensure_vc_joined()` / `_ensure_vc_left()` helpers (mutex- guarded) wired into `NpcClient.on_vc_join` / `on_vc_leave`. - `NpcClient` handles `seat_assigned` / `seat_released`; the `npc_registered` reply also triggers the join callback when it carries an existing assignment (reconnect-mid-game recovery). - VC-join callback failures are caught and logged so a transient Discord hiccup doesn't crash the message loop. 11 new tests in `test_npc_seat_lifecycle.py` (NpcClient handlers, recovery path, registry helpers, wire round-trip) plus a focused GameService test for the new `on_reactive_game_end` callback.
Master previously joined the voice channel during `on_ready` no matter what mode the next game ran in. With the NPC-bot fix landed in 998433a, Master was the only bot still squatting in VC before any game existed. - Move VC join out of `on_ready` into a `_master_join_vc_for_game` helper, gated on `voice_ingest is not None` AND `discussion_mode == reactive_voice`. Idempotent so re-entry / recovery is a no-op. - `_on_reactive_phase_enter` calls it after refreshing the voice-ingest cache so the connection lands before the arbiter starts dispatching. - `_on_reactive_game_end` now also calls `_master_leave_vc()` after the NPC-release sweep, so Master + every NPC bot exit the channel together on game end / abort. - Recovery path wraps `arbiter.try_dispatch_next` so a Master restart mid-game re-joins VC for in-flight reactive_voice games. Rounds-mode games are completely VC-free now: neither Master nor NPCs ever connect.
Adds a host-facing UI for the runtime-mutable phase-duration singleton
that already lives in `wolfbot.domain.durations`. Previously the only
way to change vote / night / discussion lengths was env vars at boot.
- New slash command `/wolf settings` (ephemeral, host-only — checked
against the active game's `host_user_id`). Posts an embed listing the
current 8 duration fields plus a `PhaseDurationsView`.
- View components:
- Select dropdown to pick one of 8 fields → opens a Modal with the
current value pre-filled; submit applies via
`set_phase_durations(replace(current, **{field: new}))`.
- "一括スケール" button → Modal for `with_factor(...)`, the same
bulk-scale knob env vars expose as `WOLFBOT_PHASE_DURATION_FACTOR`.
- "デフォルトに戻す" button → resets to dataclass defaults.
- `interaction_check` re-validates the host on every nested modal /
button so a passed-around ephemeral message can't be exploited.
- Persistence is process-memory only by design; Master restart reverts
to env-derived defaults. Wiring is identical to the future DB-backed
variant if persistence is added later.
- 9 unit tests cover the embed rendering, host-vs-non-host gating, the
edit modal's success/reject paths, and the factor modal.
Two changes scoped to reactive_voice mode quality:
1. Master narration via VOICEVOX
- Single Levi persona replaces the 3-persona pool (`stoic_gm` /
`theatrical_gm` / `warm_gm`). The trio was never wired to a
selection mechanism, so collapsing them is safe.
- New `wolfbot.master.narration` renders per-`kind` Levi-tone
templates (polite, machine-like, fact-first). Embeds runtime
phase durations + day_number + alive_count + executed seat name.
- Splits long content from short: EXECUTION / NO_EXECUTION /
RUNOFF_START voice the headline only, the vote tally goes to the
VC's attached text chat. ROLE_REVEAL is text-only (9 lines).
- New `wolfbot.master.tts_playback.MasterTtsPlayback` synthesizes
via the shared VOICEVOX engine and plays through Master's
`discord.VoiceClient`. While Master is mid-narration the
SpeakArbiter's `_active_playback` carries a sentinel so NPC
dispatch is suppressed (no audio overlap).
- `DiscordBotAdapter` gains a `public_post_narrator` hook;
`post_public` / `post_morning` route through it first. In
reactive_voice mode the legacy main-text-channel post is
suppressed entirely (text already in `logs_public` for
post-mortem). In rounds mode the hook is None and behavior is
unchanged.
- New settings `MASTER_TTS_VOICE_ID` (default 47, ナースロボ_タイプT)
and `MASTER_VOICEVOX_URL`.
2. phase_baseline seed for reactive_voice
- Rounds mode seeds the `phase_baseline` SpeechEvent inside
`submit_llm_discussion_rounds`. Reactive_voice skips that batch
so the sentinel was never written, and SpeakArbiter's
`rebuild_public_state` returned None on every dispatch — NPCs
joined VC but stayed silent.
- `_on_reactive_phase_enter` now calls `begin_phase_if_absent`
before assignment, idempotent across re-entries / recovery.
17 new tests in `tests/test_master_narration.py` pin the per-kind
template shape (variable embedding, voice/chat split, fall-through to
silent on unknown kinds).
Three reactive_voice quality fixes that prevent audio overlap and
keep dead players from polluting the public log.
1. Master TTS waits for NPC playback to drain (tts_playback.py)
`MasterTtsPlayback.suppress_npc_dispatch` now polls the arbiter's
`_active_playback` set until non-master entries are gone before
adding its own sentinel. Without this, an already-authorized NPC
utterance could play simultaneously with Master narration in the
same VC. Bounded to 15s so a leaked NPC entry can't deadlock the
engine. Master-narration sentinels are skipped during the wait so
back-to-back narrations don't self-block.
2. Dead-player VC mute (permission_manager.py)
`PermissionManager.kill` now sets `speak=False, use_voice_activation=False`
on the VC channel for the dead member. `apply()` reconciles the
same on Master restart so a recovered game keeps muting dead seats.
`on_game_end` clears the per-member VC overrides alongside the
main-text overrides so the next game starts clean. Channel-level
override is used rather than `member.edit(mute=True)` so the
permission stays scoped to this game's VC and doesn't require
admin perms.
3. Defensive dead-speaker STT discard (ingest_service.py)
`MasterIngestService.ingest_voice` now drops payloads whose
`seat_no` is not in the alive set with `failure_reason=dead_speaker_discarded`.
Voice-ingest's VAD entry already filters dead audio (their seat is
pruned from the lookup map by `_refresh_voice_ingest_cache`), but a
delayed Gemini result for a player who died mid-segment could still
land on Master after the cache refresh — this is the last line of
defense against a dead player injecting a SpeechEvent into the
public log.
11 new tests across:
* tests/test_master_tts_playback.py — sentinel hold, drain wait,
timeout safety, missing-attribute graceful fallback
* tests/test_permission_manager.py — kill/apply/on_game_end VC
override coverage
* tests/test_master_ws_server.py — dead_speaker_discarded path
- state_machine: forward actor_seat into EXECUTION (executed_seat) and MORNING (killed_seat) public logs so Levi narration voices the actual seat label instead of "対象不明". - llm_service: MockLLMActionDecider picks the smallest 席N from the candidate line on night actions. legal_attack_targets excludes all wolves so both wolves see the same list and converge — no more nightly WAITING_HOST_DECISION on wolf-attack split. - npc/main: retry websockets.connect on ECONNREFUSED with capped exponential backoff (~15s total) so a startup race doesn't kill the NPC worker. - scripts/run-bots: sleep 0.4 between NPC launches to spread Discord login completion and avoid the simultaneous WS accept race.
Three coupled fixes so day-1 narration is actually heard: - main: lazy-join VC inside `_master_narrate` so SETUP_COMPLETE and the day-1 PHASE_CHANGE — both posted *before* `_on_reactive_phase_enter` ran — get voiced instead of silently falling through to text. - main: push `deadline_epoch` forward after each TTS playback. Phase deadlines were committed at plan_next time, so a 15s announcement consumed straight into the next phase budget — in mock mode the vote window expired before NPCs could act. Deadline is now reset to `clock() + duration` once Levi finishes, but only when that pushes it forward (never backward). - main + discord_service: add `_on_reactive_game_start` hook fired from `/wolf start` after `claim_start_and_backfill`. Master joins VC, NPC bots are assigned and dispatched SeatAssigned, and we briefly poll the VC roster until the bots actually arrive — so the SETUP_COMPLETE narration plays into a populated channel. The existing phase-enter hook reuses the same NPC-assignment helper and stays a no-op when game-start already claimed everyone.
Task keywords ("対象を 1 名選んでください", "投票先として合法な候補は",
"人狼チャット") are injected into the *system* prompt by
build_system_prompt's {task_block}, not the user context. The mock
decider was matching only against user_context, so every vote / night
action / wolf-chat call fell through to the canned-speech default,
leaving target_name=None and triggering "LLM returned null target"
random fallback in _resolve_target — which in turn caused the wolves
to split on attack and parked the game in WAITING_HOST_DECISION every
night. Scan a combined haystack so dispatch works regardless of which
prompt carries the task.
Visually surface "who is alive and allowed to speak" via VC mute icons. Humans (via channel-overwrite speak=False, scoped to the game's VC): - Allowed to speak only when alive AND phase is DAY_DISCUSSION. - Vote / runoff / night / dead → speak revoked. Reconciled inside PermissionManager._apply_vc_mute, now phase-aware. NPC bots (via gateway self-mute, no admin power needed): - New WS message SetMuteState (Master → NPC). NPC's NpcClient handler forwards to a self-mute callback that calls Guild.change_voice_state(self_mute=...) on the bot's own connection. - _ensure_vc_joined connects with self_mute=True so bots land already muted — no flicker window before Master sends the first state. - DiscordBotAdapter.set_npc_registry late-binds the registry; the apply / reconcile / on_game_end paths now drive _apply_npc_server_mute (renamed but kept as the public contract) which dispatches SetMuteState messages instead of member.edit(mute=...). Side-stepping member.edit removes the MUTE_MEMBERS permission requirement *and* Discord's role-hierarchy rule (Master role must sit strictly above every NPC bot role), both of which fail under typical multi-bot setups and surfaced in production as 403 Forbidden 50013. Tests: - test_permission_manager: alive humans muted in vote / runoff / runoff-speech / night phases. - test_npc_seat_lifecycle: SetMuteState routes to on_set_mute callback, ignores mismatched npc_id, and a callback exception does not break the message loop.
Tighten reactive_voice mode so Master text never leaks to the guild's main text channel, NPCs own their own VC-chat lines, and `/wolf abort` leaves a quiet voice channel — no rejoining bots, no Discord timeout. Master text routing - `_main_text` redirects to the VC's attached text chat in reactive_voice mode (TextChannel | VoiceChannel; both are Messageable). Affects `post_public` / `post_morning` fallback + `announce_waiting` / `announce_recovery`. Rounds mode keeps the legacy main-text behavior. - `_post_to_vc_chat` drops its main-text-channel fallback. Silence on resolution failure beats a leak. NPC self-post - `NpcClient.on_post_chat` callback fires from `_on_playback_authorized` with the spoken text. NPC main wires it to send via the bot's own account so the line carries the NPC avatar / persona name in VC chat. - `_DiscussionPoster.post_public` skips PLAYER_SPEECH in reactive_voice mode so Master no longer mirrors NPC speech (avoids duplicates) and human STT transcripts stop getting reposted (they spoke in voice). Abort hardening - `/wolf abort` defers the interaction; the SeatReleased fanout to 9 bots routinely overshoots Discord's 3 s deadline and surfaced as 「アプリケーションが応答しませんでした」. - `_on_reactive_game_end` broadcasts SeatReleased to every online NPC (union of `assigned_to_game(game_id)` and `all_online()`), not just registry-pinned ones, so abort is a sweep. - `_ensure_vc_left` always emits `change_voice_state(channel=None)` to the gateway so a ghost VC connection from a prior bot session is evicted even when this process never tracked a VoiceClient. - `_on_reactive_phase_enter` and `_master_narrate` bail when the game is already ended. Without this an in-flight transition's `_dispatch_submissions` lands a few hundred ms after abort and rejoins Master to VC — exactly the "Master came back" flicker.
The MORNING log narration already announces 夜が明けました with day number and casualty info. The follow-up PHASE_CHANGE→DAY_DISCUSSION narration was repeating it, so day 2+ heard 夜が明けました twice in a row. Day 1 keeps the dawn prefix because there is no MORNING entry.
Adds wolfbot.services.llm_trace, an append-only per-game JSONL writer
wired into all six LLM call sites:
gameplay — XAILLMActionDecider / DeepSeekLLMActionDecider /
GeminiLLMActionDecider
npc_speech — OpenAICompatibleNpcGenerator / GeminiNpcGenerator
voice_stt — GeminiAudioAnalyzer (called from voice_ingest_service)
Each call captures system_prompt, user_prompt, raw response, latency_ms,
error, plus contextual phase / day / actor / task metadata propagated via
contextvars (no Protocol changes). Output goes to
logs/llm_calls/{game_id}/{role-or-persona}.jsonl, allowing post-game
replay of why every NPC voted, spoke, or dropped a transcript.
Per-file asyncio.Lock serializes concurrent appends so parallel LLM
seats interleave at line boundaries. Disable via
WOLFBOT_LLM_TRACE_DISABLED=1; override base path via
WOLFBOT_LLM_TRACE_DIR. Output dir is under logs/, already gitignored.
Extend log_llm_call with a 'tokens' field carrying
{prompt, completion, total} pulled from each provider's response:
xAI / DeepSeek / OpenAI-compat → resp.usage.{prompt,completion,total}_tokens
Gemini Vertex (google-genai) → resp.usage_metadata.{prompt,candidates,total}_token_count
Gemini REST (audio analyzer) → resp_json['usageMetadata'][...]
Three small helpers (extract_openai_tokens / extract_gemini_vertex_tokens /
extract_gemini_rest_tokens) keep the call sites tidy and use getattr
so test fakes without a usage attribute return None instead of crashing.
Lets the viewer aggregate per-seat / per-phase token spend without a
second pass over the raw logs.
New viewer/ project for inspecting one game's full event timeline + LLM
internals after the fact.
Stack: pnpm + Next.js 15 (App Router) + TypeScript strict + MUI 6.
Reads a single self-contained JSON (default sample-data/game-sample.json,
override via GAME_FILE env var). Server component loads the file, client
component manages drawer state.
UI surfaces:
- Game header (id, mode, victor, total LLM calls / tokens / latency)
- Seat grid (9 cards w/ role, persona, alive/dead, faction tint)
- Phase timeline (per phase: public logs, speeches, votes, night actions,
sorted chronologically) with bulb icon to open trace drawer
- Trace drawer (system + user prompt, raw response, tokens, latency,
metadata, error)
- Stats panel (per-seat and per-phase token / latency aggregates)
Sample data: viewer/sample-data/generate_sample.py emits a 2-day village
victory game-sample.json (9 seats, 8 phases, 36 trace lines) with
realistic prompt/response/token shapes so the viewer demos every surface
without any real LLM calls. README documents how to swap in real game
data once an exporter is built.
Split npc/ flat namespace (14 files) into functional subpackages:
* runtime/ main, client, config — bot process bootstrap +
WS client + settings.
* speech/ speech_service, generator_factory,
openai_compatible_generator, gemini_generator,
mock_generator — reactive speech generation.
* decision/ decision_service, game_state — vote/night-action
decision LLM + private state mirror.
* audio/ tts, playback — VOICEVOX synthesis +
discord.VoiceClient playback.
* personas.py stays at npc/ root (single-file persona constants).
12 files moved via 'git mv' (history preserved). 26 source/test
files updated to new import paths via the same Python sed-style
loop used for master/. The 'wolfbot-npc' console_scripts entry
point in pyproject.toml updated to point at
wolfbot.npc.runtime.main:main; the editable-install entry_points.txt
+ shebang script in .venv refreshed in-place so existing tmux-run
deployments don't need a 'uv sync'.
1238 tests + ruff + mypy all green — behavior unchanged.
The schema DDL was a 240-line Python list of triple-quoted CREATE
statements; reviewing it required scrolling past matching parens
and quoting noise. Move each table group to its own .sql so each
file reads as plain SQL with comment lines documenting its purpose.
Layout:
src/wolfbot/persistence/sql/
├── __init__.py — load_schema_script()
└── schema/
├── 01_games.sql
├── 02_seats.sql
├── 03_night_actions.sql
├── 04_votes.sql
├── 05_previous_guard.sql
├── 06_logs_public.sql
├── 07_logs_private.sql
├── 08_pending_decisions.sql
├── 09_persona_assignments.sql
├── 10_llm_speech_counts.sql
├── 11_speech_events.sql
├── 12_npc_speak_requests.sql
├── 13_npc_speak_results.sql
└── 14_npc_playback_events.sql
The numeric prefix encodes FK execution order — seats references
games, so games must run first, etc. load_schema_script()
concatenates files in lex order and returns one string suitable for
aiosqlite's executescript().
Why executescript, not Python-side split('): comment lines inside
01_games.sql contain ';' ("...enforcement; service code also")
and a naive split mangled the surrounding statement into a syntax
error. SQLite's own parser strips '--' / '/* */' comments before
splitting, so executescript handles this safely.
ALTER-TABLE migrations stay in Python (schema.migrate) because
SQLite has no 'ADD COLUMN IF NOT EXISTS' and the conditional needs
PRAGMA table_info() reads.
7 new tests in test_sql_loader.py pin the layout, lex order, cache
behavior, and the comment-with-semicolon regression. 1245 tests +
ruff + mypy all green.
…back
The exporter writes files as YYYY-MM-DD_HH-MM-SS.json (sortable by play
time) but viewer routes use game.id. loadGameById now scans the games
directory for a JSON whose game.id matches when the legacy {id}.json
fast path misses, so deep links keep working after the rename.
GamesTable extracts a small TableCell wrapper to share row click
behaviour and trims the per-row JSX duplication.
LLM seats restate "yesterday I divined X = white" across multiple discussion-round speech turns; the prior fold emitted one ledger row per utterance. Viewer's CO history panel showed the same claim twice and the per-day expected-count chip overshot. collect_claim_history now keys on (day, target_seat, is_wolf) per claimer and keeps only the first occurrence — different target or color on the same day are preserved (the validator already rejects those as swap/flip; the ledger keeps the contradiction visible for post-game review). Adds defensive in-browser dedupe to ClaimHistoryPanel so legacy exports render cleanly without a re-export, and aligns the panel's expected-seer formula (was day+1) with the canonical expected_seer_claim_count_for_day(N) == N rule.
plan_night0 and plan_night_resolve emit the next day's "夜が明けました" MORNING log and "N 日目の議論を開始" PHASE_CHANGE log via _public_log, which defaulted day=game.day_number — the OLD day at the moment the transition is computed. Result: day-N+1 morning rows landed in the viewer's day-N DAY_DISCUSSION bucket and the timeline read "DAY_DISCUSSION → DAY_VOTE → NIGHT → DAY_DISCUSSION (with started_at later than its own speeches)". Same off-by-one for the very first day's start-of-discussion log emitted from NIGHT_0 with day_number=0. _public_log / _private_log gain an optional day override; the two day-crossing transitions pass day=next_day. New regression tests assert MORNING.day == game.day+1 and the day-1-start PHASE_CHANGE day == 1 from NIGHT_0.
Decider unit tests in test_llm_service.py call decider.decide() without entering trace_context — the finally-block log_llm_call then writes to cwd-relative logs/llm_calls/no_game/gameplay.jsonl, i.e. the production trace dir when tests run from repo root. Over time this leaked 700+ stray actor=None / phase=None rows tagged with test model names (grok-x, deepseek-v4-flash, gemini-3-flash-preview) into the production trace, polluting the viewer's prompt drawer. Adds a session-scoped autouse fixture that pins WOLFBOT_LLM_TRACE_DIR to a per-session temp dir. Function-scoped trace_dir overrides still take precedence — pytest's monkeypatch undoes its setenv at teardown, falling back to this session-level sandbox value.
…imeline
In reactive_voice mode the vote, night-action, and wolf-chat decision
LLM calls run in the NPC bot process via decision_service. Their
trace lines were written as role="npc_decision" but lacked a
metadata.task discriminator, so the viewer's matchTraceForVote /
matchTraceForNightAction (looking for role=="gameplay") found nothing
and the lightbulb prompt buttons were invisible for every recent
game. NpcClient now wraps each decide_json call in
trace_context(metadata={"task": "vote"|"night_action"|"wolf_chat",
...}) carrying game_id / phase_id / actor; the viewer matchers
accept role=="gameplay" || role=="npc_decision" and use the task tag
to disambiguate.
Wolf-only night chat utterances were stored in logs_private with
visibility=PRIVATE and kind=WOLF_CHAT but the exporter only read
logs_public, so the viewer had no surface for the most informative
private signal a post-game review needs. Adds WolfChatLogEntry to
the export schema, fetches WOLF_CHAT private logs (deduping the
audience-fan-out so one row per utterance survives), attaches them
to the matching NIGHT phase, and renders them as a new
TimelineEvent with a red-tinted left border and the same trace
lightbulb wired to wolf_chat-task npc_decision rows.
Defensive rebucketing in _build_phases handles legacy games whose
MORNING / day-start logs were tagged with the prior day_number
(state_machine.py fix in a sibling commit fixes the source). Phase
ordering switches from epoch-second to ms keys with a tertiary
phase-order fallback so NIGHT (resolve) and DAY_DISCUSSION (next
day) still order deterministically when they share a second.
Master previously only voiced the MORNING announcement in reactive_voice mode and the chat carried nothing for the morning's kill / no-kill information; users had to listen back to know who died. _narrate_morning now also emits chat_text="☀️ <morning text>" so VC text chat carries the announcement, matching the rounds-mode formatting and keeping the existing concise voice line unchanged. Both modes now also post a "🌱 生存者 (N名): 席1 Alice、..." roster line after the morning announcement so players see the post-attack lineup at a glance. Voice intentionally stays survivor-list-free (per the user's "保ったまま" constraint) — the roster is a chat-only sidecar surfaced via DiscordBotAdapter.post_morning's new _post_survivors_list helper. Defensive: empty alive pool drops the roster line rather than emitting "0名".
Post-game review needs to see at a glance whether the speaker is wolf-side or village-side without cross-referencing the seat panel for every line. The discussion timeline now renders a role chip (村人 / 占い師 / 人狼 / 狂人 / 霊媒師 / 騎士) next to the speaker name on each speech row. Wolf and madman tint red (error / filled) so a fake CO line — e.g. a wolf seat carrying a "占い師 CO" chip followed by their day-2 divination claim — reads as a contrast without manual lookup. Reuses the existing roleJa / roleFaction helpers from format.ts so the labels and faction tinting stay in sync with the CO history panel.
Village power roles share a single grey "村陣営" tint across the seat
grid and chip surfaces, which makes a fake-CO comparison harder than
it has to be — readers need to memorise who holds which role rather
than spot it on the page. Each of seer / medium / knight now carries
its own hue (info blue / secondary purple / success green); wolf and
madman keep the existing red / orange split. Villager stays neutral.
Centralises the palette in lib/format.ts via two helpers:
- `roleChipStyle(role)` returns MUI Chip { color, variant } props,
used by PhaseSection (speech row), ClaimHistoryPanel (claimer
header), and SeatGrid (per-seat card chip).
- `roleTint(role)` returns rgba background + border for surface
tinting, used by SeatGrid's card so the page reads the same
colour family as the chip.
ClaimHistoryPanel drops its local ROLE_LABEL / roleLabel / isWolfSide
duplicates in favour of the shared roleJa + roleChipStyle helpers so
all three callsites stay in lockstep.
The 600-line `_ROLE_STRATEGIES` dict in prompt_builder.py glued each role's playbook into one Python triple-quoted string-concatenation literal — readable enough in code review but painful to edit (every bullet edit needs Python-string escaping, diffs span unrelated roles because of the dict layout, and copy-pasting between bullets risks the cross-leak rule that wolf-only `相方` / `襲撃先を揃える` can't appear in any other role's tips). Splits the strategies into six markdown files under `src/wolfbot/prompts/templates/strategy/<role>.md`. Each file opens with a `# 役職 (ROLE) 戦略` heading for human readers and lists the role's bullets verbatim from the previous inline dict so the existing cross-leak / unique-phrase substring tests keep passing byte-for-byte. The loader (`_load_role_strategy`) strips the heading + blank line so the LLM sees a clean bullet list, caches by role so disk is touched at most once per process, and is pinned in place by a new test that locks in the file-path mapping. Diff layout: each future strategy edit now lands as a focused ``strategy/<role>.md`` change instead of a 600-line ``prompt_builder.py`` rewrite.
The 113-bullet game_rules_9p.md and verbose strategy files were producing
~24KB system prompts (~17K input tokens) per NPC speech call — and a
significant portion of the size was a 30-term glossary of 人狼用語
(グレラン / 鉄板護衛 / グレスケ / 視点漏れ / SG etc.) that the speech LLM
is explicitly forbidden from using anyway, plus four worked examples of
CO 超過分計算 that restate the same rule.
Compression strategy:
* Drop the term glossary entirely — speech LLM can't use those words and
decision LLM gets the structural facts elsewhere.
* Collapse 4 worked examples (3-2-1 / 2-2-2 / 3-1-1 / 4-1-1) to one
line stating the rule.
* Compress 3-1 / 2-2 / 2-1 / 1-2 progression playbooks to one bullet each.
* Strategy files cut to 8–18 bullets per role (was 16–51).
* Drop _build_game_rules_block_legacy_inline (kept only as a parity
partner for the old template).
* Drop speech_profile from decision-side persona block (target_seat
selection doesn't need first-person / sentence style).
Measured on a fresh build_system_prompt for Jonas (werewolf):
24,458 chars → 7,983 chars (-67%)
18,990 chars (villager) → 7,348 chars (-61%)
game_rules_block: 14,427 → 3,636 chars (-75%)
werewolf strategy: 7,200 → 1,436 chars (-80%)
Per-game NPC prompt input tokens drop from ~600K to ~250K.
The rewrite preserves every safety invariant the runtime depends on
(NIGHT_0 random white, day-1 morning peaceful, attack-victim non-wolf
HARD fact, knight no-self / no-consecutive, candidate-token strict
match, public-claim immutability, CO 通算 vs 単独 distinction, sum-3
overflow → non-CO white-grade) — pinned by the rewritten test_llm_prompt_builder.py.
test_llm_prompt_builder.py shrinks from 2,780 lines / ~200 phrase-level
asserts to 600 lines / 88 tests focused on structural invariants and
role-isolation. Removed 34 phrase-level tests in test_llm_service.py
that asserted specific wording of removed content; structural coverage
remains via test_llm_prompt_builder.py and the surviving integration
tests for vote/runoff partner-block scoping.
All 1028 tests pass; ruff + mypy clean.
…g day The exporter's ``_rebucket_morning_logs`` was indiscriminately doing ``day += 1`` on every MORNING / "N 日目の議論を開始" PHASE_CHANGE row in logs_public. It was added as a defensive normalizer for legacy games written before the state_machine fix (cab2b92) that tagged those logs with the *prior* day_number. But the state_machine fix tagged new games' rows correctly — so the unconditional +1 corrupted the post-fix data: a day-1 PHASE_CHANGE "夜が明けました。1 日目の議論を開始" got bumped to day=2 and seeded a phantom (2, DAY_DISCUSSION) bucket starting before the actual day-1 discussion. The viewer's chronological sort then placed phantom day-N+1 buckets ahead of real day-N buckets, with starts decoupled from their own speeches. Replace it with ``_retag_morning_logs_from_text``: parse the day number embedded in the PHASE_CHANGE text ("2 日目の議論を開始") and use that as the source of truth. MORNING rows have no day in their text, so we look up the sibling PHASE_CHANGE within the same epoch second. This works for both eras: * post-fix game: row.day already matches the text → no-op * pre-fix game: row.day is N-1, text says N → patched to N Verified on game 7acbbd529fe2 (post-fix) and cbfc32400ee2 (pre-fix); both export with 13 phases in correct chronological order. Re-exported all 85 existing viewer/games/*.json files. Two new tests pin both eras down. Full suite stays at 1030 passing.
…t protection
Game ed7742ff08f8 day-1 vote: all 8 NPCs converged on the human player
(sakura, villager) for execution. Per-NPC trace analysis showed each
LLM was following its prompt logic — but the prompt design pushed them
toward the same wrong target:
* NPC vote/night/wolf-chat decision prompts injected persona +
role_strategy + state but NOT the shared game_rules block. So the
decider operated without the canonical 3-1 progression / single-CO
/ grey-seat heuristics that the speech LLM gets.
* villager.md said "守る情報役を投票で落とすな" (don't vote CO holders)
but had no symmetric rule like "灰位置の発言が拙くても狼確定ではない",
so the decider's only outlet for "棄権禁止 + 必ず候補から1人選ぶ"
was the noisiest grey seat — usually the human.
Two layered fixes:
1. Wire `_build_game_rules_block()` into `build_vote_prompt`,
`build_night_prompt`, `build_wolf_chat_prompt`. Adds ~3.6KB system
context per call (acceptable: night/vote prompts were ~70 chars
before, ~3.7KB after, much smaller than speech's ~8KB).
2. Add a `灰位置 (非 CO) の評価` section to game_rules_9p.md:
* The発言の質 (aggressive / short / repetitive / 拙い) alone is
not a wolf indicator. Trust read != log evidence.
* Strong grounds to lynch a grey seat require concrete public-log
contradictions (statement-vs-CO mismatch, attack-victim-as-wolf
claims, vote/attack pattern integrity).
* "他者が疑っている" "扇動的に見える" 印象論 alone is bandwagon
bait — wolves often seed it.
* In 3-1 / 2-2 / 1-2 boards, prioritize CO-roller over grey lynch.
3. Update decision_vote_user.md to encode a 4-step decision procedure
(formation → grey filter → no-bandwagon → relative ranking).
Replay test against the same game state with the new templates:
BEFORE: 8/8 NPCs voted seat 1 (sakura) — village loss
AFTER: 5/8 → seat 1, 2/8 → seat 5 (Jina, the actual madman),
1/8 → seat 2 (Jonas, an actual wolf)
Three NPCs (Jonas/wolf, Raqio/knight, Jina/madman) flipped onto
correct or partially-correct targets. Five seats still fell back on
sakura — confirming the prompt nudge is partially effective but the
"grey seat with weak args" attractor remains. Further tightening will
need follow-up iteration.
All 1030 tests pass; ruff + mypy clean.
…est, real-seer counter-CO priority
Iteration 2 on the bandwagon-against-human bug. The first pass cut
from 8/8 to 5/3 votes against the human; this pass adds three more
levers and re-tests against the same scenario.
(1) Hard rule on grey-seat lynching in game_rules_9p.md:
- The vote `reason` field MUST cite a concrete public-log
contradiction when targeting a grey seat. "発言の質"
(aggressive / repetitive / 拙い / 扇動的) is explicitly listed
as NOT a valid contradiction.
- "他者も疑っている" / "議論の流れがその席に向かっている" is
explicitly forbidden as a stand-alone reason — labeled the
typical wolf-induced lynch trail.
- Added a 4-step decision procedure: (i) detect formation,
(ii) on 3-1/2-2/2-1/1-2 prioritize CO group, (iii) only fall
to grey if a concrete contradiction is citable, (iv) if no
grey has one, default back to most-fake-leaning CO claim.
(2) Removed the `## 名指しされた回数 (多い順)` aggregate from
public_digest.py output. The vote/night decision LLM was reading
the per-seat addressed-count as "this seat is suspicious" and
bandwagon-piling onto whichever seat had drawn the most callouts
— typically the human player, who naturally accumulates them by
arguing. The same information stays available in the raw recent-
speeches block, where the LLM has to reconstruct intent rather
than treat a count as a suspicion ranking.
(3) seer.md: real seer must vote a counter-CO seer over a grey seat.
The real seer holds a unique view of the divine-result history;
spending a vote on a grey wastes that information. day-1/day-2
real-seer vote should target the most-fake-leaning opposing seer
CO to set up roller integrity.
Replay test (game ed7742ff08f8 day-1, same context):
BEFORE iter1 iter2-current
8 5 5 seat 1 (sakura, the human villager)
0 0 2 seat 2 (Jonas, an actual wolf)
0 2 1 seat 5 (Jina, the actual madman)
Notable shift in iter2: Shigemichi (the **real seer**) flipped onto
Jonas — the strongest single-vote improvement, since the real seer
voting an opposing seer-CO is the textbook 3-1 progression. Five
NPCs still default to sakura, citing "ローラー定石を無視した扇動 =
利敵". This is a creative re-interpretation of the rules that the
LLM applies to override the "発言の質 != ログ矛盾" guideline. Further
tightening would need to spell out specifically that "ローラーを
主張する" / "誰かを名指しする" / "処刑提案を出す" do not constitute
矛盾, but at this point we're approaching diminishing returns
without on-policy retraining.
All 1030 tests pass; ruff + mypy clean.
…ation line
Iteration 3 on the bandwagon-against-human bug. Two more concept
additions to game_rules_9p.md, both motivated by user-pointed gaps
the previous iterations missed.
(1) Mathematical roller-opposition rule:
- 9-player fixed配役 means 占CO 4 (= 真1+狼2+狂1) or 超過分合計 ≥ 3
mathematically forces the wolf-faction 3 into occult-CO group.
- In those boards "ローラー以外の進行" (灰吊り、決め打ち、
"性急すぎる"、"慎重に精査"、進行遅延) has zero villager
rationality. Anyone proposing or supporting it is a strong
wolf-faction candidate.
- In 3-1 (超過分=2) the rule is weaker but still applies: the
ローラー 反対表明 from a CO holder is itself a strong偽 indicator
because 1〜2/3 of seer COs are mathematically fake.
- Roller attitude > grey-seat tone reading as a vote signal.
(2) CO mutual-attestation line:
- 占CO X says "Y は白" + Y is the medium = X-Y mutual line is
a strong real-seer / real-medium pair candidate.
- Anyone targeting this line for roller-first is performing
真占い焼き = wolf move.
Replay test (game ed7742ff08f8 day-1, same context):
BEFORE iter1 iter2 iter3-NEW
8/0/0 5/2/1 5/2/1 **3/3/1/1**
iter3 distribution: seat 1 (sakura) = 3, **seat 2 (Jonas, real wolf)
= 3**, seat 7 (Shigemichi, real seer) = 1 (Jonas's wolf-side vote),
seat 5 (Jina, real madman) = 1 (Stella's wolf身内切り).
Most importantly, the village's information-holders converged on the
correct target:
* **Shigemichi (real seer)** → Jonas (real wolf) ✅
* **SQ (real medium)** → Jonas (real wolf) ✅
* **Setsu (villager)** → Jonas (real wolf) ✅
All three cited Jonas's roller-opposition behavior ("性急すぎる /
慎重に精査すべき") as the wolf indicator — exactly the math-driven
read the new rule encodes. The vote ties on Jonas vs sakura instead
of locking on sakura, opening a real path to a runoff that catches
the wolf instead of executing the human villager.
All 1030 tests pass.
…ty; vote-pattern rules
Game c99ecf313f96 surfaced three structural failure modes the
arbiter / prompts couldn't handle:
(1) Day 1: 4 grey seats (4 villager/knight) silent the entire
phase. 4 occult-CO holders + Jonas's white-target Jina
monopolised all 10 turns by addressing each other.
(2) Day 2: Rakio (狂人 occult-CO seer-騙り) never spoke at all,
so his fake night-2 divine never landed and his fake-CO
stayed exposed.
(3) Days 2-4: Jina (wolf) voted for Yuriko (the lone medium CO).
No one called out the lone-CO vote — a textbook wolf
information-role-削除 move — even though past_votes was
already in every NPC's digest.
Three layered fixes:
* Speak arbiter priority reorder. Old order had `addressed` above
`speech_count`, so CO holders mentioning each other kept
re-prioritising themselves while greys at count=0 stayed
blocked. New order:
pool > demoted > **pending_role_result** > count >
**addressed** (now tiebreak) > lru > random
The reorder applies to all days, not just day 1.
* Pending-result priority bucket. Computed from claim_history +
execution-day count: a seer-CO seat with seer_claims length <
expected_seer_claim_count_for_day(day) is pending; a medium-CO
seat with medium_claims length < executions_so_far is pending.
Anyone in this bucket is promoted to the top of the rotation
(just below the existing role-callout pool) so the floor is
held until they publish. Day-2 silent占いCO ラキオ would
have spoken in this game.
* game_rules_9p.md vote-pattern section. Specific patterns to
surface in day-N+1 discussions:
- Voting against a single CO (lone seer / lone medium) is a
strong wolf indicator; cite the offending vote in `reason`.
- Two seats voting the same single-CO target are a 2-wolf
candidate set.
- Failure to call these out is village information loss.
Two new tests:
* test_try_dispatch_next_speech_count_beats_addressed —
silent grey wins over addressed seat that already spoke.
* test_try_dispatch_next_pending_seer_result_beats_addressed —
seer CO with unpublished day-2 result wins over addressed seat.
All 1032 tests pass; ruff + mypy clean.
… TEXT_PIPELINE axes Replaces the model-coupled VOICE_STT_PROVIDER / VOICE_LLM_* / GROQ_STT_* switches with two orthogonal pipeline-shape selectors plus three component-scoped credential blocks (AudioAnalyzer / Stt / TextAnalyzer). Decoupling the text path from the voice path's credential pool fixes the gemini_http_429 bug where typed messages silently piggy-backed on the throttled voice key. New ingest_factory centralises construction; boot validators only require credentials that the chosen pipeline actually instantiates.
…ace fields Games list: - Add 人間 / LLM seat-count columns derived from seats[].is_llm. - Per-column sort via TableSortLabel (numeric defaults to desc, text to asc; nulls in duration_ms pinned to bottom regardless of dir). - Pagination (10/25/50/100) and Game ID partial-match search box; page resets on filter/sort change to avoid empty pages. Game detail: - Strip system_prompt / user_prompt / response from the SSR payload via slimTrace() in loadGameWithMatches and lazy-fetch them on TraceDrawer open through a new /api/games/[gameId]/trace/[index] route handler. - Extract trace ↔ event matchers from PhaseSection into lib/match.ts so the server can precompute the (eventKey → traceIndex) lookup table once and ship it down; PhaseSection becomes O(events) dictionary lookups instead of an O(events × trace) scan. - Sample page keeps inline rendering (matches=null, fetcher=null) — bundled JSON is small and the demo should not require an API hop.
Drop the per-column "natural initial direction" map — every header click now just flips between desc and asc. First click on a fresh column starts at desc (newest/biggest first); subsequent clicks toggle. Inactive columns preview the desc indicator so the affordance still hints at where the first click takes you.
…潜伏戦略
直近のゲームで唯一の霊媒師 CO が決選投票で吊られた事案を受けて、9-player
ルールセットの3点を強化:
- shared/game_rules_9p.md
- 「HARD: day 1 単独 CO 役職の処刑禁止」を CO 推理セクションに追加。day 1 朝で対抗
なし通算1件の能力役職 CO は target_seat に絶対選ばないという構造的禁止条項で、
「灰位置の評価」「進行軸の優先」より上位の制約として配置
- 2-1 / 1-2 進行軸の項目に「灰吊りも並列定石」を明示。day 1 段階で占いローラー
強行は真占いを 50% で失う賭けであり、灰吊り先行が 9 人村の多数派定石である
ことを明文化
- 「ローラー反対 = 狼指標」セクションの適用条件を「対抗超過分合計 ≥ 3」に限定。
2-1 / 1-2 / 2-2 では灰吊り提案 / CO 切り反対は狼指標として扱わない
- npc/decision_vote_user.md
- 判断手順に step 0 (HARD 除外チェック) を先頭追加
- step 1 で 3-1/2-2 (ローラー強制) と 2-1/1-2 (灰吊り並列) を区別
- strategy/knight.md
- 「立ち回り (役職メタ最優先)」セクションを冒頭新設。day 1 潜伏優先・単独情報役の
擁護・CO 切り煽動の禁止・性格指針の役職上書きを明文化
実験 (Gemini gemini-3.1-flash-lite-preview, thinking_level=low) で 16 クエリ再走、
sakura への投票は元実装の 7 件から 0 件に。決選投票そのものが発生しない結果へ
シフト (Jonas 5 票で即処刑)。テスト 115 件パス。
…upe + delay flag flip to playback-finish 決選投票演説フェイズ DAY_RUNOFF_SPEECH の人間候補対応漏れと演出バグ3点を修正。 バグ1 (致命): 人間候補に turn 案内が出ない `_dispatch_runoff_next` が `not seat.is_llm` で人間席を eligible から除外し、 `_runoff_announce` (Levi の「続いて X 様の最終演説でございます」) は LLM 席のみに 発火していた。tied=(human, LLM) の構成だと人間は決選投票演説の番が来ず、LLM が 喋り終わった瞬間 phase 進行する設計。 バグ2 (致命): 人間候補は phase 進行待ちにカウントされない `game_service._plan_next` が `tied_llm_seats` で人間席を弾いており、LLM 全員が `runoff_speech_done=True` になった瞬間 `speeches_done=True` で即 DAY_RUNOFF へ遷移。人間の SpeechEvent を待たない。 バグ3: NPC が VC で喋り終わる前に決選投票が始まる `runoff_speech_done` が `accept_speak_result` (= NPC がテキスト生成完了を Master に報告) で flip されていた。VC 上の音声再生 (`PlaybackFinished`) は さらにあとで届くため、聴衆は最終演説の途中で投票画面が出る状態になっていた。 バグ4: 「決選投票の最初の候補者の案内が2連続で読み上げられてからNPCが喋る」 既存の `_pending` 再エントランシー guard は `dispatch_request` 完了後に追加 される `_pending` エントリしか見ておらず、`_runoff_announce` (Levi TTS, 数秒) の await 中はノーガード。`_master_narrate` 経由の PHASE_CHANGE kick と `_on_reactive_phase_enter` 経由の kick が並行で `try_dispatch_next` を発火 すると、両方が同じ chosen 席に対して `_runoff_announce` を await し、Levi が 同じ案内を2回読み上げる挙動になっていた。 修正: - `speak_arbiter._dispatch_runoff_next` の `is_llm` フィルタを撤廃。人間候補 も eligible に含め、announce → 人間 grace watchdog (runoff_speech_grace 秒) を spawn する経路を新設 (`_spawn_human_runoff_watchdog`) - 人間が voice / text で喋った場合、`discussion.record()` が SpeechEvent を speech_events に追加する。`_dispatch_runoff_next` 冒頭でこのテーブルを scan し、tied 人間席で SpeechEvent が観測されたら `runoff_speech_done` を auto-mark - `game_service._plan_next` で `tied_llm_seats` → `tied_seats` に拡張。LLM / 人間を区別せず全 tied 候補の `runoff_speech_done` が立つまで待つ - `_mark_runoff_done_if_phase` 呼び出しを `accept_speak_result` から `handle_playback_finished` / `handle_playback_failed` / `handle_tts_failed` へ移設。聴衆が VC で最終演説を聴き終えてから投票が開始する - 新たに `_runoff_in_flight: dict[(game_id, day), set[seat_no]]` を追加。 `_runoff_announce` await の前に挿入、try/finally で discard する。並行 kick でも案内は1回のみに テスト追加: - `test_runoff_dispatch_concurrent_kicks_announce_once`: バグ4 の回帰防止。 asyncio.Event で _intro を block し並行 kick を実演、intros == [1] を assert - `test_runoff_dispatch_announces_human_candidate`: 人間候補に Levi 案内が 発火、NPC SpeakRequest は出ない、`runoff_speech_done` は False のまま - `test_runoff_human_speech_event_auto_marks_done`: 人間の SpeechEvent が record 済みの状態で `try_dispatch_next` を呼び、auto-mark で flag が True になり次の eligible (LLM) に announce が回ることを確認 既存テスト `test_runoff_dispatch_picks_tied_llm_candidates_in_order` も バグ3 修正に合わせ更新: accept_speak_result 直後は False、 handle_playback_finished で True に切り替わる順序を assert。 1056 tests pass / mypy strict pass / ruff pass.
直近のゲーム (6c97c1cc22ef) で、Day 2 でステラ (確定黒) を吊る流れの中、 ジナだけが単独占い SQ に票を入れた異端票が観測された。これは `game_rules_9p.md` の「単独情報役切り票=強い狼指標 / 発言で指摘必須」 ルール直撃の典型例だが、Day 3 朝の議論で誰一人この票筋に言及しなかった。 全 7 発話とも一般論止まり (「残る残党を追い詰めよう」等)。 原因: speech LLM が判断 (vote `reason`) では Day 2 異端票を正しく引用 (Jonas/Setsu/Shigemichi いずれも「day2 で SQ に投票したジナの動き= 人狼による情報役排除」を明示) しているのに、公開発話では一切 verbalize していない。`task_daytime_speech.md` のメタ用語禁止リスト (「票筋」 「ライン」「身内切り」等を発話で使うな) と `game_rules_9p.md` の 「発言で指摘必須」ルールが衝突しており、speech LLM は語彙を封じられた 結果として話題自体を回避してしまう。 修正: - `game_rules_9p.md` 「票筋から狼を炙り出す」セクションに「メタ用語禁止下 での自然な言い換え例」を 5 件追加。「ジナの票筋がライン濃厚」(❌) → 「昨日の投票、ステラを処刑する流れの中でジナだけ SQ に入れていた」(✅) のような自然な日本語パターンを明示 - 同セクションに「day N+1 朝の 1 巡目発話では前日異端票を必ず取り上げる」 ルールを追加。情報役・確白寄り・潜伏村人いずれの立場でも村陣営として 共有すべき情報と明文化 - `task_daytime_speech.md` の冒頭に day ≥ 2 朝の発話で `公開された投票 履歴` を確認し前日異端票を自然な日本語で引用する義務を追加 Day 3 朝の speech LLM クエリ再実行 (Gemini 3.1 flash-lite, thinking=low, 3 runs/persona) で挙動確認: | persona | orig | new (cites/runs) | |-------------|---------|------------------| | Jonas | silent | 3/3 | | Setsu | silent | 0/3 | | Shigemichi | silent | 2/3 | | Yuriko | silent | 1/3 | | Comet (狂人) | silent | 0/3 | | Gina (狼) | silent | 0/3 (1回弁明) | 村陣営側 6/9 (67%) の発話で Day 2 異端票が引用されるようになった (元 0%)。 副次効果として Gina (狼自身) が自発的に「混乱していた」と弁明するケース も発生し、wolf 側に反応を強要するスパイラルに入る。 副次的に確認: persona の "攻撃性" 軸が低い passive 系 (Setsu / Comet) は依然として一般論止まり。完全な改善には role 戦略側の補強が必要だが、 本コミットでは共通ルール側の改善に絞る。 テスト 105 件パス。
直近のゲーム (6c97c1cc22ef) で、Day 2 にステラが SQ (単独・真寄り占い CO) から 黒判定を受け、村の 6/8 が ステラ吊りに向かっている状況で、相方のジナだけが SQ に票を入れた。これは典型的な「相方を庇う狼の票逸らし=単独情報役切り」 パターンで、票筋として翌日以降に狼ラインを露呈する最悪ムーブ。 ジナの当該 vote reason は「SQ is a suspicious seer who is asserting a black judgment against a villager without sufficient confirmation」と 村目線フレームで構築されており、wolf 側のメタ判断 (= 自分が今この候補に 票を入れたら透ける) が一切働いていなかった。`werewolf.md` の既存ルール 「相方処刑濃厚で票逸らしすると庇い狼として透ける」は表現が抽象的で、 具体場面 (確定黒判定後の投票) に紐づいて発火していなかった。 修正: `strategy/werewolf.md` の投票項目に以下を追加。 1. **HARD: 相方が確定黒判定された後は身内切り**: 確定黒判定の定義 ((a) 真寄り単独占いの黒判定、(b) 霊媒結果が「人狼」) を明示。この状況での 別候補への票は「票筋として最も狼ラインを濃くする最悪ムーブ」と明記。 2. **単独情報役への票逸らし禁止**: 相方処刑濃厚で単独占い・単独霊媒に票 を入れる行為を共通ルール「単独情報役切り票=強い狼指標」と紐づけて明示 的に禁止。 3. **投票前のチェックリスト**: 4 ステップ (公開投票履歴で過半数確認 → 相方の確定黒判定確認 → それ以外への票は全て票逸らしと解釈される → 感情的抵抗があっても潜伏優先で透けないことが最終勝利条件) を明文化。 ジナの Day 2 投票クエリを再実行 (Gemini 3.1 flash-lite, thinking=low, 5 runs) で挙動確認: | run | original | new | |-----|----------|-----| | 1 | 席7 SQ | 席8 ステラ (身内切り) | | 2 | - | 席8 ステラ | | 3 | - | 席8 ステラ | | 4 | - | 席8 ステラ | | 5 | - | 席8 ステラ | 5/5 全試行で身内切り (相方処刑への協調票) を選択。元の単独占い切り ムーブ (席7 SQ) は完全消滅。reason フィールドに新プロンプト由来の 語彙 (「身内切り」「確定黒判定」「狼ラインを透かさない」) が直接反映 され、チェックリストの判断手順がそのまま使われている。 副次効果として、村側 day N+1 朝の vote-line 引用ルール (1 つ前のコミット) と組み合わせると、wolf も身内切り選択が強制されるため、village 側の 票筋分析自体が成立しにくくなる (wolf は consensus に乗るので異端票が 出ない) → 村陣営は別の手がかり (発言の質・噛み筋・襲撃結果) に依存 する必要がある。これは正しい設計で、wolf 側の上達 (透けない動き) と village 側の上達 (異端票指摘) は対戦相手としてバランスを取る。 105 tests pass.
ユーザー要望「怪しいと指摘した履歴を構造化して残し、後から捏造しないよう にその根拠と更新理由を残し、議論時に誰が誰をどれくらい怪しんだか可視化 したい」を、speech 系列で実装。Vote 系列は後続コミットで追加する。 データモデル: - `domain/suspicion.py`: `Suspicion` (frozen Pydantic, target_seat / level / reason / update_from_level / update_reason) + `SuspicionLevel` 4-step enum (trust / low / medium / high) - `sql/schema/15_speech_suspicions.sql`: `speech_suspicions` テーブル ((event_id, seq) 複合 PK、game_id / day / phase / suspecter_seat / target_seat / level / reason / update_from_level / update_reason / created_at_ms 列、game_id+day と (suspecter,target) の 2 索引) - `persistence/sqlite_repo.py`: - `insert_speech_suspicions` (バッチ INSERT OR IGNORE) - `load_speech_suspicions_for_game` (時系列 dict 列) LLM 出力スキーマ: - `domain/ws_messages.py::SpeakResult` に `suspicions: tuple[Suspicion, ...]` を追加 (NPC bot → Master の reactive_voice 経路) - `domain/ws_messages.py::LogicPacket` に `past_suspicions` を追加 (Master → NPC、不変記録のフラットタプル) - `services/llm_service.py::LLMAction` に同フィールド (rounds-mode gameplay LLM 経路) - `npc/speech/openai_compatible_generator.py::_RESPONSE_SCHEMA` に `suspicions` 配列を追加 (json_schema strict 対応)。各エントリは target_seat / level / reason / update_from_level / update_reason の 必須 5 項目。`level` は enum 制約 永続化: - `master/arbiter/speak_arbiter.py::accept_speak_result` で `discussion.record(speech_event)` 直後に `repo.insert_speech_suspicions` を呼び、自席ターゲットを除外して バッチ書き込み - `services/llm_service.py::_emit_npc_speech_event` で同等の処理を rounds-mode 経路に追加 レンダリング (subsequent prompt への反映): - `master/arbiter/speak_arbiter.py::_load_past_suspicions` を新設、 `dispatch_request` から `build_logic_packet` に `past_suspicions` を 渡す - `master/arbiter/logic_service.py::build_logic_packet` シグネチャ拡張 - `npc/speech/openai_compatible_generator.py::_build_user` に `## 公開された疑い履歴 (古い順、不変記録)` ブロックを追加。 `level` を「信頼/弱疑/疑/強疑」と日本語ラベル化、update_from_level が立っている行は `[弱疑→強疑 更新理由: ...]` で末尾に注釈 プロンプト規則 (anti-fabrication): - `npc/speech_system.md` 出力ルールに「`suspicions` フィールドの記入 義務 + 不変性ルール」セクションを追加。記入必須項目、過去宣言を 黙って書き換える禁止、text と suspicions の整合性、を明記 - `master/task_daytime_speech.md` に同等のルールを追加 (rounds-mode) テスト: - `tests/test_llm_structured_output.py` の `required` フィールド assertion を `suspicions` を含む新しい集合に更新 - 既存テスト 1056 件すべてパス、mypy strict (100 files) パス、ruff パス ユーザーが追加で要望した「投票時の思考でも suspicion を更新するか」 への対応: 現状、Suspicion の発生源は **speech のみ**。vote LLM の target_seat は実質的に高 suspicion と等価だが、本コミットでは別経路 の追加を別コミットに分離した。後続コミットで vote LLM の出力に suspicions 配列を加え、event_id NULL を許容する schema 微調整と共に vote-derived suspicion を timeline に統合する。
…oid high-suspect
ユーザー要望「投票時の思考でも suspicion を更新」「占い師の占い先も suspicion
を反映」「人狼の襲撃先は最も疑われていそうな人を避ける」を実装。前コミット
で speech 系列を整備したのに続き、vote / seer divine / wolf attack の3経路
を suspicion timeline に統合する。
データモデル:
- `domain/ws_messages.py::VoteDecision` に `suspicions` を追加
- `domain/ws_messages.py::NightActionDecision` に `suspicions` を追加 (主に
seer_divine 用、wolf_attack/knight_guard はオプショナル)
スキーマ:
- `sql/schema/15_speech_suspicions.sql` を `15_suspicions.sql` に rename。
surrogate PK (id AUTOINCREMENT) + source 列 ('speech'|'vote') + vote_round
列に再設計。event_id を nullable にし vote-derived 行が SpeechEvent を
持たないケースを許容。speech 行は (event_id, seq) の partial UNIQUE で
一意性確保、vote 行は (game_id, day, vote_round, suspecter_seat,
target_seat) の partial UNIQUE で重複防止
- `persistence/sqlite_repo.py`:
- `insert_speech_suspicions` を新スキーマに合わせ更新 (source='speech')
- `insert_vote_suspicions` を新規追加 (source='vote', event_id=NULL,
vote_round 必須)
- `load_suspicions_for_game` に統合 (rename from `load_speech_suspicions_
for_game`、speech+vote の双方を時系列で返す)
NPC bot 側:
- `npc/decision/decision_service.py::_VOTE_SCHEMA` に suspicions 配列を追加
(target/level/reason/update_from_level/update_reason)
- `_NIGHT_SCHEMA` に同様の suspicions 配列を追加。seer_divine では target
を medium 以上で必ず含めるよう description で明示
- `DecisionResult` dataclass に suspicions tuple を追加、`parse_decision`
が新フィールドを抽出
- `npc/runtime/client.py` の `_decide_vote_target` / `_decide_night_target`
を 3-tuple `(target, reason, suspicions)` 返却に変更、`VoteDecision` /
`NightActionDecision` 構築時に転送
Master 側 (反映 + 永続):
- `master/ws/decision_dispatcher.py::NpcDecisionDispatcher` に repo
パラメータを追加
- `_PendingDecision` に vote_day / vote_round / vote_phase を追加して
WS round-trip 終了時に context を取り戻せるよう拡張
- `on_vote_decision` で `_persist_vote_suspicions` を呼び、target_seat が
explicit suspicions に無ければ level=high で auto-promote (vote target
IS the strongest lynch suspicion by definition)
- `on_night_action_decision` で action_kind='seer_divine' のときのみ
`_persist_seer_suspicions` を呼び、target_seat が explicit に無ければ
level=medium で auto-promote (divine target は確認したい seat = 中程度
の疑い)。wolf_attack / knight_guard は意図的にスキップ
- `main.py::NpcDecisionDispatcher(repo=repo)` 渡し
レンダリング:
- `master/arbiter/public_digest.py::build_public_digest` に
`past_suspicions` 引数を追加。`## 公開された疑い履歴 (古い順、不変記録)`
ブロックを描画 (NPC speech 経路と同じ書式)
- `services/llm_service.py::_build_public_digest` で
`_load_past_suspicions` を呼び `build_public_digest` に渡す。これで
vote / night action の意思決定 LLM も同じ timeline を見られる
- `services/llm_service.py::_load_past_suspicions` を新規追加 (mirror of
`SpeakArbiter._load_past_suspicions`)
戦略強化:
- `strategy/werewolf.md` に「HARD: 強疑(high) を集めている位置は襲撃しない」
ルールを追加。複数の村人候補から high を向けられている席は明日吊られる
確率が高いので、襲撃すると「翌日の縄を 1 本無駄にしない代わりに今夜の
襲撃 1 回を捨てる」二重損失。trust 寄り or 疑い未表明の灰を優先することで
村陣営の確白根拠を削る方向に誘導
破壊的変更なし: speech 系列の挙動は前コミットから変えていない。schema
変更は新ファイル名で、旧テーブル `speech_suspicions` は idempotent
migrate 上で残存するが未使用 (運用 DB 側で手動 DROP しても良いが、
新規 game では `suspicions` テーブルだけ書き込まれる)。
1056 tests pass / mypy strict (100 files) / ruff clean。
前コミットで suspicions テーブル + 構造化記録パイプラインを実装した。
本コミットはそれを viewer 側で可視化する:
データ層:
- `services/game_export_types.py`: `SuspicionEntry` Pydantic モデル追加。
source ('speech' | 'vote') / event_id (nullable) / seq / day / phase /
vote_round (nullable) / suspecter_seat / target_seat / level (4 段階) /
reason / update_from_level / update_reason / created_at_ms。
`GameExport` に `suspicions: list[SuspicionEntry]` フィールド追加
- `services/game_export.py`: `_build_payload` で `suspicions` テーブルを
時系列で読み出し、`_build_suspicion` で各行を SuspicionEntry に投影
- `viewer/sample-data/export-schema.json`: 自動生成スクリプトで再出力
viewer:
- `viewer/src/lib/types.ts`: `SuspicionLevel` ('trust'|'low'|'medium'|
'high') と `SuspicionEntry` interface 追加。`GameSample.suspicions?:
SuspicionEntry[]` を optional で追加 (古いエクスポートとの互換性)
- `viewer/src/components/SuspicionPanel.tsx` 新規作成:
- **マトリクスタブ** (デフォルト): (suspecter × target) の最新評価ピボット。
各セルは 4 段階のヒートマップ (信頼=緑 / 弱疑=黄 / 疑=橙 / 強疑=赤)。
ホバーで day と理由をツールチップ表示。自席対角線は `—`、未評価は `·`
- **タイムラインタブ**: 全 suspicion 記録を時系列で表示。発言/投票の source
chip、day + phase chip、suspecter → target、level chip、`update_from_level`
が立っている行は `[弱疑→強疑]` 矢印 chip で更新を強調表示。reason と
update_reason を本文として描画
- `viewer/src/components/GameView.tsx`: 右ペインの ClaimHistoryPanel
直下に SuspicionPanel を配置
- `viewer/sample-data/game-sample.json`: サンプル suspicion 6 件を追加
(speech 4 件 + vote 2 件、update_from_level 連鎖を含む) — `/sample`
ページでパネルのデモ表示が確認できる
検証:
- `npm run build`: OK (新ルートの増分なし、bundle size 影響なし)
- `npm run lint`: OK
- `npm run test`: 4 passed (1 pre-existing failure: export-contract.test
は本コミットと無関係の filename mismatch、別 PR で別途対応)
- `/sample` を curl で取得し「疑い履歴 / 疑う側 / セツ・ジョナス・ラキオ /
信頼・疑・強疑」のレンダリングを確認
Python 側も全パス: 1056 tests / mypy strict (100 files) / ruff clean。
…icion aggregate
ユーザー要望3点を実装:
1) 「2日目から霊媒師の話題が一切でないことがある。2日目以降の最初の話者として
真の霊媒師とまだcoしていない人狼サイドを含めてランダムに発言させたい」
2) 「占い師の時も、最初に占い師coが発生した時点で人狼サイドはcoするかスキップ
するかの二択で特別なプロンプトを人狼サイド側に渡したい」
3) 「人狼の襲撃先を決める時に現在の全てのプレイヤーの疑い先の合計値を渡して
そこから最も疑われている人を避けて襲撃先をえらぶように修正したい」
実装:
(1) 自動 medium 召喚 — `speak_arbiter._compute_auto_callouts`:
- day ≥ 2 + DAY_DISCUSSION + medium CO 未存在の条件で `frozenset({"medium"})`
を返す
- `dispatch_request` で state を `model_copy(update={pending_role_callouts:
...})` で augment し、既存の `_compute_callout_pool` 機構が真霊媒+wolf-side
を pool に組む経路に乗せる
- pool 内で各候補は `_callout_pool_asked` で 1 回だけ dispatch される。
CO すれば `state.co_claims` 更新 → 次回の auto_callouts は空集合 → 自動収束。
全員が skip した場合は pool 枯渇で通常 rotation に戻る
(2) wolf-side 対抗CO 判断ブロック — `logic_service.build_logic_packet`:
- 新引数 `recipient_role: Role | None`
- 受信者が WEREWOLF / MADMAN かつ占い/霊媒/騎士のいずれにも CO していない、
かつ `pending_role_callouts` / `pending_co_response` のいずれかに seer /
medium / knight が含まれているとき、`public_state_summary` に
「## 【あなた宛 / 緊急判断】対抗 CO のチャンス」ブロックを追加
- 判断軸 (真寄り単独 CO の処理価値・縄数・既出 CO 件数・破綻リスク) と、
選んだ役職別の必須フィールド (`co_declaration` + `claimed_seer_result` /
`claimed_medium_result` / 護衛履歴) を明示。skip した場合の意味も注釈
- (1) の自動 medium 召喚と組み合わさることで、day 2+ 朝に CO してない狼/狂人
全員にこのブロックが届く。占い CO の counter-CO 機会も同様に発火
(3) 疑いスコア集計 — `public_digest.build_public_digest`:
- 既存の `## 公開された疑い履歴` ブロックの直下に `## 疑いスコア集計
(生存者のみ、score 高 → 低)` を追加
- 集計ロジック: 各 (suspecter, target) ペアの **最新** level のみカウント。
生存者→生存者の評価のみ採用 (死亡 suspecter / dead target は除外)。
weight: trust=-1 / low=+1 / medium=+2 / high=+4。スコア降順で表示
- 各行は `席名: 強疑Nx / 疑Mx / 弱疑Lx / 信頼Tx → 合計N点` の形式
- werewolf.md 戦略を更新: 「score 最高位は襲撃禁止 (明日吊られるので二重損失)、
score 低位 (trust 寄り) を優先襲撃して村陣営の確白根拠を削る」を明記
テスト追加:
- `test_logic_packet_wolf_decision_block_fires_for_active_callout`: 狼が
active callout で対抗CO 判断ブロックを受け取ることを確認
- `test_logic_packet_wolf_decision_block_skipped_when_already_co`: 既に
CO 済みの狼には再表示しない
- `test_logic_packet_wolf_decision_block_skipped_for_villager`: 村人陣営
には絶対にこのブロックを表示しない
1059 tests pass / mypy strict (100 files) / ruff clean。
直近のゲーム c8435a0af2d3 で真占いの sakura が 8/8 で初日処刑された原因を
分析した結果、村側 LLM が共通ルール「ローラー反対=狼指標」を一方向にしか
適用しておらず、ローラー擁護者を「急ぎすぎ=狼」として攻撃対象にする逆向き
適用が連鎖していた。さらに day 1 朝の最初の発話が狼陣営の場合、後続 LLM が
そのフレーミング (「丁寧に議論」「もう少し聞こう」) を継承して全員 wolf-side
に流れる first-mover bias が発生していた。
具体的失敗連鎖 (game c8435a0af2d3 day 1):
1. セツ (狼) 第1発話: 「議論の整理のために... まずはここから始めましょう」
(直接の sakura 攻撃なし、しかし「ゆっくり聞く」ムードを敷いた)
2. ラキオ (狂人) 第3発話: sakura に level=low 弱攻撃
3. sakura が「占い師3人から吊るべき」(=正しい 3-1 ローラー支持) を表明
4. シゲミチ (騎士・村) 第6発話で sakura medium 疑い:
「急いで白黒つけようとするのは何か隠したい」
5. コメット・ジナ (真霊媒!) が雪崩で sakura に高疑い積み上げ
6. 疑いスコア集計で sakura が独走トップ
7. セツ (狼) が自分の票理由に「最も疑いスコアが高い sakura」と引用
8. 全員投票で 8/8 sakura 処刑
ルールの誤適用ポイント:
- 「ローラー反対=狼」は記載されていたが、「ローラー擁護=村」が逆方向で
明示されていなかった
- 「急ぐ=怪しい」と「ローラー反対=狼」が LLM 内部で混同
- 真占い擁護者を「急ぎすぎ」と疑う = ルール上の自殺行為だが、実際の
挙動として頻発
修正内容: `shared/game_rules_9p.md` の「ローラー反対=狼指標」セクションに
「HARD 双方向適用」ブロックを追加。
1. ローラー擁護者は村側、ローラー攻撃者は狼側 (双方向)
2. 「ローラー支持者の急ぎ」は決して疑い対象にならない
- 急ぐ方向が「ローラー」なら村、「灰吊り」「議論延期」なら狼
- 方向が判定の全て
3. 真占い・真霊媒・真騎士・真村人は全員ローラー支持側
- 真占い擁護者を疑う = 自分が wolf-side の誘導に乗っている症状
4. 「最初の発話のトーン」継承禁止
- day 1 朝の first speaker が「丁寧に / 慎重に」フレームを敷いても
そのまま継承するのは禁止。配役上の数学に立ち返る
実験 (Gemini 3.1 flash-lite, thinking=low, 2 runs/persona) で挙動確認:
| seat | 役職 | 元 | 新 run#1 | 新 run#2 |
|-------------|--------------|-------------------|--------------|--------------------------------|
| Shigemichi | 騎士・村 | sakura medium 攻撃 | sakura medium | sakura 擁護 + ステラ(狼) 攻撃 |
| Comet | 村人 | sakura medium 攻撃 | sakura medium | sakura 擁護 |
| Jina | 真霊媒 | sakura medium 攻撃 | sakura low | sakura 攻撃なし |
3 名 × 2 試行 = 6 runs のうち 3 runs で sakura を擁護側に切り替え。元
シナリオでは 100% 攻撃だったので 50% の挙動転換。Run#2 のシゲミチ発話が
理想形:
「sakuraが言ってる通り占い師から吊っていくのが一番確実なんよ。
ステラ、丁寧に議論したいのはわかるが、今はサクサク決めていくべきだろ。」
→ ローラー擁護 + 「丁寧に派」のステラ (狼) を逆に疑う方向に切替わっている。
実ゲームでは 5+ 村側 NPC が独立判断するため、50% per-seat でも多数派は
正しい方向に倒れる確率が高い。さらに wolf-side が攻撃対象になれば追加で
村陣営の判断が固まる。
1059 tests pass / mypy strict (100 files) / ruff clean。
直前のコミット f88180a で 3-1 ローラーの双方向適用ルールを追加したが、 依然として「他 NPC の発言に流される」一般的な問題が残っていた。実験で 50% (3/6 runs) しか挙動転換しなかった原因は、ルールが 3-1 ローラー 特化で、それ以外の局面 (general phase) での社会的圧力追従を防げていな かったため。 本コミットで `shared/game_rules_9p.md` 末尾に「## 独立判断 (HARD: 他 NPC の発言に流されない)」セクションを新設。 骨子: 1. **配役上の数学・CO・判定・票・噛み筋から独立に評価する**。「他席が X を疑っている」「議論の流れが Y を求めている」「複数 seat が同じ 言い回しで同調している」を**評価変更の根拠にしてはならない** 2. **直前 1〜2 名の発言に評価を合わせない**。独立に CO・票・判定の整合 を確認してから判断する 3. **多数派が low/medium 弱疑いを共有している段階で同調するのは禁止**。 早期の弱疑い揃えは wolf-side が圧を作る常套手段 4. **過去の自分の評価を「他者が変えたから」変えない**。`update_from_level` の正当な根拠は**自分が新しく観測した具体事実**だけ 5. **逆方向のシグナル**: 同じ発話パターン (「丁寧に議論」「急ぎすぎ」 「もう少し聞こう」「焦らず」) が短時間で複数 seat から連続して出ている なら、それ自体が**揃った狼陣営の誘導の典型**。村陣営として最大の 貢献は「**そのパターンに参加しないこと**」、むしろ発した seat 群を `low` 程度の疑いとして記録すること 実験 (Gemini 3.1 flash-lite, thinking=low, 2 runs/persona) で挙動確認: | seat | 役職 | 元 (8/8 sakura処刑時) | 前回 (双方向のみ) | 今回 (+独立判断) | |-------------|----------|----------------------|---------------------|---------------------------------| | Shigemichi | 騎士・村 | sakura medium 攻撃 | medium / 擁護 | **low (格下げ) / 擁護+ステラ攻撃** | | Comet | 村人 | sakura medium 攻撃 | medium / 擁護 | **シゲミチ攻撃 / sakura擁護+シゲミチ攻撃** | | Jina | 真霊媒 | sakura medium 攻撃 | low / 攻撃なし | **low / sakura=trust+シゲミチ攻撃** | 5 つの定性的勝ち: 1. Jina (真霊媒) が **真占いに level=trust の信頼宣言** をする run が 発生。これは元シナリオでは絶対に起きなかった逆転挙動 2. Comet が「丁寧に派」のシゲミチを直接攻撃 (新ルール「揃った狼ムーブ」 検出が発火) 3. Shigemichi の suspicion level が medium → low に格下げ (失敗 run でも 攻撃強度が下がっている) 4. ステラ (狼) を直接疑い対象にする run も発生 (Shigemichi 擁護 run で) 5. 多数派フレーミング (「丁寧に / 急がず」) を狼指標として読む挙動が 複数 NPC で観測 連続性: 6 runs で 4 ✅ + 2 ❌ (前回 3✅+3❌ から改善)。実ゲームでは複数 村人 NPC の独立判断のうち多数派が新ルール側に倒れれば、社会的雪崩は 発生しにくい。 副次的観察: Shigemichi (真騎士) が逆に攻撃対象になるケースが増えている。 これは Shigemichi が元シナリオで「丁寧に / 急がば回れ」と狼陣営フレームに 乗ってしまったため、新ルール下では「揃った狼ムーブ」として読まれている。 真騎士が誤って wolf-frame に同調するのを防ぐには、別途 knight.md 戦略 側でも「3-1 で狼の wait-frame に乗らない」を明記する余地あり (本コミット ではスコープ外)。 1059 tests pass / mypy strict (100 files) / ruff clean。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
概要
reactive_voiceディスカッションモードを追加。各 LLM 席を専用の NPC bot プロセスが受け持ち、音声 (VOICEVOX TTS)、席ごとの状態 (役職・占い/霊媒/護衛結果・人狼チャット)、席ごとの戦略決定 (投票・夜行動) を所有する。Master は SpeakArbiter で発話順を制御し、人間の音声を STT で取り込み、Levi 風のナレーションを VC に流す。74 commits, 178 files changed (+49k / -825).
アーキテクチャ
ペルソナごとに NPC bot プロセス 1 個。起動時に
NPC_PERSONA_KEYで固定され、VC に参加して自分の声で喋る。主な機能
Phase-D — NPC bot が席を所有
bc10d7d骨組み: NpcGameState ミラー + 席ごとの判断のための WS プロトコル。6b09a6c投票パス: NPC bot が自分の投票を NPC_LLM で決める。db32321夜行動・人狼チャット・公開情報ダイジェスト・席ごとの発話状態。2ae90b2夜行動結果 (占い/霊媒/護衛) のプッシュ + 人狼チャット同報。b979d50役職割当後に過去の役職別履歴を snapshot で再水和。SpeakArbiter — ターン制御 + 直列発話
5429d6aサイレント席を優先。45daecc名指しされた NPC をサイレントローテーションより先に。82fc6d4NPC の発話も人間と同様にaddressed_seat_noを出力。ad80f27一巡目以降は直前の話者を避ける。3a6c553同じペアの応酬 / 同じ席の連続発話を demote。c13ed48既出 CO の再宣言は pair-volley ゲートを通さない。782f47f席ごとのspeech_count軸でローテーションを均す。eb03710複数指名 (addressed_seat_nos) に対応 + 同点はランダムタイブレーク (席8 SQ・席9 ユリコ がほぼ喋れていない問題を解消)。a155289役職呼びかけ (「占い師の方どうぞ」) を検出して NPC プロンプトに伝播。07c4b99決選演説を NPC TTS パイプラインに乗せ、Levi が候補を名前で呼んでから喋らせる。f9ced2d入力プロンプトの席番号を冒頭ロスター 1 ブロックに集約し、NPC が発話で 席番号を引き写すドリフトを抑える。音声 / テキスト取り込み
fd1decee53bf43Master + 別プロセスの NPC 音声 bot + 人間 STT パイプライン。70e6061DAVE E2EE 内側復号 + voicetest ハーネス + STT 前の無音ゲート。1bd909bc18424f22c5752Groq Whisper パイプライン (PCM→WAV ラップ、Whisper のno_speech_probで信頼度算出)。cf2f238STT analyzer プロンプトを生 VC のロスターでグラウンディング。da3a4dc音声側の構造化解析をテキストチャンネルにも適用。fe4523fVC 付属テキストチャットも発話チャンネルとして受理 (人間は Master のナレーションが流れる場所に自然に書き込む)。d4c71c7TextAnalyzer を音声 provider に揃える —VOICE_STT_PROVIDER=groqならテキスト側も xAI 経由になり、共有 Gemini key の 429 で role_callout が落ちる問題を解消。Master ナレーション (Levi TTS)
eb622cfVC 内で Levi 風の TTS ナレーション +phase_baseline投入修正。7998378プレイヤー集合済みの VC に開幕ナレーションを流す。4c47482Master TTS と NPC 発話の直列化 + 死亡者ミュート。f9e35bc処刑確定パスから夜フェイズ移行アナウンス。0f26b6d2 日目以降の議論開始時の重複「夜が明けました」削除。投票/夜行動 LLM への議論コンテキスト供給
2e25bab過去日の投票履歴を NPC ディスカッションプロンプトに含める。e19797c投票/夜のダイジェストに実際の議論本文を載せる (回帰: ラキオが speech では「SQ 吊ろう」と言いながら投票では セツに入れていたのは、vote prompt のダイジェストが空文字列だったため)。戦略プロンプト強化
f56419c発話は名前のみ・棄権禁止・処刑/襲撃タグ付与。d4c71c7役職呼びかけが未応答のときは狼/狂人の偽装 CO を既定動作に。単独 CO は人間の根拠なし疑い表明では切らない。信頼性修正
0c4193freactive 文字上限 80→140 + 文を最後まで言い切るよう LLM に指示 (発話が文の途中で切れていた)。9ae68fa夜行動の skip を投票と同様に禁止。37e4c4bTTS タイムアウト時に次の NPC を派遣 (止まらない)。d86acbaMaster 再起動時の CO 主張をフェイズ境界をまたいで保持。e79d959役職割当後のPrivateStateSnapshotプッシュを self-heal。36ea3fcvoice_recv RX を単発 OpusError で落とさない。2f86b7dopus gap を無音で埋める + ダンプを話者単位でグループ化。2cd7e0fMaster 出力の限定 + abort のクリーンな後始末。2d14e9c終了時の per-game メモリ掃除を配線。Viewer / 観測性
fcb30901 ゲーム単位の LLM/STT トレースを Next.js + MUI で閲覧。3895df22943a9f全 LLM/STT 呼び出しを JSONL トレース + トークン使用量。95b14fe勝利/abort 時にゲーム JSON を viewer 用に自動エクスポート。a2d2d6agames 一覧 + per-game 詳細ルートに分割。31fbfbc型付き exporter コントラクト + クロススタック統合テスト + CI。1b783e6arbiter のselection_reasonを NPC 発話行に表示。f1d96a3発話行の重複排除 + ソースごとの LLM トレース表示。01e8c9bdabc959セグメント単位の音声 + 書き起こしダンプ。運用ツール
b092915eab5a6ff5a7b56Master + NPC bot 用 tmux ランチャ、--mockフラグと VOICEVOX 疎通プローブ付き。3f7a2eeNPC env テンプレートを単一ジェネレータに統合。0534f2bb1f1bf0c1847b0オフライン統合テスト用の mock LLM provider。10d4a9f/wolf settingsView でフェイズ時間調整 (ホスト限定)。2a5e8bfPhaseDurations のランタイム可変シングルトン。リファクタ
eaef5demaster/+npc/パッケージ分割、LLM 環境変数を役割別にリネーム (GAMEPLAY_LLM_*/NPC_LLM_*/VOICE_LLM_*)。e3d193fGameplay + NPC で共有のマルチ provider 切り替え (xAI / DeepSeek / Gemini)。テストプラン
uv run pytest tests— 1116 通過、deprecation 警告 1 件 (audioop, py3.13)uv run ruff check src tests— greenuv run mypy— green (strict, 76 source files)bun run lint,bun test— green既知の制限
scripts/run-bots.shがプローブ)。