From fd1dece23a2fc22d62f83bd407ae0d081fbef19c Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sun, 26 Apr 2026 20:10:09 +0900 Subject: [PATCH 001/133] feat: add master + separated NPC voice bots + human STT pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../add-master-npc-voice-stt/.openspec.yaml | 2 + .../add-master-npc-voice-stt/current-phase.md | 11 + .../add-master-npc-voice-stt/design.md | 234 ++++ .../add-master-npc-voice-stt/proposal.md | 51 + .../review-ledger-design.json | 308 +++++ .../review-ledger-design.json.bak | 307 +++++ .../review-ledger.json | 310 +++++ .../review-ledger.json.bak | 309 +++++ .../specs/day-discussion/spec.md | 56 + .../specs/discord-integration/spec.md | 67 + .../specs/llm-seats/spec.md | 47 + .../specs/npc-voice-pipeline/spec.md | 145 +++ .../specs/speech-event-bus/spec.md | 65 + .../specs/voice-ingest/spec.md | 69 + .../add-master-npc-voice-stt/task-graph.json | 457 +++++++ .../changes/add-master-npc-voice-stt/tasks.md | 95 ++ openspec/specs/day-discussion/spec.md | 100 ++ openspec/specs/discord-integration/spec.md | 103 ++ openspec/specs/llm-seats/spec.md | 95 ++ pyproject.toml | 3 +- src/wolfbot/config.py | 6 + src/wolfbot/domain/discussion.py | 130 ++ src/wolfbot/domain/models.py | 4 + src/wolfbot/domain/ws_messages.py | 241 ++++ src/wolfbot/main.py | 189 ++- src/wolfbot/npc_bot_main.py | 69 + src/wolfbot/persistence/schema.py | 94 +- src/wolfbot/persistence/sqlite_repo.py | 209 +++- src/wolfbot/services/discord_service.py | 82 +- .../services/discussion_phase_summary.py | 138 ++ src/wolfbot/services/discussion_service.py | 576 +++++++++ src/wolfbot/services/game_service.py | 71 +- src/wolfbot/services/llm_service.py | 80 ++ src/wolfbot/services/master_ingest_service.py | 135 ++ src/wolfbot/services/master_logic_service.py | 82 ++ src/wolfbot/services/master_ws_server.py | 436 +++++++ src/wolfbot/services/npc_client.py | 265 ++++ src/wolfbot/services/npc_registry.py | 233 ++++ src/wolfbot/services/npc_speech_service.py | 132 ++ src/wolfbot/services/recovery_service.py | 17 + src/wolfbot/services/speak_arbiter.py | 511 ++++++++ src/wolfbot/services/structured_logging.py | 164 +++ src/wolfbot/services/stt_service.py | 136 ++ src/wolfbot/services/tts_service.py | 145 +++ src/wolfbot/services/voice_ingest_client.py | 235 ++++ src/wolfbot/services/voice_ingest_service.py | 320 +++++ .../services/voice_playback_service.py | 65 + tests/test_discord_cog_create.py | 4 +- tests/test_discussion_state.py | 444 +++++++ tests/test_game_service_advance.py | 16 +- tests/test_llm_prompt_builder.py | 8 +- tests/test_llm_service.py | 53 +- tests/test_master_ws_server.py | 544 ++++++++ tests/test_npc_voice_worker.py | 409 ++++++ tests/test_observability.py | 319 +++++ tests/test_public_discussion_state.py | 170 +++ tests/test_reactive_voice_master.py | 477 +++++++ tests/test_reactive_voice_mode.py | 1108 +++++++++++++++++ tests/test_rounds_speech_event_backfill.py | 224 ++++ tests/test_voice_ingest_service.py | 189 +++ uv.lock | 517 ++++---- 61 files changed, 11751 insertions(+), 330 deletions(-) create mode 100644 openspec/changes/add-master-npc-voice-stt/.openspec.yaml create mode 100644 openspec/changes/add-master-npc-voice-stt/current-phase.md create mode 100644 openspec/changes/add-master-npc-voice-stt/design.md create mode 100644 openspec/changes/add-master-npc-voice-stt/proposal.md create mode 100644 openspec/changes/add-master-npc-voice-stt/review-ledger-design.json create mode 100644 openspec/changes/add-master-npc-voice-stt/review-ledger-design.json.bak create mode 100644 openspec/changes/add-master-npc-voice-stt/review-ledger.json create mode 100644 openspec/changes/add-master-npc-voice-stt/review-ledger.json.bak create mode 100644 openspec/changes/add-master-npc-voice-stt/specs/day-discussion/spec.md create mode 100644 openspec/changes/add-master-npc-voice-stt/specs/discord-integration/spec.md create mode 100644 openspec/changes/add-master-npc-voice-stt/specs/llm-seats/spec.md create mode 100644 openspec/changes/add-master-npc-voice-stt/specs/npc-voice-pipeline/spec.md create mode 100644 openspec/changes/add-master-npc-voice-stt/specs/speech-event-bus/spec.md create mode 100644 openspec/changes/add-master-npc-voice-stt/specs/voice-ingest/spec.md create mode 100644 openspec/changes/add-master-npc-voice-stt/task-graph.json create mode 100644 openspec/changes/add-master-npc-voice-stt/tasks.md create mode 100644 openspec/specs/day-discussion/spec.md create mode 100644 openspec/specs/discord-integration/spec.md create mode 100644 openspec/specs/llm-seats/spec.md create mode 100644 src/wolfbot/domain/discussion.py create mode 100644 src/wolfbot/domain/ws_messages.py create mode 100644 src/wolfbot/npc_bot_main.py create mode 100644 src/wolfbot/services/discussion_phase_summary.py create mode 100644 src/wolfbot/services/discussion_service.py create mode 100644 src/wolfbot/services/master_ingest_service.py create mode 100644 src/wolfbot/services/master_logic_service.py create mode 100644 src/wolfbot/services/master_ws_server.py create mode 100644 src/wolfbot/services/npc_client.py create mode 100644 src/wolfbot/services/npc_registry.py create mode 100644 src/wolfbot/services/npc_speech_service.py create mode 100644 src/wolfbot/services/speak_arbiter.py create mode 100644 src/wolfbot/services/structured_logging.py create mode 100644 src/wolfbot/services/stt_service.py create mode 100644 src/wolfbot/services/tts_service.py create mode 100644 src/wolfbot/services/voice_ingest_client.py create mode 100644 src/wolfbot/services/voice_ingest_service.py create mode 100644 src/wolfbot/services/voice_playback_service.py create mode 100644 tests/test_discussion_state.py create mode 100644 tests/test_master_ws_server.py create mode 100644 tests/test_npc_voice_worker.py create mode 100644 tests/test_observability.py create mode 100644 tests/test_public_discussion_state.py create mode 100644 tests/test_reactive_voice_master.py create mode 100644 tests/test_reactive_voice_mode.py create mode 100644 tests/test_rounds_speech_event_backfill.py create mode 100644 tests/test_voice_ingest_service.py diff --git a/openspec/changes/add-master-npc-voice-stt/.openspec.yaml b/openspec/changes/add-master-npc-voice-stt/.openspec.yaml new file mode 100644 index 0000000..3f1f00e --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-26 diff --git a/openspec/changes/add-master-npc-voice-stt/current-phase.md b/openspec/changes/add-master-npc-voice-stt/current-phase.md new file mode 100644 index 0000000..9e03ee9 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/current-phase.md @@ -0,0 +1,11 @@ +# Current Phase: add-master-npc-voice-stt + +- Phase: fix-review +- Round: 5 +- Status: has_open_high +- Open High/Critical Findings: 4 件 — "Reactive voice still never drives the arbiter in production", "The per-NPC worker is still not runnable", "The human-speaking gate is released before STT finalization", "Playback deadlines are recorded but never enforced" +- Actionable Findings: 6 +- Accepted Risks: none +- Latest Changes: + - (no commits yet) +- Next Recommended Action: /specflow.fix_apply diff --git a/openspec/changes/add-master-npc-voice-stt/design.md b/openspec/changes/add-master-npc-voice-stt/design.md new file mode 100644 index 0000000..01a0fc2 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/design.md @@ -0,0 +1,234 @@ +# Design — Master + Separated NPC Voice Bots + Human STT + +This design backs the proposal `add-master-npc-voice-stt`. It expands the spec deltas (`speech-event-bus`, `voice-ingest`, `npc-voice-pipeline`, plus modified `day-discussion`, `llm-seats`, `discord-integration`) into the architecture, contracts, and integration choices that the apply phase will execute against. + +## Concerns + +The change is decomposed into seven user-facing concerns. Each is independently reviewable, and most can be implemented in parallel once their foundational contracts (the SpeechEvent shape, the WS protocol, the NPC registry view) are agreed. + +| ID | Concern | Problem it resolves | +|----|---------|---------------------| +| C1 | **Unified utterance bus (`speech-event-bus`)** | Today, public utterances live only as `PLAYER_SPEECH` log entries that are tightly coupled to the round-based LLM batch. Voice and reactive flows need a single canonical record of "who said what when," queryable across modes, rebuildable on restart. | +| C2 | **Public discussion derivation (`PublicDiscussionState`)** | Master-side reactive arbitration needs a stable code-derived view of who has CO'd, who is silent, etc. — not an LLM-summarized blob. The state must be deterministic from the event log so debugging and tests are sound. | +| C3 | **Human voice ingestion (`voice-ingest`)** | Humans cannot today participate by voice. We need a worker that listens to the Discord VC, runs VAD + STT, and emits `SpeechEvent(source=voice_stt)` — while reliably excluding NPC TTS audio so the model never feeds itself. | +| C4 | **NPC bot worker (`wolfbot-npc`)** | Each NPC needs its own Discord identity to be visibly distinct in VC and to play its own TTS voice. The bot must be a separable process with no game-state authority. | +| C5 | **Master ↔ NPC speech protocol** | Master must drive who speaks, when, with what context, and gate all NPC playback through explicit authorization. The protocol must reject stale results so a slow xAI response cannot leak speech into a phase that already advanced. | +| C6 | **Pluggable discussion mode (`day-discussion`)** | We need to introduce `reactive_voice` without breaking existing `rounds` games. The same code path must serve both modes, with a single mode-fixed-per-game switch via `LLM_DISCUSSION_MODE`. | +| C7 | **Cross-component observability** | Failures span STT / Master / NPC bot / TTS / VC. Without unified structured logs and a per-phase summary, regressions are invisible. | + +## State / Lifecycle + +### Canonical state (authoritative; written exactly once) + +| State | Owner | Persistence | Notes | +|-------|-------|-------------|-------| +| `Game`, `Seat`, `Player`, `Vote`, `NightAction`, `LogEntry` | Master | `wolfbot.db` (existing) | Unchanged by this change. | +| `SpeechEvent` row | Master (sole writer) | `speech_events` table (new) | Inserted by Master regardless of utterance origin (voice-ingest sends payload over ingestion endpoint; NPC bot sends `SpeakResult` over WS). | +| `npc_speak_requests` row | Master | `npc_speak_requests` (new) | Inserted at `SpeakArbiter` dispatch time. | +| `npc_speak_results` row | Master | `npc_speak_results` (new) | Inserted on `SpeakResult` arrival or recovery sweep. | +| `npc_playback_events` row | Master | `npc_playback_events` (new) | An **open** row (with `finished_at` NULL) is inserted when `PlaybackAuthorized` is issued — which happens **immediately after** Master accepts a `SpeakResult` and writes the `SpeechEvent`, **before** TTS begins. TTS and playback both run inside this authorized window. The row carries `tts_outcome` (`success` / `failed` / NULL while pending), `tts_duration_ms`, and `tts_failure_reason` columns — updated when `tts_finished` or `tts_failed` arrives from the NPC bot. If TTS fails, the row is **closed** immediately (`finished_at` + `tts_outcome=failed`); no audio playback occurs. If TTS succeeds, `tts_outcome` is updated to `success` and the row remains open until `playback_finished` / `playback_failed` / `playback_deadline_ms` timeout, at which point `finished_at` and `outcome` are set. On `master_restart`, all rows with `finished_at IS NULL` are closed with `failure_reason=master_restart`. | +| `llm_speech_counts` (existing) | Master | existing table | Used only in `rounds` mode. | + +### Derived state (rebuildable; never the source of truth) + +| State | Derivation | Lifetime | +|-------|-----------|----------| +| `PublicDiscussionState` | Pure fold over `speech_events` rows for the current `phase_id`. The alive-seat baseline is persisted as a **sentinel `SpeechEvent`** with `source=phase_baseline` inserted at phase start; the sentinel's `alive_seat_nos_json` column stores the `frozenset[int]` serialized as a JSON array. `silent_seats` = sentinel's `alive_seat_nos` minus seats with ≥1 non-sentinel `SpeechEvent` in the phase. This makes the fold self-contained — rebuild reads only `speech_events`, never the `seats` table. | In-memory per active game; rebuilt on Master restart by replaying the fold from `speech_events` alone (the sentinel provides the baseline). | +| NPC registry view in voice-ingest | Pushed from Master via WS `registry_snapshot` on connect and `registry_update` on changes. | In-memory per voice-ingest process; re-delivered on reconnect. | +| Open serial-speech gate (`human-speaking`, `npc-playing`) | `human-speaking`: set by `vad_speech_started` from voice-ingest WS, cleared on `vad_speech_ended` + finalized payload/failure (or `vad_finalization_timeout_ms`). `npc-playing`: computed from `npc_playback_events` rows with `finished_at IS NULL` (the open row inserted at `PlaybackAuthorized` time, **before TTS begins** — the gate covers both TTS synthesis and audio playback). | In-memory on Master; cleared to empty on restart (voice-ingest re-establishes via next `vad_speech_started`; open playback rows are closed with `failure_reason=master_restart` during the recovery sweep). | + +### Lifecycle boundaries + +- **Game lifecycle** is unchanged: `LOBBY → SETUP → NIGHT_0 → DAY_DISCUSSION ↔ DAY_VOTE … → GAME_OVER`. The new behavior plugs into all public speech phases (`DAY_DISCUSSION`, `DAY_RUNOFF_SPEECH`) — `LLM_DISCUSSION_MODE` selects the NPC speech strategy for these phases, and `SpeechEvent` capture + `PublicDiscussionState` derivation + `discussion_phase_summary` emission apply uniformly to every public speech phase. +- **`SpeakRequest` lifecycle**: created → dispatched over WS → result arrives (accepted/declined/error) → Master validates utterance (length cap, phase freshness) → if accepted: Master writes `SpeechEvent`, issues `PlaybackAuthorized` immediately (inserts an **open** `npc_playback_events` row) → NPC performs TTS inside the authorized window → `tts_finished` or `tts_failed` reported to Master (updates the open row) → if TTS succeeded (`tts_outcome=success`): NPC plays audio → `playback_finished` OR `playback_deadline_ms` timeout → the open row is **closed** with `finished_at` and `outcome`. If TTS failed (`tts_outcome=failed`): the open row is **closed** immediately; no audio playback occurs. The serial-speech gate (`npc-playing`) is held from `PlaybackAuthorized` through both TTS and playback, so synthesis time is covered by the gate and concurrent NPC turns cannot prepare TTS in parallel. +- **VAD window lifecycle**: `vad_speech_started` → packets buffered → `vad_speech_ended` → STT call → either `SpeechEvent(source=voice_stt)` written OR drop with structured-log evidence. +- **Process lifecycle**: Master, voice-ingest, and each NPC bot can restart independently; in-flight WS-protocol state at the restarted boundary is dropped (see "Integration Points"). + +### Persistence-sensitive state + +`speech_events` is the only new state that must survive restart for correctness — `PublicDiscussionState` is rebuilt from it. `npc_speak_*` and `npc_playback_events` are audit trails (queryable forever for postmortem) but the live arbiter does not depend on them after a restart; pending entries are simply marked rejected/failed during the recovery sweep. + +## Contracts / Interfaces + +### Inter-process contracts (new) + +**Master ↔ NPC bot (WebSocket, localhost-only)** + +``` +NPC → Master: npc_register {npc_id, discord_bot_user_id, supported_voices[], version} +Master → NPC: npc_registered {assigned_seat, game_id, phase_id} +NPC → Master: heartbeat {npc_id, ts} +Master → NPC: logic_packet {packet_id, phase_id, recipient_npc_id, public_state_summary, + logic_candidates[{id, claim, support[], counter[]}], + pressure{seat→float}, expires_at_ms} +Master → NPC: speak_request {request_id, phase_id, npc_id, seat_no, logic_packet_id, + suggested_intent, max_chars, max_duration_ms, + priority, expires_at_ms} +NPC → Master: speak_result {request_id, npc_id, phase_id, status, text?, used_logic_ids[], + intent?, estimated_duration_ms?, failure_reason?} +Master → NPC: playback_authorized {request_id, npc_id, status=authorized, + speech_event_id, playback_deadline_ms} +Master → NPC: playback_rejected {request_id, npc_id, status=rejected, failure_reason} +NPC → Master: tts_finished {request_id, npc_id, tts_duration_ms, audio_size_bytes} +NPC → Master: tts_failed {request_id, npc_id, failure_reason} +NPC → Master: playback_finished {request_id, npc_id, started_at_ms, finished_at_ms} +NPC → Master: playback_failed {request_id, npc_id, failure_reason} +``` + +All messages share a common envelope `{type, ts, trace_id}` and are JSON-encoded UTF-8. Authentication: a single `MASTER_NPC_PSK` is presented in the connect handshake; mismatch → connection refused. + +**voice-ingest → Master (WebSocket, localhost-only)** + +This contract fully specifies the voice-ingest control plane: **transport** (WebSocket, chosen below), **VAD lifecycle signaling** (`vad_speech_started` / `vad_speech_ended` with `segment_id` correlation), **finalized speech delivery** (`speech_event_payload` / `stt_failed`), **NPC registry delivery** (`registry_snapshot` / `registry_update`), **fail-closed behavior** when registry is unavailable, **serial-speech gate semantics** during control-plane failures, **restart semantics** for open VAD windows, and **`failure_reason` ownership** between voice-ingest and Master. Each sub-decision is detailed in a dedicated paragraph below. + +Transport choice: WebSocket (same `websockets` library as the NPC channel). HTTP was considered but rejected because VAD lifecycle events (`vad_speech_started`, `vad_speech_ended`) require low-latency push from voice-ingest to Master to drive the serial-speech gate; a polling or request-response model adds unacceptable latency to gate transitions. The voice-ingest WS connection authenticates with the same `MASTER_NPC_PSK` mechanism as NPC bots. + +``` +voice-ingest → Master: vad_speech_started {game_id, phase_id, speaker_discord_user_id, seat_no, + segment_id, audio_start_ms, ts} +voice-ingest → Master: vad_speech_ended {game_id, phase_id, speaker_discord_user_id, seat_no, + segment_id, audio_end_ms, ts} +voice-ingest → Master: speech_event_payload {game_id, phase_id, seat_no, speaker_discord_user_id, + segment_id, text, confidence, duration_ms, + audio_start_ms, audio_end_ms, ts} +voice-ingest → Master: stt_failed {game_id, phase_id, speaker_discord_user_id, seat_no, + segment_id, failure_reason, ts} +Master → voice-ingest: registry_snapshot {npc_user_ids: [discord_bot_user_id]} +Master → voice-ingest: registry_update {added: [discord_bot_user_id], removed: [discord_bot_user_id]} +voice-ingest → Master: heartbeat {ts} +``` + +All messages share the same common envelope `{type, ts, trace_id}` as the NPC channel. + +**VAD lifecycle signaling to Master.** `vad_speech_started` and `vad_speech_ended` are sent as soon as the VAD engine detects transitions. Master uses these to set/clear the `human_currently_speaking` serial-speech gate — while a VAD window is open (after `vad_speech_started`, before `vad_speech_ended` + finalized `speech_event_payload` or `stt_failed` arrives), `SpeakArbiter` rejects NPC `SpeakRequest`s with `failure_reason=human_currently_speaking`. The gate clears when **both** `vad_speech_ended` has arrived **and** the corresponding `speech_event_payload` or `stt_failed` has been received (or a configurable `vad_finalization_timeout_ms` expires, closing the gate with a structured-log warning). + +**Audio boundary fields and segment correlation.** Each VAD segment is assigned a unique `segment_id` by voice-ingest at `vad_speech_started` time; the same `segment_id` is carried through `vad_speech_ended`, `speech_event_payload`, and `stt_failed` so Master can correlate the full lifecycle. `vad_speech_started` carries `audio_start_ms` (milliseconds since the voice-ingest process joined the VC); `vad_speech_ended` carries `audio_end_ms`. Both values are echoed in `speech_event_payload` so Master can persist `audio_start_ms` and `audio_end_ms` directly into the `speech_events` row for `source=voice_stt` without inference. For `source=text` and `source=npc_generated`, `audio_start_ms` and `audio_end_ms` are NULL. + +**`failure_reason` ownership.** voice-ingest owns STT-layer failure reasons (`stt_low_confidence`, `stt_provider_error`, `stt_timeout`). Master owns protocol- and game-layer failure reasons (`stale_phase`, `expired_request`, `human_currently_speaking`, `queue_busy`, `npc_offline`, `utterance_too_long`, `tts_timeout`, `tts_synthesis_error`, `playback_not_authorized`, `master_restart`, `npc_restart`, `voice_ingest_restart`, `voice_ingest_disconnect`, `discord_playback_error`, `npc_stt_discarded`). Each process emits structured-log events with only its own failure reasons; Master records voice-ingest-originated reasons verbatim when they arrive via WS (e.g. in `stt_failed` messages) but does not synthesize new STT-layer reasons. + +**NPC registry delivery.** Master pushes `registry_snapshot` on voice-ingest connection and `registry_update` on NPC registration/deregistration changes. Voice-ingest no longer polls. + +**Fail-closed on registry unavailability.** If voice-ingest has not yet received a `registry_snapshot` (initial connect not complete) or the WS connection to Master is down, voice-ingest operates in **fail-closed mode**: it treats the NPC user-ID set as **empty**, meaning all audio (including any NPC TTS) is processed through VAD/STT. This is safe because Master applies a **NPC-originated STT discard** guard: when writing a `SpeechEvent` from a `speech_event_payload`, Master checks if `speaker_discord_user_id` belongs to a registered NPC in the `NpcRegistry`; if so, the event is discarded with structured-log event `npc_stt_discarded` and no `SpeechEvent` row is written. This guarantees the "never feed NPC TTS into STT" invariant even during fail-closed operation — the worst case is wasted STT compute, not corrupted game state. The alternative (fail-open = drop all audio) would silence humans during transient Master outages, which is worse. + +**Serial-speech gate during control-plane failures.** When the voice-ingest WS connection to Master is down, Master cannot receive `vad_speech_started` / `vad_speech_ended`. During this window the `human_currently_speaking` gate remains in whichever state it was left (cleared on disconnect per the restart semantics below). `SpeakArbiter` continues to schedule NPC speech; if a human is actually speaking but the gate is unset (because voice-ingest disconnected), the NPC may speak concurrently — this is an accepted degradation under connection loss. The reconnection path is self-healing: voice-ingest's next `vad_speech_started` after reconnection re-establishes the gate. No manual intervention is required. + +**Restart semantics for open VAD windows.** On voice-ingest restart, all open VAD windows are abandoned — no `vad_speech_ended` is sent for windows from the prior process. On Master restart, all tracked `human_currently_speaking` gates are cleared (Master starts with an empty gate set); if a VAD window is genuinely still open, voice-ingest's next `vad_speech_started` re-establishes it after reconnection. On Master-side WS disconnect from voice-ingest, Master clears all `human_currently_speaking` gates associated with that voice-ingest connection, logging `failure_reason=voice_ingest_disconnect`. + +### In-process contracts (Protocol-based, for testability) + +| Protocol | Implementations (real / fake) | Owner | +|----------|-------------------------------|-------| +| `SpeechEventStore` | `SqliteSpeechEventStore` / `FakeSpeechEventStore` | Master | +| `PublicDiscussionStateBuilder` | `DefaultBuilder` (pure code) / fake n/a (build is pure) | Master | +| `NpcRegistry` | `InMemoryNpcRegistry` / `FakeNpcRegistry` | Master | +| `SpeakArbiter` | `DefaultSpeakArbiter` / `FakeArbiter` (test override) | Master | +| `MasterWsServer` | `WebsocketsMasterWsServer` / `FakeMasterWsServer` | Master | +| `VoiceIngestClient` (master-side) | `WebsocketsVoiceIngestClient` / `FakeVoiceIngestClient` | voice-ingest | +| `SttService` | `GeminiSttService` / `FakeSttService` | voice-ingest | +| `VadEngine` | `WebrtcVadEngine` (or chosen library) / fake n/a | voice-ingest | +| `MasterClient` (npc-side) | `WebsocketsMasterClient` / `FakeMasterClient` | NPC bot | +| `TtsService` | `GoogleTtsService` (cost-minimized default) / `FakeTtsService` | NPC bot | +| `VoicePlayback` | `DiscordVoicePlayback` / `FakeVoicePlayback` | NPC bot | +| `NpcGenerator` | `XaiNpcGenerator` (reuses existing decider) / `FakeNpcGenerator` | NPC bot | + +The existing `DiscordAdapter`, `LLMAdapter`, `LLMActionDecider`, `MessagePoster`, `WakeSink` Protocols are preserved — `LLMAdapter` is unchanged for `rounds` mode; `reactive_voice` mode goes through the new `SpeakArbiter` instead. + +### Human text ingestion and PLAYER_SPEECH emission (R1-F01) + +**Human text → SpeechEvent.** In both discussion modes, human messages sent to the main text channel during any public speech phase (`DAY_DISCUSSION`, `DAY_RUNOFF_SPEECH`) are captured by `WolfCog` (existing Discord message listener on Master) and written as `SpeechEvent(source=text, speaker_kind=human)` via `SpeechEventStore`. No new inter-process contract is needed — this is an in-process path on Master. + +**SpeechEvent → PLAYER_SPEECH LogEntry + channel post.** Every accepted `SpeechEvent` — regardless of source (`text`, `voice_stt`, `npc_generated`) — triggers two side-effects on Master: +1. A `LogEntry(kind="PLAYER_SPEECH")` is appended to the existing game log (preserving the public-log contract for both modes). +2. The utterance text is posted to the main text channel via `MessagePoster` (for `voice_stt` and `npc_generated` sources this makes the utterance visible to text-only observers; for `text` source the original Discord message already serves as the channel post, so step 2 is skipped to avoid duplication). + +This is implemented as a `SpeechEventStore` post-write hook (or inline in the discussion service write path) so the contract is uniform across all ingestion origins: voice-ingest POST, NPC `SpeakResult` acceptance, rounds-mode backfill, and human text capture. + +**Reactive `SpeakResult` utterance validation.** Before writing a `SpeechEvent` and authorizing playback, Master validates the returned utterance text in `SpeakResult`: +- **Length cap**: In `reactive_voice` mode, `len(text) > 80` → reject with `failure_reason=utterance_too_long`; no `SpeechEvent` is written and no `PlaybackAuthorized` is issued. In `rounds` mode (if `SpeakResult` were ever used there), the cap is 300. The rejected result is recorded in `npc_speak_results` with `status=rejected` and the failure reason. +- **Phase freshness**: `phase_id` and `request_id` must match the current active phase and an outstanding request (existing check). +- Validation runs **before** `SpeechEvent` insertion and **before** `PlaybackAuthorized`, so an over-length utterance never enters the public discussion state or reaches Discord playback. + +## Persistence / Ownership + +### Data ownership boundaries + +- **Master is the sole writer** to all SQLite tables (`speech_events`, `npc_speak_*`, `npc_playback_events`, plus existing tables). voice-ingest and NPC bots never touch `wolfbot.db`. +- **NPC bots own their own Discord identity** (token, voice channel join, audio playback). Master never plays audio. +- **voice-ingest owns audio capture**. NPC bots and Master never read incoming voice packets. + +### Storage mechanisms + +- All persistent state stays in `wolfbot.db` (SQLite via aiosqlite). Schema changes are additive (`CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ADD COLUMN` guarded by `PRAGMA table_info`). +- The TTS audio cache (used by NPC bots) is in-memory per process for the MVP; cache key = `provider + voice_id + sha256(text) + speed + pitch`. A persistent cache is out of scope. +- WebSocket sessions, heartbeat tracking, and arbiter gates are in-memory on Master. After restart, Master starts with empty session state and reconciles existing DB rows via the recovery sweep. + +### Artifact ownership + +- `proposal.md`, `design.md`, `tasks.md`, the change's `specs/**/*.md` deltas — owned by this change directory. +- Baseline `openspec/specs//spec.md` files — shared across changes; will be merged-in form on archive (per OpenSpec convention). + +## Integration Points + +### External systems + +- **Discord (gateway + voice + REST)** — Master, every NPC bot, voice-ingest each maintain their own Discord client connection. Slash commands stay on Master. +- **xAI Grok endpoint** — used by Master's existing `LLMAdapter` (rounds mode) and by each NPC bot (reactive_voice). No change to the `LLMAction` schema or to `response_format` strictness. +- **Gemini API (audio input)** — invoked by voice-ingest for STT only. Out of band from xAI traffic. +- **TTS provider (cost-minimized default: Google Cloud TTS Standard voices)** — invoked by each NPC bot. Provider is pluggable via `TTS_PROVIDER` / `TTS_VOICE_ID`. + +### Cross-layer dependency points + +- voice-ingest depends on Master's NPC registry (delivered via WS `registry_snapshot` / `registry_update`) to know which Discord user IDs to drop. If the WS connection is down or no snapshot has been received, voice-ingest operates fail-closed (empty NPC set → all audio processed; see "voice-ingest → Master" contract above for rationale). +- The serial-speech gate on Master couples voice-ingest VAD lifecycle events (`vad_speech_started`, `vad_speech_ended`) with NPC playback windows. The gate is the single integration point where these two streams meet; gate transitions and timeout behavior are specified in the voice-ingest WS contract above. +- `SpeakArbiter` reads `PublicDiscussionState`, which is built from `speech_events`, which is fed by both voice-ingest and NPC bots. This is a closed loop and is the heart of reactive_voice. + +### Regen / retry / save / restore boundaries + +- xAI errors retry within `tenacity` (existing). NPC bot failures bubble up as `speak_result.status=error` with `failure_reason`. +- STT errors and low-confidence results drop the utterance silently (no retry from voice-ingest's side — the human can simply speak again). +- TTS errors are recorded; the NPC stays silent for that turn but the game continues. +- Process restarts: in-flight WS state is dropped, audit-trail rows are finalized as rejected/failed by the recovery sweep, `PublicDiscussionState` is rebuilt from `speech_events`. + +## Ordering / Dependency Notes + +Implementation must proceed in the order below because later concerns rely on earlier contracts. Where parallel work is feasible, it is called out. + +1. **Foundational (must come first):** + - C1 `speech-event-bus` — defines the `SpeechEvent` shape, the `speech_events` table, the apply-event/rebuild functions. Everything downstream produces or consumes `SpeechEvent`. + - C2 `PublicDiscussionState` — pure code, depends on C1's shape. + +2. **Parallelizable once C1+C2 land:** + - C3 `voice-ingest` worker (depends on C1 shape, on the Master ingestion endpoint, and on the NPC-registry read endpoint). + - C4 `wolfbot-npc` worker skeleton (depends on the Master WS protocol shape from C5 below). + - C5 Master WS server + `MasterLogicBuilder` + `SpeakArbiter` + new tables (depends on C2). + - C6 `LLM_DISCUSSION_MODE=rounds` SpeechEvent writer (depends on C1; preserves observable rounds behavior). + +3. **Integration (last):** + - C6 `LLM_DISCUSSION_MODE=reactive_voice` end-to-end wiring — ties C3, C4, C5 together. + - C7 structured-log standardization + `discussion_phase_summary` event — overlays everything; can begin in parallel with C5/C6. + +4. **Operational ordering:** + - DB migrations before any new code path that writes new tables. + - Protocol Fakes alongside the real implementations (TDD-friendly). + - Apply tasks should land in commits aligned to the concern boundaries above (one commit per concern when feasible). + +## Completion Conditions + +A concern is complete when its observable conditions and reviewable artifacts are all in place: + +| Concern | Reviewable artifact | Observable condition | +|---------|---------------------|----------------------| +| C1 `speech-event-bus` | `domain/discussion.py` (`SpeechEvent`), `services/discussion_service.py`, `persistence/schema.py` migration block, unit tests | A `SpeechEvent` for any input source round-trips through write → rebuild → identical state. | +| C2 `PublicDiscussionState` | `domain/discussion.py` (state value object + `apply_speech_event` + `rebuild_public_state_from_events`), property tests | For a fixed event sequence and alive-seat baseline, the state is bitwise reproducible from a fresh fold; co_claims and silent_seats match the specified rules; silent_seats correctly excludes dead seats. | +| C3 `voice-ingest` | `services/voice_ingest_service.py`, `services/stt_service.py`, NPC-registry read client, ingestion HTTP client, unit tests with `FakeSttService` | A scripted human VAD segment produces exactly one `SpeechEvent(source=voice_stt)` on Master; an NPC packet produces zero `SpeechEvent`s; below-threshold STT produces zero `SpeechEvent`s plus the expected log events. | +| C4 `wolfbot-npc` worker | `npc_bot_main.py`, `services/npc_client.py`, `services/npc_speech_service.py`, `services/tts_service.py`, `services/voice_playback_service.py`, integration test with `FakeMasterWsServer` + `FakeTtsService` + `FakeVoicePlayback` | The NPC bot registers, receives a `SpeakRequest`, returns `SpeakResult`, waits for `PlaybackAuthorized` before calling TTS, synthesizes and plays only within the authorized window, reports `tts_finished`/`tts_failed` and `playback_finished`/`playback_failed`. | +| C5 Master speech arbitration | `services/master_logic_service.py`, `services/speak_arbiter.py`, `services/npc_registry.py`, `services/master_ws_server.py`, three new tables, recovery sweep, unit + integration tests | An end-to-end reactive_voice fixture (Master + Fake NPC + Fake voice-ingest) produces correct serial speech, drops stale results, and rebuilds `PublicDiscussionState` after a simulated Master restart. | +| C6 `day-discussion` mode plumbing | `LLM_DISCUSSION_MODE` setting in `config.py`, dispatcher in `game_service`, regression tests for `rounds` mode (must stay green) | Existing `rounds` tests still pass; a new `reactive_voice` integration test exercises the new path; mode is observably fixed for a game's lifetime. | +| C7 Observability | Logging helper in shared module, every new service emits the required fields, `discussion_phase_summary` event at every phase end, structured-log fixture tests | Every spec-listed log event appears in the test logs with the required fields; `discussion_phase_summary` includes separate `tts_success` / `tts_failed` counts (from `npc_playback_events.tts_outcome`) and `playback_success` / `playback_failed` counts; speech-event counts equal the number of `SpeechEvent` rows for the phase under test. | + +## Accepted Spec Conflicts + +| id | capability | delta_clause | baseline_clause | rationale | accepted_at | +|----|-----------|--------------|-----------------|-----------|-------------| +| AC1 | llm-seats | Under `LLM_DISCUSSION_MODE=reactive_voice`, utterances MUST be ≤80 characters and MAY be shorter. | Every LLM-generated utterance SHALL be Japanese, between 80 and 300 characters inclusive. | Reactive voice mode favors short reactive interjections (single barbs, ≤80 chars) over long monologues; the user explicitly approved relaxing the lower bound under this mode during clarify. The 80–300 contract is preserved unchanged for `rounds` mode. | 2026-04-26T04:50:00Z | +| AC2 | speech-event-bus | A sentinel row with `source=phase_baseline` and `alive_seat_nos_json` is inserted into `speech_events` at the start of each public speech phase to provide the alive-seat baseline for `PublicDiscussionState` rebuild. | `SpeechEvent` is defined as a unified public-utterance contract with `source` values `voice_stt`, `text`, `npc_generated`. | The sentinel makes the `PublicDiscussionState` fold self-contained (rebuild reads only `speech_events`, never the `seats` table), which is critical for correctness on Master restart. The sentinel is excluded from public-log emission (no `PLAYER_SPEECH` LogEntry, no channel post) and from all downstream consumer counts; consumers filter on `source != phase_baseline`. | 2026-04-26T15:00:00Z | diff --git a/openspec/changes/add-master-npc-voice-stt/proposal.md b/openspec/changes/add-master-npc-voice-stt/proposal.md new file mode 100644 index 0000000..fb98db0 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/proposal.md @@ -0,0 +1,51 @@ +## Why + +Today the bot drives 9-player Werewolf entirely through Discord text. LLM-controlled seats only emit a fixed two-round burst of `PLAYER_SPEECH` log entries during `DAY_DISCUSSION`, and human players type to participate. To make games feel like a live voice table — humans speaking aloud, NPCs replying with TTS — we need three things the current architecture cannot provide: + +1. A way to ingest human voice from a Discord VC and turn it into structured `SpeechEvent` records. +2. A reactive (rather than fixed-round) discussion engine that can decide *which* NPC should speak *now*, given the unfolding public dialogue. +3. NPC voices that are visibly distinct in Discord (one Discord bot identity per NPC) and that play back through TTS in the same VC where humans are speaking — but whose TTS audio is never re-fed into STT. + +Putting all of this inside the single existing bot process would couple game logic, public-state derivation, voice capture, LLM generation, TTS, and VC playback into one untestable surface. Splitting into Master + per-NPC voice bots + a dedicated voice-ingest worker isolates each concern, makes failures observable, and lets us run NPCs as real Discord identities with their own voices. + +## What Changes + +- **Introduce `SpeechEvent` as the unified public-utterance contract.** Every utterance — human voice (via STT), human text, and NPC-generated (via Grok) — is recorded as a `SpeechEvent` with the same schema, distinguished by a `source` enum (`voice_stt`, `text`, `npc_generated`) and a `speaker_kind` enum (`human`, `npc`). For human voice, exactly **one** `SpeechEvent` is written **after STT completes**; the upstream VAD lifecycle (`vad_speech_started`, `vad_speech_ended`) is emitted as separate structured-log events on the voice-ingest worker, not as `SpeechEvent` records. Persisted in a new `speech_events` table. **Both `LLM_DISCUSSION_MODE` modes** write `SpeechEvent` records — `rounds` mode also persists each LLM utterance as `SpeechEvent(source=npc_generated)` — so persistence is uniform. +- **Introduce `PublicDiscussionState` derived from `SpeechEvent` history.** Master maintains `co_claims`, `stances`, `pressure`, `open_topics`, `silent_seats`, and `recent_speech_event_ids` as code-side derivations from the event log — never as LLM-generated state. Initially in-memory; rebuildable from `speech_events`. **Both modes build the state** so debugging telemetry and `discussion_phase_summary` are uniform; `reactive_voice` additionally consumes the state to drive `MasterLogicBuilder` / `SpeakArbiter`. **MVP-required derivation rules** are limited to (a) `co_claims` from `SpeechEvent.text` containing canonical CO tokens (`〇〇CO`, `占いCO`, `霊媒CO`, `騎士CO`, etc.) for the speaker's seat, and (b) `silent_seats` as the set of seats with zero `SpeechEvent`s in the current `phase_id`. The remaining fields (`stances`, `pressure`, `open_topics`) are skeletons in MVP — their concrete heuristics are decided in the design phase. +- **Add a voice-ingest worker.** Receives only human VC audio, runs per-speaker VAD, STT, and emits `SpeechEvent(source=voice_stt)` to Master. NPC TTS audio is explicitly excluded from STT: voice-ingest reads the Master NPC registry, builds the set of `discord_bot_user_id`s currently registered as NPCs, and discards any incoming voice packet whose `user_id` is in that set at the receive boundary (before VAD/STT). The MVP STT is **Gemini API audio input**. STT results below a configured `stt_confidence` threshold (or hard provider errors) are **dropped without writing a `SpeechEvent`** — only `vad_speech_started`, `vad_speech_ended`, and `stt_request_failed` / `stt_low_confidence` structured-log events are recorded; no Discord-side surfacing of "please repeat" is required for MVP. +- **Split each NPC into its own bot process.** Per-NPC Discord token, persona, private state, and TTS voice. The NPC bot connects to Master via WebSocket, receives `LogicPacket` + `SpeakRequest`, generates a short utterance via Grok, returns `SpeakResult` to Master, and plays TTS in VC **only after** receiving `PlaybackAuthorized`. NPC bots never speak unsolicited. The MVP runs Master and all NPC bot processes on the **same host**: the WebSocket server binds to `127.0.0.1` and authenticates connections with a **pre-shared key** read from an environment variable. Multi-host deployment, TLS, and per-NPC token rotation are out of scope for the MVP. +- **Add a Master-side speech arbitrator.** `MasterLogicBuilder` builds per-NPC `LogicPacket`s from the public state; `SpeakArbiter` decides which NPC to wake, dispatches `SpeakRequest` with `phase_id` and `expires_at`, validates returned `SpeakResult` against current phase, records the accepted utterance as `SpeechEvent(source=npc_generated)`, and authorizes playback. Speech is **strictly serial** within a game: while a human is mid-utterance (after `vad_speech_started` and before either `vad_speech_ended` *and* a finalized `SpeechEvent(source=voice_stt)` arrives — or a configurable timeout) or another NPC's `PlaybackAuthorized` window is still open, the arbitrator rejects new `SpeakRequest`s with `failure_reason=human_currently_speaking` or `queue_busy`. A `PlaybackAuthorized` window closes when **either** the NPC bot reports `playback_finished` over the WebSocket **or** the Master clock passes `playback_deadline_ms` (whichever comes first); a deadline-driven close is logged with `failure_reason=tts_timeout` or `discord_playback_error` so the cause is auditable. Stale results (phase changed, expired) are dropped with structured log evidence. +- **Tolerate NPC bot disconnection.** When `SpeakArbiter` evaluates a candidate NPC for whom no recent heartbeat exists or whose WebSocket is offline, that NPC silently stays out of the round; the game continues, votes and night actions still resolve through the existing DM flow. There is no automatic fallback to text-mode speech for that NPC during the affected phase. +- **Recovery on process restart.** If Master, voice-ingest, or any NPC bot restarts during `DAY_DISCUSSION`, in-flight state at that process boundary is dropped: any open `SpeakRequest` whose `SpeakResult` has not yet arrived, and any unfinished `PlaybackAuthorized` window, are closed with `failure_reason=master_restart` (or `npc_restart` / `voice_ingest_restart` as appropriate); open VAD windows on voice-ingest are abandoned. On Master restart, `PublicDiscussionState` is rebuilt from `speech_events` for the active phase. The existing `RecoveryService` rule — `deadline_epoch < now` parks the game in `WAITING_HOST_DECISION` — is preserved and is the canonical path for stale phases. There is no per-request persistence of in-flight WebSocket exchanges. +- **Make `DAY_DISCUSSION` engine pluggable.** New env var `LLM_DISCUSSION_MODE` selects between `rounds` (existing two-round LLM batch — default, kept on the existing 80–300 character cap) and `reactive_voice` (the new Master-arbitrated flow, capped at **≤80 characters per utterance** to favor short reactive interjections). The `PLAYER_SPEECH` public-log contract is preserved by both modes. Mode is fixed for the lifetime of a single game (no mid-game switching). +- **Add new persistence tables.** `speech_events`, `npc_speak_requests`, `npc_speak_results`, `npc_playback_events` — all additive (`CREATE TABLE IF NOT EXISTS`) and backwards-compatible. +- **Standardize structured logging across all components.** Required fields include `ts`, `level`, `component`, `event`, `game_id`, `phase_id`, `trace_id`, `span_id`, plus event-specific fields. A `discussion_phase_summary` event is emitted at the end of every discussion phase summarizing STT / generation / authorization / playback / stale-drop counts. +- **Standardize `failure_reason` enum** across STT, LLM, TTS, VC, and protocol layers (e.g. `stale_phase`, `expired_request`, `npc_offline`, `tts_timeout`, `stt_low_confidence`, `playback_not_authorized`). +- **Preserve unchanged surfaces.** Voting, night actions, wolf-channel chat, persona registry, Grok structured-output schema, SQLite optimistic-locking transition flow, DM-based vote/action submission, and restart recovery are kept as-is. + +## Capabilities + +### New Capabilities + +- `speech-event-bus`: Unified `SpeechEvent` contract for all public utterances; code-side `PublicDiscussionState` derivation; `speech_events` table; rebuild-from-log semantics. +- `voice-ingest`: Human-only Discord VC audio capture, per-speaker VAD, STT, and `SpeechEvent(source=voice_stt)` emission to Master. NPC TTS audio is explicitly out of scope for STT. +- `npc-voice-pipeline`: End-to-end Master ↔ NPC bot speech protocol — separated NPC bot worker processes (one Discord token each), WebSocket transport, Master-side `MasterLogicBuilder` / `SpeakArbiter` / NPC registry / `LogicPacket` / `SpeakRequest` / `SpeakResult` / `PlaybackAuthorized` lifecycle, NPC-side Grok generation / TTS / VC playback gated on Master authorization, `phase_id` + `expires_at` staleness handling, and the `npc_speak_requests` / `npc_speak_results` / `npc_playback_events` tables. + +### Modified Capabilities + +- `day-discussion`: Add `LLM_DISCUSSION_MODE` setting; introduce the `reactive_voice` mode that replaces fixed-round LLM batches with Master-arbitrated NPC speech driven by `PublicDiscussionState`, capped at ≤80 chars per utterance. The default `rounds` mode keeps its existing two-round LLM-speech batches and 80–300-char cap. **Both modes additionally write every utterance (human text, human voice via STT, NPC) as a `SpeechEvent` and build `PublicDiscussionState`**, so persistence and telemetry are uniform; only the LLM speech-generation strategy differs between modes. Mode is fixed for the lifetime of a single game. Both modes continue to emit `PLAYER_SPEECH` log entries (the public-log contract is unchanged), and a `discussion_phase_summary` log event is emitted at every discussion phase end. +- `llm-seats`: Decouple LLM-driven NPC behavior from the in-process `LLMAdapter` so it can also run inside a separate `wolfbot-npc` process. NPC bots receive `LogicPacket` payloads (rather than the full game snapshot) for day-discussion utterances, while preserving today's persona registry, prompt-builder pipeline, role-strategy isolation, structured-output `LLMAction` schema, and seat-token resolver. +- `discord-integration`: Permit multiple Discord bot identities to coexist in one guild — one Master bot plus one bot per NPC. Add VC join/playback responsibilities to the NPC bot side and rules ensuring NPC bots never speak before Master authorizes their utterance. The existing `/wolf` slash-command set, channel-permission triplet, DM views, and end-of-game channel deletion behavior are preserved. + +## Impact + +- **New code modules** (Master): `src/wolfbot/domain/discussion.py` (SpeechEvent, PublicDiscussionState), `src/wolfbot/services/discussion_service.py`, `src/wolfbot/services/master_logic_service.py`, `src/wolfbot/services/speak_arbiter.py`, `src/wolfbot/services/npc_registry.py`, `src/wolfbot/services/master_ws_server.py`. +- **New code modules** (NPC bot worker): `src/wolfbot/npc_bot_main.py`, `src/wolfbot/services/npc_client.py`, `src/wolfbot/services/npc_speech_service.py`, `src/wolfbot/services/tts_service.py`, `src/wolfbot/services/voice_playback_service.py`. +- **New code modules** (voice ingest): `src/wolfbot/services/voice_ingest_service.py`, `src/wolfbot/services/stt_service.py`. +- **Persistence**: additive migrations in `src/wolfbot/persistence/schema.py` for `speech_events`, `npc_speak_requests`, `npc_speak_results`, `npc_playback_events`. No destructive changes. +- **Configuration**: new env vars `LLM_DISCUSSION_MODE` (Master), `MASTER_WS_LISTEN` (`127.0.0.1:`), `MASTER_NPC_PSK` (pre-shared key), plus a separate NPC-process env set (`WOLFBOT_PROCESS_ROLE`, `MASTER_WS_URL` pointing at `127.0.0.1`, `MASTER_NPC_PSK`, `NPC_ID`, `NPC_DISCORD_TOKEN`, `TTS_PROVIDER`, `TTS_VOICE_ID`). The voice-ingest process reuses Master's `MAIN_VOICE_CHANNEL_ID` and the same NPC bot identities published by Master's NPC registry — there is no separate `NPC_VOICE_CHANNEL_ID`. Existing env vars (`DISCORD_TOKEN`, `XAI_API_KEY`, `MAIN_VOICE_CHANNEL_ID`, etc.) remain. +- **Dependencies**: STT and TTS providers are pluggable. The MVP pins **Gemini API audio input for STT** and a low-cost TTS provider (e.g. Google Cloud TTS Standard voices or comparable) chosen during the design phase under a hard cost-minimization constraint. WebSocket transport reuses the existing async stack (`websockets` or equivalent). No removal of existing deps. +- **Discord application registrations**: each NPC requires a separate Discord bot user / token. Operationally non-trivial — covered in design. +- **Backwards compatibility**: default `LLM_DISCUSSION_MODE=rounds` keeps the **observable** discussion behavior (round count, length cap, public log channel posts, deadline) identical to today. The only added side-effects in `rounds` mode are internal: each utterance is also written as a `SpeechEvent` row and contributes to the in-memory `PublicDiscussionState`. `reactive_voice` is opt-in and additive. +- **Testing**: every external surface — STT, TTS, NPC ↔ Master WebSocket, Discord VC — is reached through a Protocol so tests substitute Fakes. New Fakes added: `FakeSttService`, `FakeTtsService`, `FakeNpcClient`, `FakeMasterWsServer`, `FakeVoicePlayback`. Unit and integration tests run with these Fakes only — no live STT/TTS/Discord/xAI traffic in the test suite. +- **Out of scope (future changes)**: persistent `PublicDiscussionState` snapshots, multi-guild support, multi-host deployment, automatic NPC token provisioning, alternate STT/TTS providers beyond the MVP pick, mid-game `LLM_DISCUSSION_MODE` switching, automatic text-mode fallback when an NPC bot disconnects. diff --git a/openspec/changes/add-master-npc-voice-stt/review-ledger-design.json b/openspec/changes/add-master-npc-voice-stt/review-ledger-design.json new file mode 100644 index 0000000..a404246 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/review-ledger-design.json @@ -0,0 +1,308 @@ +{ + "feature_id": "add-master-npc-voice-stt", + "phase": "design", + "current_round": 5, + "status": "has_open_high", + "max_finding_id": 16, + "findings": [ + { + "id": "R1-F01", + "severity": "high", + "category": "completeness", + "title": "Human text and public PLAYER_SPEECH flows are not fully designed or tasked", + "detail": "The design/tasks cover rounds NPC backfill, voice STT ingestion, and reactive NPC playback, but they never add the master-side integration that records human Discord text as `SpeechEvent(source=text)` or the path that emits `LogEntry(kind=\"PLAYER_SPEECH\")` and main-channel posts when a voice-STT or reactive NPC utterance is accepted. The spec requires every public utterance (human text, human voice, NPC) to create a SpeechEvent and preserve the PLAYER_SPEECH contract in both modes. Add explicit contracts and tasks for text-message ingestion plus public-log emission for STT and reactive NPC speech.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R1-F02", + "severity": "high", + "category": "feasibility", + "title": "The voice-ingest to Master control plane is too underspecified to implement serial speech safely", + "detail": "`SpeakArbiter` must suppress NPC speech after `vad_speech_started` and until `vad_speech_ended` plus final STT completion, but the only defined voice-ingest→Master contract is a finalized `SpeechEvent` POST and NPC-registry lookup. There is no explicit VAD lifecycle message path, and the design also defers key boundary decisions such as the concrete transport choice and what happens when NPC-registry state is unavailable. As written, Master cannot reliably implement `human_currently_speaking`, and voice-ingest can violate the \"never feed NPC TTS into STT\" guarantee during control-plane failures. Choose the transport in the design, add explicit VAD start/end signaling and restart semantics, and define fail-closed registry/error behavior and shared `failure_reason` ownership before implementation.", + "origin_round": 1, + "latest_round": 1, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R2-F03", + "severity": "high", + "category": "completeness", + "file": "design.md", + "title": "PlaybackAuthorized does not create an open playback record", + "detail": "The design says `npc_playback_events` is written only on `playback_finished`, `playback_failed`, or deadline timeout, yet restart recovery and serial gating both depend on knowing which playback windows are currently open. Without inserting an open playback row when `PlaybackAuthorized` is issued, Master cannot satisfy the spec's requirement to mark open playback rows failed on `master_restart`, and `request_id` joins cannot reconstruct the full lifecycle. Define an authorization-open row (or equivalent persisted state) created at authorization time and explicitly add tasks to open and close it.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R2-F04", + "severity": "medium", + "category": "completeness", + "file": "design.md", + "title": "Voice STT payloads cannot populate required audio boundary fields", + "detail": "The voice-ingest contract sends `speech_event_payload` with `confidence` and `duration_ms`, while `vad_speech_started`/`vad_speech_ended` expose only generic `ts` values. The `speech_events` schema requires `audio_start_ms` and `audio_end_ms` aligned to the actual VAD segment boundaries for successful `voice_stt` rows. As written, Master has no explicit contract for those persisted fields and would have to infer them from message timing. Add boundary timestamps (or a segment id plus explicit start/end times) to the WS messages and a task to persist them into `speech_events`.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R2-F05", + "severity": "medium", + "category": "consistency", + "file": "design.md", + "title": "silent_seats rebuild is underspecified for alive-seat filtering", + "detail": "The design states that `PublicDiscussionState` is rebuilt as a pure fold over current-phase `speech_events`, but the MVP rule defines `silent_seats` as alive seats with zero speech in the phase. Alive/dead membership is not derivable from the speech log alone, so a restart rebuild cannot distinguish a dead seat from a living silent one. Either persist a phase-start alive-seat baseline as part of the rebuild inputs, or explicitly record and justify a spec conflict; the current state contract is internally inconsistent.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R3-F06", + "severity": "medium", + "category": "consistency", + "file": "design.md", + "title": "PublicDiscussionState rebuild now depends on `seats` state instead of `speech_events` alone", + "detail": "The design resolves dead-seat filtering by seeding the fold from `alive_seat_nos` read from the `seats` table, and tasks 2.1-2.4 mirror that approach. The spec explicitly requires `PublicDiscussionState` to be rebuildable from `speech_events` alone. Persist the phase-start alive-seat baseline as phase-scoped data within the log/persistence model, or record an explicit accepted spec conflict; otherwise replay correctness depends on mutable external state.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F07", + "severity": "medium", + "category": "completeness", + "file": "tasks.md", + "title": "Restart-recovery tasks omit rejecting open speak requests", + "detail": "Task 5.3 only calls out closing open playback rows and clearing in-memory human-speaking gates. It does not explicitly include the required recovery sweep that inserts rejected `npc_speak_results` rows with `failure_reason=master_restart` for open `npc_speak_requests` lacking a result. Without that task, the restart contract and per-request audit trail are incomplete.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F08", + "severity": "medium", + "category": "completeness", + "file": "design.md", + "title": "The protocol does not expose enough TTS-stage telemetry for `discussion_phase_summary`", + "detail": "Master currently receives `playback_finished` / `playback_failed`, and task 9.3 says phase summaries are derived from `speech_events` and playback audit rows. That is not enough to populate the required `tts_success` and `tts_failed` counts separately from `playback_success` and `playback_failed`, especially when TTS succeeds but Discord playback fails or TTS fails before playback begins. Add explicit TTS-stage outcome reporting or persisted stage markers and include them in the summary task.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F09", + "severity": "medium", + "category": "completeness", + "file": "design.md", + "title": "Reactive `SpeakResult` acceptance omits the required utterance validator", + "detail": "The design specifies freshness checks for `phase_id` and `request_id`, but it does not define Master-side validation of the returned utterance before writing a `SpeechEvent` and authorizing playback. The spec requires over-length reactive utterances to be rejected with `failure_reason=utterance_too_long` and no playback authorization. Add explicit validation for the `<=80` character cap in the acceptance contract, plus tasks/tests for the rejection path.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R4-F10", + "severity": "high", + "category": "consistency", + "file": "design.md", + "title": "TTS is moved ahead of `PlaybackAuthorized`, breaking the authorization and serial-speech contract", + "detail": "The current lifecycle and tasks 5.2/7.3 have the NPC worker synthesize TTS first, report `tts_finished`/`tts_failed`, and only then wait for `PlaybackAuthorized`. The spec requires the NPC bot to call TTS only after receiving `PlaybackAuthorized`. This change is not cosmetic: because the playback window is only opened after TTS succeeds, synthesis time is no longer covered by the serial gate, so multiple NPC turns can be prepared concurrently even though speech is supposed to be strictly serialized. Keep `PlaybackAuthorized` immediately after Master accepts `SpeakResult`, then run TTS and playback under that authorization window while reporting TTS-stage telemetry from inside the authorized lifecycle.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R4-F11", + "severity": "medium", + "category": "consistency", + "file": "design.md", + "title": "The `phase_baseline` sentinel widens the public `SpeechEvent` contract without an accepted spec conflict", + "detail": "To solve replayability, the design/tasks add `source=phase_baseline` plus `alive_seat_nos_json` sentinel rows inside `speech_events`. The published spec defines `SpeechEvent` as the unified public-utterance contract with `source` values `voice_stt | text | npc_generated`, and the accepted-conflicts section does not record this new source type. As written, implementation will place non-utterance rows into the public utterance table and every downstream consumer must special-case them. Either record this as an explicit accepted spec conflict or move the alive-seat baseline into a separately documented persistence artifact that does not masquerade as a normal `SpeechEvent`.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R4-F12", + "severity": "medium", + "category": "completeness", + "file": "tasks.md", + "title": "SpeechEvent capture is scoped to `DAY_DISCUSSION` instead of all public speech phases", + "detail": "The design's human-text contract and task 3.2 explicitly capture main-channel text only during `DAY_DISCUSSION`, and the mode-plumbing tasks are likewise framed only around `DAY_DISCUSSION`. The spec, however, requires every public utterance to become a `SpeechEvent` and requires `discussion_phase_summary` at `DAY_RUNOFF_SPEECH` as well when that phase exists. Without making the shared write path and tests explicit for runoff/public-speech phases too, those utterances can fall out of `speech_events`, state rebuild, and phase summaries. Expand the tasks/contracts to all public speech phases, especially `DAY_RUNOFF_SPEECH`.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R5-F13", + "severity": "high", + "category": "consistency", + "file": "design.md", + "title": "Registry-loss behavior still feeds NPC audio into VAD/STT", + "detail": "The voice-ingest contract says that when no `registry_snapshot` has been received or the Master connection is down, the worker treats the NPC set as empty and processes all audio, relying on Master to discard NPC-originated transcripts later. That contradicts the spec's receive-boundary rule that NPC packets must be discarded before VAD/STT, and it breaks the proposal's \"never feed NPC TTS into STT\" guarantee during control-plane failures. The design needs a true fail-safe path here: either stop capture until a registry snapshot is available, retain a last-known-good NPC registry across reconnects, or block NPC playback while the registry view is unavailable. Task 6.2 should be updated to implement that behavior instead of the current empty-set fallback.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F14", + "severity": "high", + "category": "consistency", + "file": "design.md", + "title": "Voice-ingest disconnect handling breaks strict serial speech", + "detail": "The design explicitly clears all `human_currently_speaking` gates on voice-ingest disconnect and allows `SpeakArbiter` to keep scheduling NPC speech until a future `vad_speech_started` re-establishes the gate. That creates a window where a human can be speaking while Master believes the gate is clear, violating the requirement that speech remain strictly serial whenever a human utterance is open. Master needs to fail safe when capture-state visibility is lost, for example by suppressing new NPC requests while voice-ingest is disconnected or until capture readiness is re-established. Update tasks 4.4, 5.2, and 6.5 accordingly.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F15", + "severity": "high", + "category": "completeness", + "file": "design.md", + "title": "Voice-ingest has no authoritative source for game, phase, or seat context", + "detail": "The voice-ingest protocol only defines `registry_snapshot` and `registry_update`, but its outgoing messages and required structured logs include `game_id`, `phase_id`, and `seat_no`, and the worker must also know whether the current phase is a public speech phase at all. As written, voice-ingest has no contract for learning the active game, current phase, or the human `discord_user_id -> seat_no` mapping, so it cannot reliably label utterances, distinguish players from non-players, or avoid turning lobby/night chatter into `SpeechEvent`s. Add a Master→voice-ingest lifecycle/assignment contract (active-game snapshot, phase updates, seat map updates, public-speech enabled flag), or move game/phase/seat resolution entirely to Master and simplify the worker payloads and logs to match.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F16", + "severity": "medium", + "category": "completeness", + "file": "tasks.md", + "title": "`discussion_phase_summary` lacks a defined source of truth for required counters", + "detail": "Task 9.3 says the phase summary is emitted from `speech_events` and playback audit rows, but mandatory fields such as `logic_packets_built` and `stt_failed` are not stored in those tables, and the design never defines a restart-safe per-phase accumulator for them. That leaves the implementation without a reliable way to satisfy the required summary schema, especially after a mid-phase restart. The design/tasks should specify where every required counter comes from and how it survives restart boundaries, whether via a dedicated persisted phase-metrics artifact or an explicit recovery model for in-memory counters.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + } + ], + "round_summaries": [ + { + "round": 1, + "total": 2, + "open": 2, + "new": 2, + "resolved": 0, + "overridden": 0, + "by_severity": { + "high": 2 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-1" + }, + { + "round": 2, + "total": 5, + "open": 4, + "new": 3, + "resolved": 1, + "overridden": 0, + "by_severity": { + "high": 2, + "medium": 2 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-2" + }, + { + "round": 3, + "total": 9, + "open": 5, + "new": 4, + "resolved": 4, + "overridden": 0, + "by_severity": { + "high": 1, + "medium": 4 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-3" + }, + { + "round": 4, + "total": 12, + "open": 4, + "new": 3, + "resolved": 8, + "overridden": 0, + "by_severity": { + "high": 2, + "medium": 2 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-4" + }, + { + "round": 5, + "total": 16, + "open": 5, + "new": 4, + "resolved": 11, + "overridden": 0, + "by_severity": { + "high": 4, + "medium": 1 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-5" + } + ] +} diff --git a/openspec/changes/add-master-npc-voice-stt/review-ledger-design.json.bak b/openspec/changes/add-master-npc-voice-stt/review-ledger-design.json.bak new file mode 100644 index 0000000..92c0084 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/review-ledger-design.json.bak @@ -0,0 +1,307 @@ +{ + "feature_id": "add-master-npc-voice-stt", + "phase": "design", + "current_round": 5, + "status": "has_open_high", + "max_finding_id": 16, + "findings": [ + { + "id": "R1-F01", + "severity": "high", + "category": "completeness", + "title": "Human text and public PLAYER_SPEECH flows are not fully designed or tasked", + "detail": "The design/tasks cover rounds NPC backfill, voice STT ingestion, and reactive NPC playback, but they never add the master-side integration that records human Discord text as `SpeechEvent(source=text)` or the path that emits `LogEntry(kind=\"PLAYER_SPEECH\")` and main-channel posts when a voice-STT or reactive NPC utterance is accepted. The spec requires every public utterance (human text, human voice, NPC) to create a SpeechEvent and preserve the PLAYER_SPEECH contract in both modes. Add explicit contracts and tasks for text-message ingestion plus public-log emission for STT and reactive NPC speech.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R1-F02", + "severity": "high", + "category": "feasibility", + "title": "The voice-ingest to Master control plane is too underspecified to implement serial speech safely", + "detail": "`SpeakArbiter` must suppress NPC speech after `vad_speech_started` and until `vad_speech_ended` plus final STT completion, but the only defined voice-ingest→Master contract is a finalized `SpeechEvent` POST and NPC-registry lookup. There is no explicit VAD lifecycle message path, and the design also defers key boundary decisions such as the concrete transport choice and what happens when NPC-registry state is unavailable. As written, Master cannot reliably implement `human_currently_speaking`, and voice-ingest can violate the \"never feed NPC TTS into STT\" guarantee during control-plane failures. Choose the transport in the design, add explicit VAD start/end signaling and restart semantics, and define fail-closed registry/error behavior and shared `failure_reason` ownership before implementation.", + "origin_round": 1, + "latest_round": 1, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R2-F03", + "severity": "high", + "category": "completeness", + "file": "design.md", + "title": "PlaybackAuthorized does not create an open playback record", + "detail": "The design says `npc_playback_events` is written only on `playback_finished`, `playback_failed`, or deadline timeout, yet restart recovery and serial gating both depend on knowing which playback windows are currently open. Without inserting an open playback row when `PlaybackAuthorized` is issued, Master cannot satisfy the spec's requirement to mark open playback rows failed on `master_restart`, and `request_id` joins cannot reconstruct the full lifecycle. Define an authorization-open row (or equivalent persisted state) created at authorization time and explicitly add tasks to open and close it.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R2-F04", + "severity": "medium", + "category": "completeness", + "file": "design.md", + "title": "Voice STT payloads cannot populate required audio boundary fields", + "detail": "The voice-ingest contract sends `speech_event_payload` with `confidence` and `duration_ms`, while `vad_speech_started`/`vad_speech_ended` expose only generic `ts` values. The `speech_events` schema requires `audio_start_ms` and `audio_end_ms` aligned to the actual VAD segment boundaries for successful `voice_stt` rows. As written, Master has no explicit contract for those persisted fields and would have to infer them from message timing. Add boundary timestamps (or a segment id plus explicit start/end times) to the WS messages and a task to persist them into `speech_events`.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R2-F05", + "severity": "medium", + "category": "consistency", + "file": "design.md", + "title": "silent_seats rebuild is underspecified for alive-seat filtering", + "detail": "The design states that `PublicDiscussionState` is rebuilt as a pure fold over current-phase `speech_events`, but the MVP rule defines `silent_seats` as alive seats with zero speech in the phase. Alive/dead membership is not derivable from the speech log alone, so a restart rebuild cannot distinguish a dead seat from a living silent one. Either persist a phase-start alive-seat baseline as part of the rebuild inputs, or explicitly record and justify a spec conflict; the current state contract is internally inconsistent.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R3-F06", + "severity": "medium", + "category": "consistency", + "file": "design.md", + "title": "PublicDiscussionState rebuild now depends on `seats` state instead of `speech_events` alone", + "detail": "The design resolves dead-seat filtering by seeding the fold from `alive_seat_nos` read from the `seats` table, and tasks 2.1-2.4 mirror that approach. The spec explicitly requires `PublicDiscussionState` to be rebuildable from `speech_events` alone. Persist the phase-start alive-seat baseline as phase-scoped data within the log/persistence model, or record an explicit accepted spec conflict; otherwise replay correctness depends on mutable external state.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F07", + "severity": "medium", + "category": "completeness", + "file": "tasks.md", + "title": "Restart-recovery tasks omit rejecting open speak requests", + "detail": "Task 5.3 only calls out closing open playback rows and clearing in-memory human-speaking gates. It does not explicitly include the required recovery sweep that inserts rejected `npc_speak_results` rows with `failure_reason=master_restart` for open `npc_speak_requests` lacking a result. Without that task, the restart contract and per-request audit trail are incomplete.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F08", + "severity": "medium", + "category": "completeness", + "file": "design.md", + "title": "The protocol does not expose enough TTS-stage telemetry for `discussion_phase_summary`", + "detail": "Master currently receives `playback_finished` / `playback_failed`, and task 9.3 says phase summaries are derived from `speech_events` and playback audit rows. That is not enough to populate the required `tts_success` and `tts_failed` counts separately from `playback_success` and `playback_failed`, especially when TTS succeeds but Discord playback fails or TTS fails before playback begins. Add explicit TTS-stage outcome reporting or persisted stage markers and include them in the summary task.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F09", + "severity": "medium", + "category": "completeness", + "file": "design.md", + "title": "Reactive `SpeakResult` acceptance omits the required utterance validator", + "detail": "The design specifies freshness checks for `phase_id` and `request_id`, but it does not define Master-side validation of the returned utterance before writing a `SpeechEvent` and authorizing playback. The spec requires over-length reactive utterances to be rejected with `failure_reason=utterance_too_long` and no playback authorization. Add explicit validation for the `<=80` character cap in the acceptance contract, plus tasks/tests for the rejection path.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R4-F10", + "severity": "high", + "category": "consistency", + "file": "design.md", + "title": "TTS is moved ahead of `PlaybackAuthorized`, breaking the authorization and serial-speech contract", + "detail": "The current lifecycle and tasks 5.2/7.3 have the NPC worker synthesize TTS first, report `tts_finished`/`tts_failed`, and only then wait for `PlaybackAuthorized`. The spec requires the NPC bot to call TTS only after receiving `PlaybackAuthorized`. This change is not cosmetic: because the playback window is only opened after TTS succeeds, synthesis time is no longer covered by the serial gate, so multiple NPC turns can be prepared concurrently even though speech is supposed to be strictly serialized. Keep `PlaybackAuthorized` immediately after Master accepts `SpeakResult`, then run TTS and playback under that authorization window while reporting TTS-stage telemetry from inside the authorized lifecycle.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R4-F11", + "severity": "medium", + "category": "consistency", + "file": "design.md", + "title": "The `phase_baseline` sentinel widens the public `SpeechEvent` contract without an accepted spec conflict", + "detail": "To solve replayability, the design/tasks add `source=phase_baseline` plus `alive_seat_nos_json` sentinel rows inside `speech_events`. The published spec defines `SpeechEvent` as the unified public-utterance contract with `source` values `voice_stt | text | npc_generated`, and the accepted-conflicts section does not record this new source type. As written, implementation will place non-utterance rows into the public utterance table and every downstream consumer must special-case them. Either record this as an explicit accepted spec conflict or move the alive-seat baseline into a separately documented persistence artifact that does not masquerade as a normal `SpeechEvent`.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R4-F12", + "severity": "medium", + "category": "completeness", + "file": "tasks.md", + "title": "SpeechEvent capture is scoped to `DAY_DISCUSSION` instead of all public speech phases", + "detail": "The design's human-text contract and task 3.2 explicitly capture main-channel text only during `DAY_DISCUSSION`, and the mode-plumbing tasks are likewise framed only around `DAY_DISCUSSION`. The spec, however, requires every public utterance to become a `SpeechEvent` and requires `discussion_phase_summary` at `DAY_RUNOFF_SPEECH` as well when that phase exists. Without making the shared write path and tests explicit for runoff/public-speech phases too, those utterances can fall out of `speech_events`, state rebuild, and phase summaries. Expand the tasks/contracts to all public speech phases, especially `DAY_RUNOFF_SPEECH`.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R5-F13", + "severity": "high", + "category": "consistency", + "file": "design.md", + "title": "Registry-loss behavior still feeds NPC audio into VAD/STT", + "detail": "The voice-ingest contract says that when no `registry_snapshot` has been received or the Master connection is down, the worker treats the NPC set as empty and processes all audio, relying on Master to discard NPC-originated transcripts later. That contradicts the spec's receive-boundary rule that NPC packets must be discarded before VAD/STT, and it breaks the proposal's \"never feed NPC TTS into STT\" guarantee during control-plane failures. The design needs a true fail-safe path here: either stop capture until a registry snapshot is available, retain a last-known-good NPC registry across reconnects, or block NPC playback while the registry view is unavailable. Task 6.2 should be updated to implement that behavior instead of the current empty-set fallback.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F14", + "severity": "high", + "category": "consistency", + "file": "design.md", + "title": "Voice-ingest disconnect handling breaks strict serial speech", + "detail": "The design explicitly clears all `human_currently_speaking` gates on voice-ingest disconnect and allows `SpeakArbiter` to keep scheduling NPC speech until a future `vad_speech_started` re-establishes the gate. That creates a window where a human can be speaking while Master believes the gate is clear, violating the requirement that speech remain strictly serial whenever a human utterance is open. Master needs to fail safe when capture-state visibility is lost, for example by suppressing new NPC requests while voice-ingest is disconnected or until capture readiness is re-established. Update tasks 4.4, 5.2, and 6.5 accordingly.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F15", + "severity": "high", + "category": "completeness", + "file": "design.md", + "title": "Voice-ingest has no authoritative source for game, phase, or seat context", + "detail": "The voice-ingest protocol only defines `registry_snapshot` and `registry_update`, but its outgoing messages and required structured logs include `game_id`, `phase_id`, and `seat_no`, and the worker must also know whether the current phase is a public speech phase at all. As written, voice-ingest has no contract for learning the active game, current phase, or the human `discord_user_id -> seat_no` mapping, so it cannot reliably label utterances, distinguish players from non-players, or avoid turning lobby/night chatter into `SpeechEvent`s. Add a Master→voice-ingest lifecycle/assignment contract (active-game snapshot, phase updates, seat map updates, public-speech enabled flag), or move game/phase/seat resolution entirely to Master and simplify the worker payloads and logs to match.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F16", + "severity": "medium", + "category": "completeness", + "file": "tasks.md", + "title": "`discussion_phase_summary` lacks a defined source of truth for required counters", + "detail": "Task 9.3 says the phase summary is emitted from `speech_events` and playback audit rows, but mandatory fields such as `logic_packets_built` and `stt_failed` are not stored in those tables, and the design never defines a restart-safe per-phase accumulator for them. That leaves the implementation without a reliable way to satisfy the required summary schema, especially after a mid-phase restart. The design/tasks should specify where every required counter comes from and how it survives restart boundaries, whether via a dedicated persisted phase-metrics artifact or an explicit recovery model for in-memory counters.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + } + ], + "round_summaries": [ + { + "round": 1, + "total": 2, + "open": 2, + "new": 2, + "resolved": 0, + "overridden": 0, + "by_severity": { + "high": 2 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-1" + }, + { + "round": 2, + "total": 5, + "open": 4, + "new": 3, + "resolved": 1, + "overridden": 0, + "by_severity": { + "high": 2, + "medium": 2 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-2" + }, + { + "round": 3, + "total": 9, + "open": 5, + "new": 4, + "resolved": 4, + "overridden": 0, + "by_severity": { + "high": 1, + "medium": 4 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-3" + }, + { + "round": 4, + "total": 12, + "open": 4, + "new": 3, + "resolved": 8, + "overridden": 0, + "by_severity": { + "high": 2, + "medium": 2 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-4" + }, + { + "round": 5, + "total": 16, + "open": 5, + "new": 4, + "resolved": 11, + "overridden": 0, + "by_severity": { + "high": 4, + "medium": 1 + } + } + ] +} diff --git a/openspec/changes/add-master-npc-voice-stt/review-ledger.json b/openspec/changes/add-master-npc-voice-stt/review-ledger.json new file mode 100644 index 0000000..55490fc --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/review-ledger.json @@ -0,0 +1,310 @@ +{ + "feature_id": "add-master-npc-voice-stt", + "phase": "impl", + "current_round": 5, + "status": "has_open_high", + "max_finding_id": 16, + "findings": [ + { + "id": "R1-F01", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/discord_service.py", + "title": "New games never capture `LLM_DISCUSSION_MODE`", + "detail": "`/wolf create` still builds `Game(...)` without `discussion_mode=self.settings.LLM_DISCUSSION_MODE`, so every game persists the default `rounds` value. That makes `reactive_voice` unreachable through the normal app flow and violates the spec’s “mode fixed at game start” contract. Populate the field when creating the game and validate it against the allowed modes.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R1-F02", + "severity": "high", + "category": "completeness", + "file": "src/wolfbot/main.py", + "title": "The speech-event / reactive-voice pipeline is not wired into the running app", + "detail": "The boot path still starts only the legacy single-process bot: it never constructs `DiscussionService`, `MasterIngestService`, `SpeakArbiter`, or `WebsocketsMasterWsServer`, and it instantiates `LLMAdapter` without the new `discussion_service`. In production this means rounds mode does not backfill `speech_events`, `MASTER_WS_LISTEN`/`MASTER_NPC_PSK` are unused, and `reactive_voice` has no active WS/STT/TTS pipeline. Wire the new services at startup and connect the recovery and phase-end hooks before merging.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R1-F03", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/discord_service.py", + "title": "Human text discussion messages still bypass `SpeechEvent` persistence", + "detail": "`on_message()` still writes `PLAYER_SPEECH` directly to `logs_public` and returns. The spec requires every public utterance (human text, human voice, NPC) to create a `SpeechEvent` and feed `PublicDiscussionState`; with the current path, text messages never reach `speech_events`, `silent_seats`, or CO extraction. Route DAY_DISCUSSION / DAY_RUNOFF_SPEECH main-channel messages through `DiscussionService.record(make_human_text_event(...))` and seed the phase baseline once per phase.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R1-F04", + "severity": "medium", + "category": "testing", + "file": "tests/test_reactive_voice_mode.py", + "title": "Coverage misses the app-level integrations that are regressing", + "detail": "The new tests cover repo persistence and isolated helper classes, but they do not exercise `/wolf create` with `LLM_DISCUSSION_MODE=reactive_voice`, `WolfCog.on_message()` producing `speech_events`, or the main boot path wiring the new services. Those gaps are why the production regressions above still pass. Add integration tests around game creation, main-channel text ingestion, and startup wiring.", + "origin_round": 1, + "latest_round": 1, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R2-F05", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/game_service.py", + "title": "Recovery still restarts legacy discussion rounds inside reactive_voice games", + "detail": "`RecoveryService._recover_one()` always calls `resume_llm_speech_progress()`, and that helper still treats every `DAY_DISCUSSION` game as rounds-mode. If an alive LLM seat has `discussion_rounds_done < 2`, it re-dispatches `submit_llm_discussion_rounds()` with no `discussion_mode` guard. After a restart, a `reactive_voice` game will therefore spawn the legacy two-round speech batch, violating the per-game mode contract and polluting the speech-event timeline. Skip this resume path for `discussion_mode == \"reactive_voice\"` and hand recovery to the arbiter rebuild/sweep flow instead.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R2-F06", + "severity": "medium", + "category": "completeness", + "file": "pyproject.toml", + "title": "The WebSocket transport still has no declared runtime dependency", + "detail": "The codebase now imports `websockets` in `master_ws_server.py` and `voice_ingest_client.py`, but `pyproject.toml` only adds a mypy ignore and never adds `websockets` to `[project.dependencies]`. A clean `uv sync` install does not guarantee that package is present, so the WS transport cannot start reliably in production once the remaining boot wiring lands. Add an explicit runtime dependency or switch the transport to an already-declared library.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R3-F07", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/master_ws_server.py", + "title": "The WebSocket handshake rejects every connection under the pinned `websockets` version", + "detail": "`WebsocketsMasterWsServer._authenticate()` reads the query string from `ws.path`, but the branch now pins `websockets==16.0`, where `serve()` passes a `ServerConnection` that exposes the HTTP request as `ws.request`, not `ws.path`. In practice `getattr(ws, \"path\", \"\")` falls back to an empty string, so `role` and `psk` are always parsed as empty and every NPC / voice-ingest client is closed with `auth_failed`. Read the query from `ws.request.path` (or move auth into `process_request`) and add a real integration test for the live server.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F08", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/main.py", + "title": "Reactive voice/STT utterances are persisted but never mirrored to the public text channel", + "detail": "`main.py` constructs `DiscussionService(store=speech_store, log_sink=repo)` without any `message_poster`, so `DiscussionService.record()` only writes `speech_events` and `PLAYER_SPEECH` logs for `voice_stt` / `npc_generated` events. Those utterances never get posted to the main Discord channel, which breaks the stated requirement that non-text public speech remain visible to text observers. This is especially visible in `SpeakArbiter.handle_speak_result()` and `MasterIngestService.ingest_voice()`, which rely on `DiscussionService.record()` for their outward side effects. Wire a compatible public-message poster into `DiscussionService` and cover the path with a runtime test.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F09", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/master_ingest_service.py", + "title": "Voice-ingest STT events bypass canonical phase seeding and accept caller-supplied phase IDs", + "detail": "`MasterIngestService.ingest_voice()` constructs a `SpeechEvent` directly from `payload.phase_id` and calls `discussion.record(event)` without first seeding or reusing the phase baseline. A voice-only discussion phase therefore has no `phase_baseline`, so `PublicDiscussionState` rebuilds, `silent_seats`, and CO extraction all fail for the very path reactive voice depends on. Because the service also trusts the incoming `payload.phase_id` instead of recomputing the current canonical phase id, a delayed STT result from an older public phase can be written under a stale phase id if another public phase is active. Compute the current canonical phase id on Master, seed the baseline once, and record the STT event against that canonical phase only.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R4-F10", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/main.py", + "title": "Reactive voice still never drives the arbiter in production", + "detail": "The new startup wiring only instantiates `SpeakArbiter`; it never connects a real dispatch loop. `GameService`'s reactive_voice phase-enter path just logs and returns, `WolfCog.on_message()` / `MasterIngestService.ingest_voice()` only persist speech, and `MasterHandlers` in `main.py` is created without `on_speak_result`, `on_vad_started`, or `on_vad_ended` callbacks. In a live reactive_voice game no `LogicPacket`/`SpeakRequest` is ever sent, and even if an NPC replied its `speak_result` would be dropped, so NPCs remain silent. Add a real trigger on phase entry / new public speech and wire the arbiter callbacks through the WS server.", + "origin_round": 4, + "latest_round": 4, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R4-F11", + "severity": "medium", + "category": "correctness", + "file": "src/wolfbot/services/recovery_service.py", + "title": "Master restart never closes in-flight reactive_voice rows", + "detail": "`SpeakArbiter.reactive_voice_recovery_sweep()` exists, but `RecoveryService._recover_one()` still only reconciles Discord state, restarts the engine, resends DMs, and resumes rounds speech. It never sweeps `npc_speak_requests` or `npc_playback_events` for reactive_voice games, so a Master restart leaves open rows dangling instead of marking them with `failure_reason=master_restart` as the spec requires. Thread a reactive_voice recovery hook into startup recovery before the engine resumes.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R4-F12", + "severity": "medium", + "category": "completeness", + "file": "src/wolfbot/services/game_service.py", + "title": "Discussion phases still never emit the required summary event", + "detail": "The new `discussion_phase_summary` helper is only referenced by tests; there is no production call site when `DAY_DISCUSSION` or `DAY_RUNOFF_SPEECH` ends. That means the promised phase-end telemetry for both rounds and reactive_voice modes is never emitted. Invoke the summary emitter during the public-speech phase exit/advance path before transitioning away from the discussion phase.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R5-F13", + "severity": "high", + "category": "completeness", + "file": "src/wolfbot/npc_bot_main.py", + "title": "The per-NPC worker is still not runnable", + "detail": "The repository still does not provide a deployable NPC worker for the new pipeline. `npc_bot_main._main()` logs that production wiring is deferred and exits immediately, and there is no corresponding voice-ingest entrypoint in the tree. That means the Master can start its WS server, but the proposal's required worker processes cannot actually be launched from this implementation.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F14", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/main.py", + "title": "The human-speaking gate is released before STT finalization", + "detail": "`_on_vad_ended()` calls `arbiter.clear_human_speaking(...)` and immediately `try_dispatch_next(...)`. The proposal requires the human gate to stay closed until the finalized STT `SpeechEvent` arrives (or times out), so this allows an NPC to start speaking before the just-finished human utterance has been transcribed and recorded. Keep the segment blocked until `speech_event_payload`/`stt_failed` completes, then dispatch.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F15", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/speak_arbiter.py", + "title": "Playback deadlines are recorded but never enforced", + "detail": "`handle_speak_result()` stores `playback_deadline_ms` and adds the request to `_active_playback`, but nothing ever checks for an expired authorization. If an NPC disconnects after authorization and never sends `tts_failed`/`playback_finished`/`playback_failed`, the playback row stays open and `_active_playback` blocks all future NPC speech for the rest of the phase. Add a timeout sweep that closes expired playback with the spec'd failure reason and releases the gate.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F16", + "severity": "medium", + "category": "correctness", + "file": "src/wolfbot/services/recovery_service.py", + "title": "Reactive-voice recovery never re-enters the arbiter after restart", + "detail": "Recovery now sweeps in-flight rows, but after that it only starts the timer engine and calls `resume_llm_speech_progress()`, which intentionally no-ops for `reactive_voice`. There is no recovery-time call to `try_dispatch_next()` or the reactive phase-enter hook, so a game recovered mid-discussion will stay silent until a new human utterance arrives or the deadline expires. Trigger one post-recovery reactive dispatch for active public-speech phases.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + } + ], + "round_summaries": [ + { + "round": 1, + "total": 4, + "open": 4, + "new": 4, + "resolved": 0, + "overridden": 0, + "by_severity": { + "high": 3, + "medium": 1 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-1" + }, + { + "round": 2, + "total": 6, + "open": 5, + "new": 2, + "resolved": 1, + "overridden": 0, + "by_severity": { + "high": 3, + "medium": 2 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-2" + }, + { + "round": 3, + "total": 9, + "open": 5, + "new": 3, + "resolved": 4, + "overridden": 0, + "by_severity": { + "high": 4, + "medium": 1 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-3" + }, + { + "round": 4, + "total": 12, + "open": 5, + "new": 3, + "resolved": 7, + "overridden": 0, + "by_severity": { + "high": 2, + "medium": 3 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-4" + }, + { + "round": 5, + "total": 16, + "open": 6, + "new": 4, + "resolved": 10, + "overridden": 0, + "by_severity": { + "medium": 2, + "high": 4 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-5" + } + ] +} diff --git a/openspec/changes/add-master-npc-voice-stt/review-ledger.json.bak b/openspec/changes/add-master-npc-voice-stt/review-ledger.json.bak new file mode 100644 index 0000000..95bb360 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/review-ledger.json.bak @@ -0,0 +1,309 @@ +{ + "feature_id": "add-master-npc-voice-stt", + "phase": "impl", + "current_round": 5, + "status": "has_open_high", + "max_finding_id": 16, + "findings": [ + { + "id": "R1-F01", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/discord_service.py", + "title": "New games never capture `LLM_DISCUSSION_MODE`", + "detail": "`/wolf create` still builds `Game(...)` without `discussion_mode=self.settings.LLM_DISCUSSION_MODE`, so every game persists the default `rounds` value. That makes `reactive_voice` unreachable through the normal app flow and violates the spec’s “mode fixed at game start” contract. Populate the field when creating the game and validate it against the allowed modes.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R1-F02", + "severity": "high", + "category": "completeness", + "file": "src/wolfbot/main.py", + "title": "The speech-event / reactive-voice pipeline is not wired into the running app", + "detail": "The boot path still starts only the legacy single-process bot: it never constructs `DiscussionService`, `MasterIngestService`, `SpeakArbiter`, or `WebsocketsMasterWsServer`, and it instantiates `LLMAdapter` without the new `discussion_service`. In production this means rounds mode does not backfill `speech_events`, `MASTER_WS_LISTEN`/`MASTER_NPC_PSK` are unused, and `reactive_voice` has no active WS/STT/TTS pipeline. Wire the new services at startup and connect the recovery and phase-end hooks before merging.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R1-F03", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/discord_service.py", + "title": "Human text discussion messages still bypass `SpeechEvent` persistence", + "detail": "`on_message()` still writes `PLAYER_SPEECH` directly to `logs_public` and returns. The spec requires every public utterance (human text, human voice, NPC) to create a `SpeechEvent` and feed `PublicDiscussionState`; with the current path, text messages never reach `speech_events`, `silent_seats`, or CO extraction. Route DAY_DISCUSSION / DAY_RUNOFF_SPEECH main-channel messages through `DiscussionService.record(make_human_text_event(...))` and seed the phase baseline once per phase.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R1-F04", + "severity": "medium", + "category": "testing", + "file": "tests/test_reactive_voice_mode.py", + "title": "Coverage misses the app-level integrations that are regressing", + "detail": "The new tests cover repo persistence and isolated helper classes, but they do not exercise `/wolf create` with `LLM_DISCUSSION_MODE=reactive_voice`, `WolfCog.on_message()` producing `speech_events`, or the main boot path wiring the new services. Those gaps are why the production regressions above still pass. Add integration tests around game creation, main-channel text ingestion, and startup wiring.", + "origin_round": 1, + "latest_round": 1, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R2-F05", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/game_service.py", + "title": "Recovery still restarts legacy discussion rounds inside reactive_voice games", + "detail": "`RecoveryService._recover_one()` always calls `resume_llm_speech_progress()`, and that helper still treats every `DAY_DISCUSSION` game as rounds-mode. If an alive LLM seat has `discussion_rounds_done < 2`, it re-dispatches `submit_llm_discussion_rounds()` with no `discussion_mode` guard. After a restart, a `reactive_voice` game will therefore spawn the legacy two-round speech batch, violating the per-game mode contract and polluting the speech-event timeline. Skip this resume path for `discussion_mode == \"reactive_voice\"` and hand recovery to the arbiter rebuild/sweep flow instead.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R2-F06", + "severity": "medium", + "category": "completeness", + "file": "pyproject.toml", + "title": "The WebSocket transport still has no declared runtime dependency", + "detail": "The codebase now imports `websockets` in `master_ws_server.py` and `voice_ingest_client.py`, but `pyproject.toml` only adds a mypy ignore and never adds `websockets` to `[project.dependencies]`. A clean `uv sync` install does not guarantee that package is present, so the WS transport cannot start reliably in production once the remaining boot wiring lands. Add an explicit runtime dependency or switch the transport to an already-declared library.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R3-F07", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/master_ws_server.py", + "title": "The WebSocket handshake rejects every connection under the pinned `websockets` version", + "detail": "`WebsocketsMasterWsServer._authenticate()` reads the query string from `ws.path`, but the branch now pins `websockets==16.0`, where `serve()` passes a `ServerConnection` that exposes the HTTP request as `ws.request`, not `ws.path`. In practice `getattr(ws, \"path\", \"\")` falls back to an empty string, so `role` and `psk` are always parsed as empty and every NPC / voice-ingest client is closed with `auth_failed`. Read the query from `ws.request.path` (or move auth into `process_request`) and add a real integration test for the live server.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F08", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/main.py", + "title": "Reactive voice/STT utterances are persisted but never mirrored to the public text channel", + "detail": "`main.py` constructs `DiscussionService(store=speech_store, log_sink=repo)` without any `message_poster`, so `DiscussionService.record()` only writes `speech_events` and `PLAYER_SPEECH` logs for `voice_stt` / `npc_generated` events. Those utterances never get posted to the main Discord channel, which breaks the stated requirement that non-text public speech remain visible to text observers. This is especially visible in `SpeakArbiter.handle_speak_result()` and `MasterIngestService.ingest_voice()`, which rely on `DiscussionService.record()` for their outward side effects. Wire a compatible public-message poster into `DiscussionService` and cover the path with a runtime test.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F09", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/master_ingest_service.py", + "title": "Voice-ingest STT events bypass canonical phase seeding and accept caller-supplied phase IDs", + "detail": "`MasterIngestService.ingest_voice()` constructs a `SpeechEvent` directly from `payload.phase_id` and calls `discussion.record(event)` without first seeding or reusing the phase baseline. A voice-only discussion phase therefore has no `phase_baseline`, so `PublicDiscussionState` rebuilds, `silent_seats`, and CO extraction all fail for the very path reactive voice depends on. Because the service also trusts the incoming `payload.phase_id` instead of recomputing the current canonical phase id, a delayed STT result from an older public phase can be written under a stale phase id if another public phase is active. Compute the current canonical phase id on Master, seed the baseline once, and record the STT event against that canonical phase only.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R4-F10", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/main.py", + "title": "Reactive voice still never drives the arbiter in production", + "detail": "The new startup wiring only instantiates `SpeakArbiter`; it never connects a real dispatch loop. `GameService`'s reactive_voice phase-enter path just logs and returns, `WolfCog.on_message()` / `MasterIngestService.ingest_voice()` only persist speech, and `MasterHandlers` in `main.py` is created without `on_speak_result`, `on_vad_started`, or `on_vad_ended` callbacks. In a live reactive_voice game no `LogicPacket`/`SpeakRequest` is ever sent, and even if an NPC replied its `speak_result` would be dropped, so NPCs remain silent. Add a real trigger on phase entry / new public speech and wire the arbiter callbacks through the WS server.", + "origin_round": 4, + "latest_round": 4, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R4-F11", + "severity": "medium", + "category": "correctness", + "file": "src/wolfbot/services/recovery_service.py", + "title": "Master restart never closes in-flight reactive_voice rows", + "detail": "`SpeakArbiter.reactive_voice_recovery_sweep()` exists, but `RecoveryService._recover_one()` still only reconciles Discord state, restarts the engine, resends DMs, and resumes rounds speech. It never sweeps `npc_speak_requests` or `npc_playback_events` for reactive_voice games, so a Master restart leaves open rows dangling instead of marking them with `failure_reason=master_restart` as the spec requires. Thread a reactive_voice recovery hook into startup recovery before the engine resumes.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R4-F12", + "severity": "medium", + "category": "completeness", + "file": "src/wolfbot/services/game_service.py", + "title": "Discussion phases still never emit the required summary event", + "detail": "The new `discussion_phase_summary` helper is only referenced by tests; there is no production call site when `DAY_DISCUSSION` or `DAY_RUNOFF_SPEECH` ends. That means the promised phase-end telemetry for both rounds and reactive_voice modes is never emitted. Invoke the summary emitter during the public-speech phase exit/advance path before transitioning away from the discussion phase.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R5-F13", + "severity": "high", + "category": "completeness", + "file": "src/wolfbot/npc_bot_main.py", + "title": "The per-NPC worker is still not runnable", + "detail": "The repository still does not provide a deployable NPC worker for the new pipeline. `npc_bot_main._main()` logs that production wiring is deferred and exits immediately, and there is no corresponding voice-ingest entrypoint in the tree. That means the Master can start its WS server, but the proposal's required worker processes cannot actually be launched from this implementation.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F14", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/main.py", + "title": "The human-speaking gate is released before STT finalization", + "detail": "`_on_vad_ended()` calls `arbiter.clear_human_speaking(...)` and immediately `try_dispatch_next(...)`. The proposal requires the human gate to stay closed until the finalized STT `SpeechEvent` arrives (or times out), so this allows an NPC to start speaking before the just-finished human utterance has been transcribed and recorded. Keep the segment blocked until `speech_event_payload`/`stt_failed` completes, then dispatch.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F15", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/speak_arbiter.py", + "title": "Playback deadlines are recorded but never enforced", + "detail": "`handle_speak_result()` stores `playback_deadline_ms` and adds the request to `_active_playback`, but nothing ever checks for an expired authorization. If an NPC disconnects after authorization and never sends `tts_failed`/`playback_finished`/`playback_failed`, the playback row stays open and `_active_playback` blocks all future NPC speech for the rest of the phase. Add a timeout sweep that closes expired playback with the spec'd failure reason and releases the gate.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F16", + "severity": "medium", + "category": "correctness", + "file": "src/wolfbot/services/recovery_service.py", + "title": "Reactive-voice recovery never re-enters the arbiter after restart", + "detail": "Recovery now sweeps in-flight rows, but after that it only starts the timer engine and calls `resume_llm_speech_progress()`, which intentionally no-ops for `reactive_voice`. There is no recovery-time call to `try_dispatch_next()` or the reactive phase-enter hook, so a game recovered mid-discussion will stay silent until a new human utterance arrives or the deadline expires. Trigger one post-recovery reactive dispatch for active public-speech phases.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + } + ], + "round_summaries": [ + { + "round": 1, + "total": 4, + "open": 4, + "new": 4, + "resolved": 0, + "overridden": 0, + "by_severity": { + "high": 3, + "medium": 1 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-1" + }, + { + "round": 2, + "total": 6, + "open": 5, + "new": 2, + "resolved": 1, + "overridden": 0, + "by_severity": { + "high": 3, + "medium": 2 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-2" + }, + { + "round": 3, + "total": 9, + "open": 5, + "new": 3, + "resolved": 4, + "overridden": 0, + "by_severity": { + "high": 4, + "medium": 1 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-3" + }, + { + "round": 4, + "total": 12, + "open": 5, + "new": 3, + "resolved": 7, + "overridden": 0, + "by_severity": { + "high": 2, + "medium": 3 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-4" + }, + { + "round": 5, + "total": 16, + "open": 6, + "new": 4, + "resolved": 10, + "overridden": 0, + "by_severity": { + "medium": 2, + "high": 4 + } + } + ] +} diff --git a/openspec/changes/add-master-npc-voice-stt/specs/day-discussion/spec.md b/openspec/changes/add-master-npc-voice-stt/specs/day-discussion/spec.md new file mode 100644 index 0000000..c34aa27 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/specs/day-discussion/spec.md @@ -0,0 +1,56 @@ +## MODIFIED Requirements + +### Requirement: LLM seats speak twice per discussion phase + +For every `DAY_DISCUSSION` phase **when `LLM_DISCUSSION_MODE=rounds`**, each living LLM seat SHALL emit exactly two structured speech utterances. Progress for each seat MUST be tracked in the `llm_speech_counts.discussion_rounds_done` column so that an interrupted phase (process restart, deadline-driven advance, host `/wolf force-skip`) does not cause the same round to be repeated when execution resumes. **When `LLM_DISCUSSION_MODE=reactive_voice`, this fixed two-round contract does NOT apply** — speech is instead driven by `SpeakArbiter` against `PublicDiscussionState`, and the count of utterances per seat is dynamic (zero or more). + +#### Scenario: Two utterances per LLM seat in a fresh phase (rounds mode) +- **WHEN** `DAY_DISCUSSION` starts for `day_number = 1` with 4 living LLM seats and the deadline is far enough in the future, with `LLM_DISCUSSION_MODE=rounds` +- **THEN** the system schedules two utterances per LLM seat (8 total), each producing a `PLAYER_SPEECH` `LogEntry` + +#### Scenario: Resume after restart in mid-phase (rounds mode) +- **WHEN** the bot restarts during `DAY_DISCUSSION` after one round of LLM speech has already been recorded for an LLM seat under `LLM_DISCUSSION_MODE=rounds` +- **AND** that seat's `llm_speech_counts.discussion_rounds_done` is `1` +- **THEN** on resumption the system schedules only the remaining round for that seat, not both rounds + +#### Scenario: reactive_voice mode does not use llm_speech_counts.discussion_rounds_done +- **WHEN** the bot runs with `LLM_DISCUSSION_MODE=reactive_voice` +- **THEN** the speech-batching path is not invoked; `llm_speech_counts.discussion_rounds_done` is not incremented and is not used to drive scheduling + +### Requirement: Discussion mode is configurable + +The day-discussion engine SHALL accept a runtime mode setting via the `LLM_DISCUSSION_MODE` environment variable that selects between two strategies. The default mode `rounds` MUST run the existing two-round LLM batching with the existing 80–300-character utterance cap. The alternative mode `reactive_voice` MUST replace the LLM-speech batching with Master-arbitrated NPC speech driven by `PublicDiscussionState`, with a hard `max_chars=80` per utterance. **The mode is fixed for the lifetime of a single game** (no mid-game switching). Both modes MUST preserve the public-log contract — every utterance, regardless of mode, is emitted as a `PLAYER_SPEECH` `LogEntry`. Both modes MUST also write a `SpeechEvent` row for every utterance (human text, human voice via STT, NPC) and update an in-memory `PublicDiscussionState` derived from those rows. + +#### Scenario: Default mode is rounds +- **WHEN** the bot starts without `LLM_DISCUSSION_MODE` set +- **THEN** `DAY_DISCUSSION` runs the two-round LLM batching described in this spec, with the existing 80–300-character cap + +#### Scenario: reactive_voice mode replaces LLM batching +- **WHEN** the bot starts with `LLM_DISCUSSION_MODE=reactive_voice` +- **THEN** `DAY_DISCUSSION` does not invoke the round-based LLM batch; `SpeakArbiter` drives NPC speech instead, with `max_chars=80` per utterance + +#### Scenario: Both modes preserve PLAYER_SPEECH +- **WHEN** an utterance is recorded under either mode +- **THEN** a `LogEntry(kind="PLAYER_SPEECH", actor_seat=, text=)` is emitted to the public log path and posted to the main channel + +#### Scenario: Both modes write SpeechEvent +- **WHEN** an utterance is recorded under either mode +- **THEN** a `SpeechEvent` row is inserted into `speech_events` with the appropriate `source` (`text`, `voice_stt`, or `npc_generated`) and `speaker_kind` (`human` or `npc`) + +#### Scenario: Mode is fixed for a game +- **WHEN** a game starts in mode `M` and the host or operator changes the `LLM_DISCUSSION_MODE` environment variable mid-game +- **THEN** the running game continues to use mode `M`; the new value applies only to subsequent games + +## ADDED Requirements + +### Requirement: Discussion phase end emits a discussion_phase_summary log event + +At the end of every `DAY_DISCUSSION` phase (and `DAY_RUNOFF_SPEECH` if applicable), regardless of `LLM_DISCUSSION_MODE`, the system SHALL emit a structured log event with `event=discussion_phase_summary` summarizing what happened during that phase. The event MUST include at least: `game_id`, `phase_id`, `mode`, `speech_events_total`, `human_speech_events`, `npc_speech_events`, and (when applicable) `stt_success`, `stt_failed`, `logic_packets_built`, `speak_requests_sent`, `speak_results_accepted`, `speak_results_rejected`, `playback_authorized`, `tts_success`, `tts_failed`, `playback_success`, `playback_failed`, `stale_dropped`. + +#### Scenario: rounds mode emits a summary +- **WHEN** a `DAY_DISCUSSION` phase ends under `LLM_DISCUSSION_MODE=rounds` +- **THEN** a structured log event with `event=discussion_phase_summary`, `mode=rounds`, the count of human and NPC `SpeechEvent` rows for the phase, and zeros for the reactive-voice fields is emitted + +#### Scenario: reactive_voice mode emits a richer summary +- **WHEN** a `DAY_DISCUSSION` phase ends under `LLM_DISCUSSION_MODE=reactive_voice` +- **THEN** the summary log event includes the full reactive-voice telemetry (logic packets, speak requests/results, playback outcomes, stale drops) along with the speech counts diff --git a/openspec/changes/add-master-npc-voice-stt/specs/discord-integration/spec.md b/openspec/changes/add-master-npc-voice-stt/specs/discord-integration/spec.md new file mode 100644 index 0000000..b0890ee --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/specs/discord-integration/spec.md @@ -0,0 +1,67 @@ +## MODIFIED Requirements + +### Requirement: /wolf slash commands are registered to a single guild + +The Master bot SHALL register the `/wolf` command set (`join`, `leave`, `start`, `abort`, `force-skip`, `extend`) inside a single Discord guild whose id is read from the `DISCORD_GUILD_ID` setting. Host-only commands (`start`, `abort`, `force-skip`, `extend`) MUST verify that the invoking user is the recorded game host before performing any state mutation. **NPC bot workers and the voice-ingest worker run as separate Discord bot identities in the same guild and SHALL NOT register any slash commands** — only the Master bot owns the `/wolf` command surface. NPC bots and voice-ingest authenticate to Discord with their own tokens but do not expose any user-facing commands. + +#### Scenario: Non-host abort attempt is rejected +- **WHEN** a user who is not the host invokes `/wolf abort` on an active game +- **THEN** the bot replies ephemerally with a permission error and does not call `GameService.host_abort` + +#### Scenario: Host abort tears down only on first call +- **WHEN** the host invokes `/wolf abort` while the game is still active +- **THEN** `GameService.host_abort` returns `True`, the cog detaches and stops the `GameEngine`, and posts the public `強制終了` message + +#### Scenario: Host abort on already-ended game is a no-op +- **WHEN** the host invokes `/wolf abort` on a game whose `ended_at` is non-null +- **THEN** `GameService.host_abort` returns `False`, the cog replies ephemerally, and no public message is posted and no engine teardown runs + +#### Scenario: NPC bots register no slash commands +- **WHEN** a `wolfbot-npc` process connects to Discord with `NPC_DISCORD_TOKEN` +- **THEN** that bot does not register any `/wolf` (or other) application commands; only the Master bot owns the slash-command surface for the guild + +## ADDED Requirements + +### Requirement: NPC bots and voice-ingest share the main voice channel + +When `LLM_DISCUSSION_MODE=reactive_voice` is active for a game, every NPC bot worker AND the voice-ingest worker SHALL join the Discord voice channel identified by `MAIN_VOICE_CHANNEL_ID` (the same channel humans speak in). NPC bots act as audio sources only (TTS playback). voice-ingest acts as a listener only (no playback). There is no per-game voice channel allocation in the MVP and no separate `NPC_VOICE_CHANNEL_ID`. + +#### Scenario: All NPC bots and voice-ingest join the same VC +- **WHEN** a reactive_voice game starts with three NPC seats and voice-ingest is enabled +- **THEN** the three `wolfbot-npc` processes and the voice-ingest worker all join the channel referenced by `MAIN_VOICE_CHANNEL_ID` + +#### Scenario: voice-ingest does not play audio +- **WHEN** the voice-ingest worker is present in `MAIN_VOICE_CHANNEL_ID` +- **THEN** it never invokes any voice-send / playback API; it only consumes incoming audio packets + +### Requirement: NPC bots play VC audio only after Master authorization + +NPC bot workers MUST gate all VC audio output on having received a valid `PlaybackAuthorized` from Master for a specific `request_id`. NPC bots MUST NOT play any audio they generate independently (e.g. test playback, ambient effects, retries of a rejected utterance) in the live VC. + +#### Scenario: Unauthorized playback is suppressed +- **WHEN** an NPC bot has a finished Grok response and finished TTS audio cached but has not received `PlaybackAuthorized` for that `request_id` +- **THEN** the NPC bot does not play the audio in `MAIN_VOICE_CHANNEL_ID` + +#### Scenario: Authorized playback proceeds +- **WHEN** an NPC bot receives `PlaybackAuthorized` for `request_id=sr_01HX` +- **THEN** the NPC bot plays the corresponding TTS audio in `MAIN_VOICE_CHANNEL_ID` and emits `playback_finished` upon completion + +### Requirement: NPC bots stay silent when no game is active for them + +NPC bot workers MUST remain silent (no VC audio, no Discord text post, no DM) when no game has assigned them a seat or when the assigned game's phase is not one in which `SpeakArbiter` could legitimately request speech (e.g. `LOBBY`, `SETUP`, `NIGHT_0`, `NIGHT`, `DAY_VOTE`, `DAY_RUNOFF`, `WAITING_HOST_DECISION`, `GAME_OVER`). + +#### Scenario: Idle NPC bot is silent +- **WHEN** no game is assigned to NPC `npc_p5`, or the assigned game is in `LOBBY` +- **THEN** the NPC bot makes no Discord-side action — no VC playback, no message in any channel + +#### Scenario: NPC bot is silent during NIGHT +- **WHEN** the assigned game enters `NIGHT` +- **THEN** the NPC bot performs no VC playback even if it receives a stale `SpeakRequest`; the Master arbiter would in any case not issue one + +### Requirement: voice-ingest excludes NPC bot identities by registry lookup + +The voice-ingest worker SHALL fetch the current NPC bot `discord_bot_user_id` set from the Master NPC registry and discard any incoming voice packet whose `user_id` is in that set, before VAD or STT runs (see voice-ingest spec for full rules). This requirement is reflected in the discord-integration capability because it is the boundary at which Discord-supplied user identity becomes the authoritative filter for "is this a human". + +#### Scenario: NPC TTS audio is filtered out by user_id +- **WHEN** an NPC bot plays TTS in `MAIN_VOICE_CHANNEL_ID` and Discord routes the corresponding voice packets to voice-ingest +- **THEN** voice-ingest discards those packets at the receive boundary and does not run them through VAD or STT diff --git a/openspec/changes/add-master-npc-voice-stt/specs/llm-seats/spec.md b/openspec/changes/add-master-npc-voice-stt/specs/llm-seats/spec.md new file mode 100644 index 0000000..149824a --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/specs/llm-seats/spec.md @@ -0,0 +1,47 @@ +## MODIFIED Requirements + +### Requirement: Utterances respect length, language, and content invariants + +Every LLM-generated utterance SHALL be Japanese, SHALL NOT quote original Gnosia dialogue verbatim, and SHALL NOT contain meta-commentary about being an AI or about the input being structured data. Length is constrained per `LLM_DISCUSSION_MODE`: under `rounds`, utterances MUST be between 80 and 300 characters inclusive; under `reactive_voice`, utterances MUST be **80 characters or fewer** (and MAY be shorter, including a single short interjection of a handful of characters). + +#### Scenario: Over-length utterance under rounds mode is rejected +- **WHEN** an LLM produces an utterance longer than 300 characters under `LLM_DISCUSSION_MODE=rounds` +- **THEN** the system prompt's hard rules cause the model to self-correct on retry, and a non-conforming response is logged and dropped rather than published as `PLAYER_SPEECH` + +#### Scenario: Over-length utterance under reactive_voice mode is rejected +- **WHEN** an LLM produces an utterance longer than 80 characters under `LLM_DISCUSSION_MODE=reactive_voice` +- **THEN** the SpeakArbiter rejects the corresponding `SpeakResult` with `failure_reason=utterance_too_long`, no `SpeechEvent` is written, and the NPC bot receives `playback_rejected` + +#### Scenario: Short reactive interjection is allowed under reactive_voice mode +- **WHEN** an LLM produces a 12-character utterance such as `「うーん…怪しいかも」` under `LLM_DISCUSSION_MODE=reactive_voice` +- **THEN** the utterance is accepted, recorded as `SpeechEvent(source=npc_generated)`, and authorized for playback + +#### Scenario: Original Gnosia dialogue is forbidden in any mode +- **WHEN** the prompt builder renders any persona's prompt under any `LLM_DISCUSSION_MODE` +- **THEN** the rendered prompt explicitly forbids quoting original Gnosia dialogue and requires personality imitation by tone alone + +## ADDED Requirements + +### Requirement: NPC bot worker process consumes LogicPacket instead of full game snapshot + +When `LLM_DISCUSSION_MODE=reactive_voice`, each NPC seat SHALL be served by a separate `wolfbot-npc` worker process which receives **only** Master-published `LogicPacket` payloads for day-discussion utterances — never the full game snapshot, never other NPCs' private state, never the master role assignments. The NPC bot SHALL combine the `LogicPacket` with its own persona, role, and private state to compose the Grok prompt. The persona registry, the prompt-builder pipeline, the role-strategy isolation rules, the structured-output `LLMAction` schema, and the seat-token resolver MUST be reused unchanged. + +#### Scenario: NPC bot does not see other NPCs' private state +- **WHEN** Master sends a `LogicPacket` to NPC `npc_p5` +- **THEN** the payload contains no role assignments, no private state, no LLM-context for any seat other than `npc_p5`'s own + +#### Scenario: Persona registry is shared with NPC bot processes +- **WHEN** an NPC bot worker starts with `NPC_ID=npc_p5` +- **THEN** the NPC bot loads the persona for `npc_p5` from the same `wolfbot.llm.personas.PERSONAS_BY_KEY` registry the Master process uses + +#### Scenario: Prompt-builder isolation is preserved across processes +- **WHEN** an NPC bot worker for a seat whose role is `villager` composes its system prompt +- **THEN** the rendered prompt contains the villager strategy block and does NOT contain any wolf, madman, or other-role strategy text + +### Requirement: rounds-mode behavior remains in-process + +Under `LLM_DISCUSSION_MODE=rounds`, LLM seats SHALL continue to be served by the in-process `LLMAdapter` exactly as today: structured-output requests issued from the Master process, results consumed by the existing `submit_llm_*` flows, no separate `wolfbot-npc` worker required. The new NPC-bot worker MUST NOT be required to be running for `rounds`-mode games. + +#### Scenario: rounds-mode game runs without NPC bot worker +- **WHEN** a game starts under `LLM_DISCUSSION_MODE=rounds` while no `wolfbot-npc` workers are connected +- **THEN** the game runs end-to-end: `submit_llm_discussion_rounds`, `submit_llm_votes`, `submit_llm_night_actions`, and the runoff-speech path all operate via the in-process `LLMAdapter` and produce the same observable behavior as today diff --git a/openspec/changes/add-master-npc-voice-stt/specs/npc-voice-pipeline/spec.md b/openspec/changes/add-master-npc-voice-stt/specs/npc-voice-pipeline/spec.md new file mode 100644 index 0000000..fa9a6d5 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/specs/npc-voice-pipeline/spec.md @@ -0,0 +1,145 @@ +## ADDED Requirements + +### Requirement: Each NPC runs as its own Discord bot process + +Each NPC seat in `LLM_DISCUSSION_MODE=reactive_voice` SHALL be served by a separate `wolfbot-npc` worker process with its own Discord bot user, its own `NPC_DISCORD_TOKEN`, its own `NPC_ID`, its own `TTS_VOICE_ID`, and its own WebSocket connection to Master. The Master process MUST NOT play TTS audio itself or impersonate any NPC's Discord identity. + +#### Scenario: NPC bot uses a distinct Discord identity +- **WHEN** an NPC bot connects to Discord with token `NPC_DISCORD_TOKEN` +- **THEN** the resulting `discord_bot_user_id` is distinct from the Master bot's user id and from every other NPC bot's user id + +#### Scenario: Master never plays NPC audio +- **WHEN** Master is asked to coordinate NPC speech in any phase +- **THEN** Master never directly invokes a TTS provider or plays audio in the voice channel; only the NPC bot for the speaking seat does + +### Requirement: Master <-> NPC transport is localhost WebSocket with pre-shared key + +The MVP Master ↔ NPC transport SHALL be a WebSocket bound to `127.0.0.1` on Master and connected from each NPC bot worker on the same host. Authentication MUST use a single pre-shared key carried in `MASTER_NPC_PSK` (read by Master and by every NPC bot). The protocol MUST refuse any connection that does not present the correct key. Multi-host deployment, TLS, and per-NPC rotating tokens are out of scope for the MVP. + +#### Scenario: Master rejects a connection with a wrong PSK +- **WHEN** an NPC bot attempts to connect with an incorrect `MASTER_NPC_PSK` +- **THEN** Master closes the connection immediately and emits a structured log event with `event=npc_register_rejected` and `failure_reason=invalid_psk` + +#### Scenario: Master accepts a localhost connection with the correct PSK +- **WHEN** an NPC bot connects from `127.0.0.1` with the correct `MASTER_NPC_PSK` +- **THEN** Master accepts the connection, runs the registration handshake below, and replies with `npc_registered` + +### Requirement: NPC registration produces a typed handshake + +On connect, an NPC bot SHALL send a typed `npc_register` message containing `npc_id`, `discord_bot_user_id`, `supported_voices` (a list of `voice_id`s), and `version`. Master SHALL respond with `npc_registered` containing `assigned_seat`, `game_id`, and `phase_id`, after which the NPC is considered live in the registry. NPC bots MUST send `heartbeat` messages at a configurable interval; Master MUST treat an NPC as offline if no heartbeat arrives within a configurable timeout. + +#### Scenario: NPC registers and is bound to a seat +- **WHEN** an NPC bot sends `npc_register` for `npc_id=npc_p5` while seat 5 of game `g_123` is in setup or active +- **THEN** Master maps `npc_p5` to seat 5 of `g_123` in its in-memory NPC registry and responds with `npc_registered` carrying `assigned_seat=5` + +#### Scenario: Heartbeat timeout marks an NPC offline +- **WHEN** Master receives no heartbeat from an NPC for longer than the configured timeout +- **THEN** the NPC is marked offline in the registry and any subsequent `SpeakArbiter` evaluation skips it for the affected phase + +### Requirement: Master-side speak arbitration drives the reactive_voice flow + +When `LLM_DISCUSSION_MODE=reactive_voice`, Master SHALL implement `MasterLogicBuilder` and `SpeakArbiter`. `MasterLogicBuilder` reads `PublicDiscussionState` and produces per-NPC `LogicPacket` payloads with `packet_id`, `phase_id`, `recipient_npc_id`, a textual `public_state_summary`, a list of `logic_candidates` (each with `id`, `claim`, `support[]`, `counter[]`), per-seat `pressure` weights, and `expires_at_ms`. `SpeakArbiter` MUST decide which NPC should speak next, dispatch a typed `SpeakRequest` over the WebSocket carrying `request_id`, `phase_id`, `npc_id`, `seat_no`, `logic_packet_id`, `suggested_intent`, `max_chars=80`, `max_duration_ms`, `priority`, and `expires_at_ms`, and persist a row in `npc_speak_requests`. + +#### Scenario: SpeakRequest is sent only to alive NPCs +- **WHEN** `SpeakArbiter` selects a candidate NPC who is alive and online +- **THEN** Master sends a `SpeakRequest` over the WebSocket and inserts a `npc_speak_requests` row with the same `request_id` and `phase_id` + +#### Scenario: Dead or offline NPC is not sent SpeakRequest +- **WHEN** `SpeakArbiter` evaluates a candidate NPC seat that is dead or whose WebSocket is offline +- **THEN** no `SpeakRequest` is dispatched and no `npc_speak_requests` row is created for that turn + +### Requirement: Speech is strictly serial inside a game + +While a human speaker has an open utterance (after `vad_speech_started` and before either `vad_speech_ended` AND a finalized `SpeechEvent(source=voice_stt)` arrives, or a configurable timeout elapses) OR another NPC's `PlaybackAuthorized` window is still open, `SpeakArbiter` SHALL NOT issue a new `SpeakRequest`. If a `SpeakRequest` would be appropriate but blocked, the arbiter MUST emit a structured log event `speak_request_suppressed` with `failure_reason=human_currently_speaking` or `failure_reason=queue_busy`. + +#### Scenario: NPC speech blocked while human is speaking +- **WHEN** voice-ingest reports `vad_speech_started` for seat 3 and `vad_speech_ended` has not arrived +- **THEN** `SpeakArbiter` emits no `SpeakRequest` for any NPC and logs `speak_request_suppressed` with `failure_reason=human_currently_speaking` if a candidate was otherwise eligible + +#### Scenario: NPC speech blocked while another NPC plays back +- **WHEN** an NPC's `PlaybackAuthorized` window is open and the arbiter evaluates a different NPC candidate +- **THEN** no `SpeakRequest` is sent to the second NPC; `speak_request_suppressed` with `failure_reason=queue_busy` is logged + +### Requirement: NPC bot generates a short utterance via Grok and returns SpeakResult + +On receipt of a `SpeakRequest`, the NPC bot SHALL build a Grok prompt by combining its own persona / role / private state with the `LogicPacket` referenced by `logic_packet_id`, call the existing xAI structured-output endpoint, and return a typed `SpeakResult` to Master containing `request_id`, `npc_id`, `phase_id`, `status` (`accepted | declined | error`), `text` (≤80 characters when `status=accepted`), `used_logic_ids`, `intent`, and `estimated_duration_ms`. NPC bots MUST NOT play audio before sending `SpeakResult`. + +#### Scenario: NPC produces an accepted SpeakResult under 80 characters +- **WHEN** an NPC bot receives a `SpeakRequest` and Grok returns a valid utterance +- **THEN** the NPC bot sends `SpeakResult` with `status=accepted`, `text` not exceeding 80 characters, `used_logic_ids` referencing one or more entries from the matching `LogicPacket`, and an `estimated_duration_ms` + +#### Scenario: NPC bot does not pre-play audio +- **WHEN** an NPC bot has a finished Grok response but has not yet received `PlaybackAuthorized` +- **THEN** no audio is queued or played; the NPC bot only emits `SpeakResult` and waits + +### Requirement: Master validates SpeakResult against current phase + +When `SpeakResult` arrives, Master SHALL validate `phase_id` against the current `phase_id` of the game and `request_id` against an open `npc_speak_requests` row whose `expires_at_ms` has not yet passed. On validation success, Master inserts a row in `npc_speak_results` with `status=accepted`, writes a `SpeechEvent(source=npc_generated, speaker_kind=npc)` for the seat, and dispatches `PlaybackAuthorized` over the WebSocket. On validation failure (stale phase, expired request, unknown request_id), Master writes the result row with `status=rejected` and an explicit `failure_reason` (`stale_phase`, `expired_request`, or `unknown_request`), emits a structured log, and returns a `playback_rejected` message to the NPC bot. + +#### Scenario: Stale phase result is rejected +- **WHEN** an NPC's `SpeakResult` arrives carrying `phase_id=day1_discussion_001` after Master has already advanced to `day1_vote_001` +- **THEN** the result is recorded with `status=rejected`, `failure_reason=stale_phase`; no `SpeechEvent` is written; the NPC bot receives `playback_rejected` + +#### Scenario: Accepted SpeakResult yields PlaybackAuthorized +- **WHEN** Master accepts a `SpeakResult` whose `phase_id` matches and whose `request_id` is open and unexpired +- **THEN** Master writes the corresponding `npc_speak_results` row with `status=accepted`, inserts the matching `SpeechEvent(source=npc_generated)`, and sends `PlaybackAuthorized` to the NPC bot with `speech_event_id` and `playback_deadline_ms` + +### Requirement: NPC bot plays TTS only after PlaybackAuthorized + +NPC bots MUST gate VC playback on receiving a `PlaybackAuthorized` for a specific `request_id`. On receipt, the NPC bot calls its TTS provider with the accepted `text` and the configured `TTS_VOICE_ID`, then plays the resulting audio in `MAIN_VOICE_CHANNEL_ID`. Once playback finishes, the NPC bot SHALL send a typed `playback_finished` message to Master. NPC bots MUST NOT play any audio for which they did not receive a matching `PlaybackAuthorized`. + +#### Scenario: Playback occurs only post-authorization +- **WHEN** an NPC bot receives `PlaybackAuthorized` for `request_id=sr_01HX` +- **THEN** the NPC bot runs TTS for the corresponding `text`, plays the audio in the voice channel, and emits `playback_finished` after the audio completes + +#### Scenario: Without authorization, no playback occurs +- **WHEN** an NPC bot has sent `SpeakResult` and either receives `playback_rejected` or no `PlaybackAuthorized` arrives within the configured timeout +- **THEN** the NPC bot performs no TTS call and plays no audio for that `request_id` + +### Requirement: PlaybackAuthorized window closes on playback_finished or deadline + +A `PlaybackAuthorized` window SHALL be closed on Master when **either** the matching `playback_finished` message arrives over the WebSocket OR Master's clock passes `playback_deadline_ms` for that request — whichever comes first. Deadline-driven closures MUST be logged with `failure_reason=tts_timeout` (no playback_finished received and no playback_failed reported) or `failure_reason=discord_playback_error` (playback_failed reported but window already deadline-closed). Once the window closes, `SpeakArbiter` is unblocked from issuing the next `SpeakRequest`. + +#### Scenario: Window closes on playback_finished +- **WHEN** Master receives a `playback_finished` for an open authorization window +- **THEN** Master records a row in `npc_playback_events` with `status=succeeded` and `finished_at` set, and the serial-speech gate is released + +#### Scenario: Window closes on deadline timeout +- **WHEN** Master's clock passes `playback_deadline_ms` for an open authorization window without a `playback_finished` arriving +- **THEN** Master writes `npc_playback_events` with `status=failed` and `failure_reason=tts_timeout`, releases the serial-speech gate, and emits a structured log + +### Requirement: NPC disconnection causes that NPC to silently sit out the round + +If `SpeakArbiter` evaluates a candidate NPC for whom no recent heartbeat exists or whose WebSocket is offline, that NPC SHALL silently stay out of the round; the game continues, votes and night actions still resolve through the existing DM flow. There MUST be no automatic fallback to text-mode speech for that NPC during the affected phase. The skip MUST be logged as `speak_candidate_skipped` with `failure_reason=npc_offline`. + +#### Scenario: Offline NPC is skipped +- **WHEN** `SpeakArbiter` evaluates seat 5 and seat 5's NPC bot has not heartbeat in N seconds +- **THEN** no `SpeakRequest` is sent to seat 5 and a structured log event `speak_candidate_skipped` with `failure_reason=npc_offline` is recorded + +### Requirement: Restart drops in-flight requests and authorizations + +If Master, voice-ingest, or any NPC bot restarts during `DAY_DISCUSSION`, in-flight protocol state at that process boundary SHALL be dropped. On Master restart, every open `npc_speak_requests` row whose `npc_speak_results` is missing MUST be marked rejected with `failure_reason=master_restart`, and every open `npc_playback_events` row without a paired `playback_finished` MUST be marked failed with `failure_reason=master_restart`. NPC bot restart and voice-ingest restart MUST emit structured-log events (`npc_restart`, `voice_ingest_restart`). On Master restart, `PublicDiscussionState` is rebuilt from `speech_events` for the active phase. The existing `RecoveryService` rule (`deadline_epoch < now` parks in `WAITING_HOST_DECISION`) MUST be preserved. + +#### Scenario: Master restart marks open requests rejected +- **WHEN** Master restarts mid-phase with two open `SpeakRequest`s outstanding +- **THEN** the recovery flow inserts `npc_speak_results` rows with `status=rejected`, `failure_reason=master_restart` for both, and Master rebuilds `PublicDiscussionState` from `speech_events` before resuming `SpeakArbiter` + +### Requirement: Required tables exist with idempotent migrations + +The following tables SHALL be created via additive `CREATE TABLE IF NOT EXISTS` migrations: `npc_speak_requests`, `npc_speak_results`, `npc_playback_events`. Each MUST carry the fields necessary to reconstruct a per-request audit trail (request id, phase id, npc id, seat, status, failure_reason, timestamps). + +#### Scenario: Migrations are idempotent on existing databases +- **WHEN** the bot starts against a database that already has these tables +- **THEN** `migrate()` issues no DDL changes for them + +#### Scenario: Audit trail is queryable by request_id +- **WHEN** a single `request_id` is queried across `npc_speak_requests`, `npc_speak_results`, and `npc_playback_events` +- **THEN** the join produces a complete lifecycle record (sent → result accepted/rejected → playback succeeded/failed/timeout) for that request + +### Requirement: All external surfaces are testable via Protocol Fakes + +NPC bots, the Master ↔ NPC transport, the TTS provider, the Discord VC playback, and the per-NPC Grok client SHALL each be reached through a Protocol so tests substitute Fakes. New testing utilities MUST include `FakeNpcClient`, `FakeMasterWsServer`, `FakeTtsService`, and `FakeVoicePlayback`. No test in the suite may make real Discord, xAI, Gemini, or TTS-provider calls. + +#### Scenario: Reactive-voice flow is tested with Fakes only +- **WHEN** a unit test exercises the full SpeakArbiter → SpeakRequest → SpeakResult → PlaybackAuthorized → playback_finished cycle +- **THEN** it can do so by composing the Master service, an in-memory `FakeMasterWsServer`, a `FakeNpcClient`, a `FakeTtsService`, and a `FakeVoicePlayback`, without touching any real network diff --git a/openspec/changes/add-master-npc-voice-stt/specs/speech-event-bus/spec.md b/openspec/changes/add-master-npc-voice-stt/specs/speech-event-bus/spec.md new file mode 100644 index 0000000..179df63 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/specs/speech-event-bus/spec.md @@ -0,0 +1,65 @@ +## ADDED Requirements + +### Requirement: SpeechEvent is the unified public-utterance contract + +The system SHALL record every public utterance — human voice (post-STT), human Discord text, and NPC-generated text — as a `SpeechEvent` row in the `speech_events` table with the same shape regardless of origin. The schema MUST include `event_id` (ULID), `game_id`, `phase_id`, `day`, `phase`, `source` (`voice_stt | text | npc_generated`), `speaker_kind` (`human | npc`), `speaker_seat`, `text`, `stt_confidence` (nullable), `audio_start_ms` (nullable), `audio_end_ms` (nullable), and `created_at_ms`. Both `LLM_DISCUSSION_MODE` modes MUST write `SpeechEvent` rows for every utterance they produce — `rounds` mode writes one `SpeechEvent(source=npc_generated)` per LLM speech in addition to the existing `PLAYER_SPEECH` log entry. + +#### Scenario: Human text utterance is recorded +- **WHEN** a human player sends a Discord main-channel message during `DAY_DISCUSSION` +- **THEN** a `SpeechEvent` is inserted with `source=text`, `speaker_kind=human`, the player's `seat_no`, the message text, and `stt_confidence`/`audio_*` fields all `null` + +#### Scenario: NPC utterance under rounds mode is recorded +- **WHEN** the bot is running with `LLM_DISCUSSION_MODE=rounds` and an LLM seat's discussion task posts a `PLAYER_SPEECH` log entry +- **THEN** the same utterance is also recorded as `SpeechEvent(source=npc_generated, speaker_kind=npc)` with the same `seat_no` and `text` and matching `phase_id` + +#### Scenario: NPC utterance under reactive_voice mode is recorded +- **WHEN** the bot is running with `LLM_DISCUSSION_MODE=reactive_voice` and an NPC bot's `SpeakResult` is accepted by the Master arbiter +- **THEN** a `SpeechEvent(source=npc_generated, speaker_kind=npc)` is inserted before `PlaybackAuthorized` is dispatched + +### Requirement: Human voice produces exactly one finalized SpeechEvent + +For human voice input, the system SHALL emit exactly one `SpeechEvent(source=voice_stt)` per utterance, written **after STT completes**. The upstream `vad_speech_started` and `vad_speech_ended` events MUST NOT be persisted as `SpeechEvent` rows; they SHALL only appear as structured-log events on the voice-ingest worker. STT failures and below-threshold confidences MUST NOT produce a `SpeechEvent` (see voice-ingest spec for the failure contract). + +#### Scenario: Successful STT writes one SpeechEvent +- **WHEN** voice-ingest receives a complete human VAD segment, runs STT successfully, and the result's `stt_confidence` is at or above the configured threshold +- **THEN** exactly one `SpeechEvent(source=voice_stt, speaker_kind=human)` is inserted with the transcribed `text`, the `stt_confidence`, the `audio_start_ms` / `audio_end_ms` aligned with VAD boundaries, and `created_at_ms = STT completion time` + +#### Scenario: VAD events are not stored as SpeechEvent +- **WHEN** voice-ingest detects `vad_speech_started` and later `vad_speech_ended` for a human segment +- **THEN** the `speech_events` table receives no row from those VAD events — only structured-log entries are emitted + +### Requirement: PublicDiscussionState is derived deterministically from SpeechEvent history + +The Master SHALL maintain an in-memory `PublicDiscussionState` value object per active game that is **derived deterministically** from the `speech_events` rows for the current `phase_id`. The state MUST contain `co_claims`, `stances`, `pressure`, `open_topics`, `silent_seats`, and `recent_speech_event_ids`. The derivation MUST never rely on LLM output — pure code-side computation only. The state MUST be rebuildable from `speech_events` alone (no external state); on Master restart, the state for the current phase SHALL be reconstructed from the persisted rows. + +#### Scenario: State rebuilds from log on Master restart +- **WHEN** Master restarts during an active `DAY_DISCUSSION` phase +- **THEN** Master rebuilds `PublicDiscussionState` for the current `phase_id` by re-reading `speech_events` rows whose `phase_id` matches and replaying them through the apply function, producing the same value object that would have been held in memory before restart + +#### Scenario: Derivation is pure +- **WHEN** the same sequence of `SpeechEvent` rows is applied twice from a fresh empty state +- **THEN** the two resulting `PublicDiscussionState` values are bitwise equal + +### Requirement: MVP derivation rules cover co_claims and silent_seats + +For the MVP, `PublicDiscussionState` derivation SHALL implement at minimum two deterministic rules: (1) `co_claims` adds an entry whenever a `SpeechEvent.text` contains a canonical CO token (`占いCO`, `霊媒CO`, `騎士CO`, or `〇〇CO` for any role keyword) attributed to the speaker's seat; (2) `silent_seats` is the set of alive seats with zero `SpeechEvent` rows in the current `phase_id`. The other fields (`stances`, `pressure`, `open_topics`) MAY remain empty in the MVP — their concrete heuristics are decided in the design phase and refined post-MVP. + +#### Scenario: Seer CO is detected +- **WHEN** seat 3's `SpeechEvent.text` contains `占いCO` for the first time in `DAY_DISCUSSION` +- **THEN** `PublicDiscussionState.co_claims` for the current phase contains an entry `{seat: 3, role_claim: "seer", ...}` + +#### Scenario: Silent seats are tracked +- **WHEN** `DAY_DISCUSSION` is 60 seconds in and seats 6 and 7 have no `SpeechEvent` rows for the current `phase_id` +- **THEN** `PublicDiscussionState.silent_seats` is the unordered set `{6, 7}` (assuming both are alive) + +### Requirement: speech_events table is additive and forward-compatible + +The `speech_events` table SHALL be created via `CREATE TABLE IF NOT EXISTS` in the existing `migrate()` flow. The DDL MUST be idempotent so that existing databases upgrade cleanly on restart. Adding new columns to the table MUST follow the existing additive pattern (`ALTER TABLE ADD COLUMN` guarded by `PRAGMA table_info`); destructive changes are out of scope. + +#### Scenario: Migration runs on every boot +- **WHEN** the bot starts against a database that already has the `speech_events` table +- **THEN** `migrate()` issues no DDL changes for that table because the `IF NOT EXISTS` guard short-circuits + +#### Scenario: Migration creates the table on a fresh database +- **WHEN** the bot starts against a database without the `speech_events` table +- **THEN** `migrate()` creates it with the full MVP schema and the bot proceeds to start diff --git a/openspec/changes/add-master-npc-voice-stt/specs/voice-ingest/spec.md b/openspec/changes/add-master-npc-voice-stt/specs/voice-ingest/spec.md new file mode 100644 index 0000000..a9129ba --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/specs/voice-ingest/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: voice-ingest runs as a separate worker process + +A dedicated `voice-ingest` worker SHALL be implemented as a separate process from Master and from each NPC bot. The worker connects to the same Discord guild as Master, joins the voice channel identified by `MAIN_VOICE_CHANNEL_ID` (shared with Master and all NPC bots), captures incoming voice packets, runs VAD per speaker, performs STT on completed segments, and sends `SpeechEvent(source=voice_stt)` records to Master over a Master-defined ingestion endpoint (HTTP POST or WebSocket — chosen in design). The worker MUST NOT inline VC playback, NPC speech generation, or game-state mutation. + +#### Scenario: voice-ingest joins MAIN_VOICE_CHANNEL_ID +- **WHEN** the `voice-ingest` worker starts and Master has an active game +- **THEN** the worker joins the Discord voice channel whose id equals `MAIN_VOICE_CHANNEL_ID` and registers as a listener-only client (no audio playback) + +#### Scenario: voice-ingest does not write SpeechEvent directly to SQLite +- **WHEN** voice-ingest produces a transcribed utterance +- **THEN** it sends the `SpeechEvent` payload to Master's ingestion endpoint; Master is the sole writer of `speech_events` rows + +### Requirement: NPC bot audio is excluded at the receive boundary + +voice-ingest SHALL exclude NPC TTS audio from STT processing **before** VAD runs. The worker MUST read the Master NPC registry (over the same protocol it uses to send `SpeechEvent`s) and build the set of `discord_bot_user_id` values currently registered as NPCs. Any inbound voice packet whose Discord `user_id` is in that set MUST be discarded immediately at the receive boundary; such packets MUST NOT be stored, mixed, or fed into VAD/STT. + +#### Scenario: NPC voice packet is dropped +- **WHEN** voice-ingest receives a voice packet whose `user_id` matches a registered NPC bot's `discord_bot_user_id` +- **THEN** the packet is discarded immediately and no `vad_speech_started` event is emitted for that packet + +#### Scenario: Human voice packet proceeds to VAD +- **WHEN** voice-ingest receives a voice packet whose `user_id` is not in the NPC registry set +- **THEN** the packet is forwarded into the per-speaker VAD pipeline + +### Requirement: VAD lifecycle is logged but not persisted as SpeechEvent + +voice-ingest SHALL emit structured log events `vad_speech_started` and `vad_speech_ended` for each detected human voice segment, including `game_id`, `phase_id`, `speaker_user_id`, `speaker_seat`, `audio_start_ms` / `audio_end_ms`, and `trace_id`. These events MUST NOT cause `SpeechEvent` rows to be written; only the post-STT finalized utterance does (see speech-event-bus spec). + +#### Scenario: VAD start emits a log event +- **WHEN** the VAD engine detects voice activity beginning for a human speaker +- **THEN** voice-ingest emits a structured log entry with `event=vad_speech_started`, the speaker's `user_id`, the resolved `seat_no`, and the absolute `audio_start_ms` + +### Requirement: STT uses Gemini API audio input for the MVP + +The voice-ingest worker SHALL use the Gemini API audio-input feature as the MVP STT provider. Provider configuration (API key, model id, language hint, confidence threshold) MUST come from environment variables; no provider credentials may be hard-coded. The STT call MUST run asynchronously from the VAD pipeline so a slow STT response does not stall packet capture. + +#### Scenario: Gemini API is invoked with the segment audio +- **WHEN** a complete human VAD segment becomes available +- **THEN** voice-ingest sends the segment audio to the Gemini API audio endpoint and awaits the transcription with a configurable timeout + +### Requirement: Low-confidence and failed STT are dropped with structured-log evidence + +When STT returns a transcription whose `stt_confidence` is below the configured threshold, or when the STT provider returns a hard error (timeout, 5xx, malformed response), voice-ingest SHALL **drop the utterance**: no `SpeechEvent` is sent to Master, and the human player receives no Discord-side surfacing. The drop MUST be recorded as a structured-log event with `event=stt_request_failed` (provider error) or `event=stt_low_confidence` (below threshold), including `failure_reason`, `stt_confidence` (if known), `audio_duration_ms`, and `trace_id`. + +#### Scenario: Below-threshold STT result is dropped +- **WHEN** STT returns `stt_confidence = 0.42` and the configured threshold is `0.6` +- **THEN** voice-ingest emits a `stt_low_confidence` log event and does NOT send a `SpeechEvent` to Master + +#### Scenario: Provider error drops the utterance +- **WHEN** the Gemini API call raises a timeout or returns a 5xx status +- **THEN** voice-ingest emits a `stt_request_failed` log event with `failure_reason=stt_provider_error` (or `stt_timeout`) and does NOT send a `SpeechEvent` to Master + +### Requirement: voice-ingest is fully testable via Protocol Fakes + +The voice-ingest worker SHALL expose its external dependencies (Discord VC audio source, STT provider, Master ingestion endpoint, NPC-registry lookup) through Protocols so that tests can substitute Fakes. New testing utilities `FakeSttService`, `FakeNpcRegistryClient`, and `FakeMasterIngestionClient` MUST be provided in `tests/fakes.py` (or a new `tests/voice_fakes.py`). No test in the suite may make real Discord, Gemini, or Master HTTP calls. + +#### Scenario: STT can be substituted in tests +- **WHEN** a unit test instantiates the voice-ingest pipeline +- **THEN** it can pass a `FakeSttService` that returns predetermined transcriptions and confidences without making any external network call + +### Requirement: voice-ingest restart drops in-flight VAD windows + +When the voice-ingest worker restarts during an active `DAY_DISCUSSION`, any open per-speaker VAD windows SHALL be abandoned: no partial `SpeechEvent` is sent for an utterance whose `vad_speech_ended` had not yet been observed. The restart MUST be logged as `voice_ingest_restart` with the abandoned `speaker_user_id`s. The worker reconnects to Discord and rebuilds its NPC-registry view from Master before resuming capture. + +#### Scenario: In-flight VAD is abandoned across restart +- **WHEN** voice-ingest crashes mid-utterance for a human speaker and restarts +- **THEN** no `SpeechEvent` is sent for that abandoned utterance, and the restart is logged with `event=voice_ingest_restart` and the affected speaker's `user_id` diff --git a/openspec/changes/add-master-npc-voice-stt/task-graph.json b/openspec/changes/add-master-npc-voice-stt/task-graph.json new file mode 100644 index 0000000..16d4724 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/task-graph.json @@ -0,0 +1,457 @@ +{ + "version": "1.0", + "change_id": "add-master-npc-voice-stt", + "bundles": [ + { + "id": "speech-event-bus-foundation", + "title": "Speech Event Bus Foundation", + "goal": "Introduce the canonical SpeechEvent model, persistence, and service APIs that every discussion mode can write to and rebuild from.", + "depends_on": [], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/speech-event-bus/spec.md", + "src/wolfbot/persistence/schema.py", + "src/wolfbot/persistence/sqlite_repo.py" + ], + "outputs": [ + "src/wolfbot/domain/discussion.py#SpeechEvent", + "src/wolfbot/services/discussion_service.py", + "src/wolfbot/persistence/schema.py#speech_events", + "tests/test_discussion_service.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Define SpeechEvent domain types, source enums, and serialization rules.", + "status": "done" + }, + { + "id": "2", + "title": "Add the additive speech_events migration and store/repository interfaces.", + "status": "done" + }, + { + "id": "3", + "title": "Implement speech event write, read, and rebuild helpers for the master process.", + "status": "done" + }, + { + "id": "4", + "title": "Add persistence and round-trip rebuild tests for SpeechEvent streams.", + "status": "done" + } + ], + "owner_capabilities": [ + "day-discussion", + "llm-seats" + ], + "size_score": 4 + }, + { + "id": "public-discussion-state", + "title": "Public Discussion State Derivation", + "goal": "Build a deterministic PublicDiscussionState fold over speech_events for the active phase.", + "depends_on": [ + "speech-event-bus-foundation" + ], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/day-discussion/spec.md", + "src/wolfbot/domain/discussion.py#SpeechEvent" + ], + "outputs": [ + "src/wolfbot/domain/discussion.py#PublicDiscussionState", + "src/wolfbot/domain/discussion.py#apply_speech_event", + "src/wolfbot/domain/discussion.py#rebuild_public_state_from_events", + "tests/test_public_discussion_state.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Define the PublicDiscussionState value object and its deterministic fields.", + "status": "done" + }, + { + "id": "2", + "title": "Implement apply_speech_event and rebuild_public_state_from_events as a pure fold.", + "status": "done" + }, + { + "id": "3", + "title": "Encode co-claim and silence rules directly from event order without LLM summaries.", + "status": "done" + }, + { + "id": "4", + "title": "Add regression and property-style tests that verify bitwise reproducibility from a fresh rebuild.", + "status": "done" + } + ], + "owner_capabilities": [ + "day-discussion", + "llm-seats" + ], + "size_score": 4 + }, + { + "id": "rounds-mode-speechevent-backfill", + "title": "Rounds Mode SpeechEvent Backfill", + "goal": "Make the existing rounds discussion path write canonical SpeechEvents without changing current gameplay behavior.", + "depends_on": [ + "speech-event-bus-foundation" + ], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/day-discussion/spec.md", + "src/wolfbot/services/llm_service.py", + "src/wolfbot/services/game_service.py", + "src/wolfbot/services/discussion_service.py" + ], + "outputs": [ + "src/wolfbot/services/llm_service.py#rounds-speech-event-writes", + "src/wolfbot/services/game_service.py#rounds-discussion-path", + "tests/test_llm_service.py", + "tests/test_llm_trigger.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Route rounds-mode utterance generation through the SpeechEvent store.", + "status": "done" + }, + { + "id": "2", + "title": "Preserve existing log entries and llm_speech_counts side effects for rounds mode.", + "status": "done" + }, + { + "id": "3", + "title": "Add regression coverage that proves current rounds behavior remains unchanged.", + "status": "done" + } + ], + "owner_capabilities": [ + "day-discussion", + "llm-seats" + ], + "size_score": 3 + }, + { + "id": "master-speech-control-surface", + "title": "Master Speech Control Surface", + "goal": "Add the master-owned protocol, endpoints, registry, and audit-table schema that external voice workers connect to.", + "depends_on": [ + "public-discussion-state" + ], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/npc-voice-pipeline/spec.md", + "openspec/changes/add-master-npc-voice-stt/specs/voice-ingest/spec.md", + "src/wolfbot/domain/discussion.py#PublicDiscussionState", + "src/wolfbot/persistence/schema.py" + ], + "outputs": [ + "src/wolfbot/services/master_ws_server.py", + "src/wolfbot/services/master_ingest_service.py", + "src/wolfbot/services/npc_registry.py", + "src/wolfbot/persistence/schema.py#npc_speak_requests", + "src/wolfbot/persistence/schema.py#npc_speak_results", + "src/wolfbot/persistence/schema.py#npc_playback_events", + "tests/test_master_ws_server.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Define the shared WebSocket envelope, message schemas, and PSK handshake rules.", + "status": "done" + }, + { + "id": "2", + "title": "Add additive migrations and repository hooks for npc_speak_requests, npc_speak_results, and npc_playback_events.", + "status": "done" + }, + { + "id": "3", + "title": "Implement the master WebSocket server, NPC registration and heartbeat tracking, and localhost ingest and registry endpoints.", + "status": "done" + }, + { + "id": "4", + "title": "Add fakes and protocol-focused tests for registration, heartbeats, and endpoint contracts.", + "status": "done" + } + ], + "owner_capabilities": [ + "discord-integration", + "day-discussion" + ], + "size_score": 4 + }, + { + "id": "master-arbitration-and-recovery", + "title": "Master Arbitration And Recovery", + "goal": "Build the master logic and arbiter that selects NPC speech, enforces serial gating, and finalizes stale or interrupted work.", + "depends_on": [ + "public-discussion-state", + "master-speech-control-surface" + ], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/npc-voice-pipeline/spec.md", + "src/wolfbot/domain/discussion.py#PublicDiscussionState", + "src/wolfbot/services/master_ws_server.py", + "src/wolfbot/services/npc_registry.py", + "src/wolfbot/services/discussion_service.py" + ], + "outputs": [ + "src/wolfbot/services/master_logic_service.py", + "src/wolfbot/services/speak_arbiter.py", + "src/wolfbot/services/recovery_service.py#reactive-voice-sweep", + "tests/test_reactive_voice_master.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Build logic packet generation from PublicDiscussionState and seat pressure inputs.", + "status": "done" + }, + { + "id": "2", + "title": "Implement SpeakArbiter dispatch, authorization and rejection flow, serial speech gates, and stale result handling.", + "status": "done" + }, + { + "id": "3", + "title": "Finalize restart recovery rules for pending speak and playback rows plus in-memory gate rebuild.", + "status": "done" + }, + { + "id": "4", + "title": "Add master-side tests for serial speech, stale responses, and restart rebuild behavior.", + "status": "done" + } + ], + "owner_capabilities": [ + "day-discussion", + "discord-integration", + "llm-seats" + ], + "size_score": 4 + }, + { + "id": "voice-ingest-worker", + "title": "Voice Ingest Worker", + "goal": "Capture human voice, filter NPC audio, run VAD and STT, and send canonical speech events to the master.", + "depends_on": [ + "speech-event-bus-foundation", + "master-speech-control-surface" + ], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/voice-ingest/spec.md", + "src/wolfbot/services/master_ingest_service.py", + "src/wolfbot/config.py" + ], + "outputs": [ + "src/wolfbot/services/voice_ingest_service.py", + "src/wolfbot/services/stt_service.py", + "src/wolfbot/services/voice_ingest_client.py", + "tests/test_voice_ingest_service.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Add VAD, STT, and master-client protocols plus configuration for the voice-ingest worker.", + "status": "done" + }, + { + "id": "2", + "title": "Implement NPC registry refresh and Discord packet filtering for bot user IDs.", + "status": "done" + }, + { + "id": "3", + "title": "Implement VAD buffering, STT submission, and speech_event ingestion with explicit drop paths for low-confidence and error cases.", + "status": "done" + }, + { + "id": "4", + "title": "Add tests for one-human-one-event, NPC exclusion, and low-confidence or error drops.", + "status": "done" + } + ], + "owner_capabilities": [ + "discord-integration" + ], + "size_score": 4 + }, + { + "id": "npc-voice-worker", + "title": "NPC Voice Worker", + "goal": "Run a per-NPC bot process that registers with the master, generates short utterances, and only plays after authorization.", + "depends_on": [ + "master-speech-control-surface" + ], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/npc-voice-pipeline/spec.md", + "src/wolfbot/config.py", + "src/wolfbot/llm/prompt_builder.py", + "src/wolfbot/services/master_ws_server.py" + ], + "outputs": [ + "src/wolfbot/npc_bot_main.py", + "src/wolfbot/services/npc_client.py", + "src/wolfbot/services/npc_speech_service.py", + "src/wolfbot/services/tts_service.py", + "src/wolfbot/services/voice_playback_service.py", + "tests/test_npc_voice_worker.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Implement the NPC-side master client with register, heartbeat, and request and result handling.", + "status": "done" + }, + { + "id": "2", + "title": "Build short-utterance NPC generation, TTS synthesis, and in-memory audio cache services.", + "status": "done" + }, + { + "id": "3", + "title": "Gate playback on explicit authorization and emit playback_finished and playback_failed events.", + "status": "done" + }, + { + "id": "4", + "title": "Add integration tests with fake master, TTS, and playback services.", + "status": "done" + } + ], + "owner_capabilities": [ + "discord-integration", + "llm-seats" + ], + "size_score": 4 + }, + { + "id": "reactive-voice-mode-plumbing", + "title": "Reactive Voice Mode Plumbing", + "goal": "Wire reactive_voice as a mode-fixed day-discussion path without regressing rounds mode.", + "depends_on": [ + "rounds-mode-speechevent-backfill", + "master-arbitration-and-recovery", + "voice-ingest-worker", + "npc-voice-worker" + ], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/day-discussion/spec.md", + "openspec/changes/add-master-npc-voice-stt/specs/llm-seats/spec.md", + "src/wolfbot/config.py", + "src/wolfbot/services/game_service.py", + "src/wolfbot/main.py" + ], + "outputs": [ + "src/wolfbot/config.py#LLM_DISCUSSION_MODE", + "src/wolfbot/services/game_service.py#reactive-voice-dispatch", + "src/wolfbot/main.py#voice-worker-startup", + "tests/test_reactive_voice_mode.py", + "tests/test_game_service_advance.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Add LLM_DISCUSSION_MODE selection and freeze the chosen discussion mode for each game instance.", + "status": "done" + }, + { + "id": "2", + "title": "Dispatch DAY_DISCUSSION between rounds and reactive_voice without changing rounds semantics.", + "status": "done" + }, + { + "id": "3", + "title": "Wire master, voice-ingest, and NPC worker lifecycle management for reactive_voice sessions.", + "status": "done" + }, + { + "id": "4", + "title": "Add end-to-end reactive_voice and rounds-regression tests.", + "status": "done" + } + ], + "owner_capabilities": [ + "day-discussion", + "discord-integration", + "llm-seats" + ], + "size_score": 4 + }, + { + "id": "cross-component-observability", + "title": "Cross-Component Observability", + "goal": "Standardize structured logs and phase summaries across master, voice-ingest, and NPC workers for debugging and recovery.", + "depends_on": [ + "master-arbitration-and-recovery", + "voice-ingest-worker", + "npc-voice-worker", + "reactive-voice-mode-plumbing" + ], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/day-discussion/spec.md", + "openspec/changes/add-master-npc-voice-stt/specs/voice-ingest/spec.md", + "openspec/changes/add-master-npc-voice-stt/specs/npc-voice-pipeline/spec.md", + "src/wolfbot/services/master_logic_service.py", + "src/wolfbot/services/voice_ingest_service.py", + "src/wolfbot/services/npc_speech_service.py" + ], + "outputs": [ + "src/wolfbot/services/structured_logging.py", + "src/wolfbot/services/recovery_service.py#discussion-phase-summary", + "tests/test_observability.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Define a shared structured logging helper and required fields for trace, phase, request, seat, and source identifiers.", + "status": "done" + }, + { + "id": "2", + "title": "Instrument master, voice-ingest, and NPC worker flows for drops, authorization, playback, and timeout events.", + "status": "done" + }, + { + "id": "3", + "title": "Emit discussion_phase_summary at each phase end using speech_events and playback audit rows.", + "status": "done" + }, + { + "id": "4", + "title": "Add log fixture tests that assert required events and per-phase speech counts.", + "status": "done" + } + ], + "owner_capabilities": [ + "day-discussion", + "discord-integration", + "llm-seats" + ], + "size_score": 4 + } + ], + "generated_at": "2026-04-26T06:10:31.890Z", + "generated_from": "design.md" +} diff --git a/openspec/changes/add-master-npc-voice-stt/tasks.md b/openspec/changes/add-master-npc-voice-stt/tasks.md new file mode 100644 index 0000000..5839605 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/tasks.md @@ -0,0 +1,95 @@ +## 1. Speech Event Bus Foundation ✓ + +> Introduce the canonical SpeechEvent model, persistence, and service APIs that every discussion mode can write to and rebuild from. + +- [x] 1.1 Define SpeechEvent domain types, source enums, and serialization rules. +- [x] 1.2 Add the additive speech_events migration and store/repository interfaces. +- [x] 1.3 Implement speech event write, read, and rebuild helpers for the master process. +- [x] 1.4 Add persistence and round-trip rebuild tests for SpeechEvent streams. + +## 2. Public Discussion State Derivation ✓ + +> Build a deterministic PublicDiscussionState fold over speech_events for the active phase. + +> Depends on: speech-event-bus-foundation + +- [x] 2.1 Define the PublicDiscussionState value object and its deterministic fields. +- [x] 2.2 Implement apply_speech_event and rebuild_public_state_from_events as a pure fold. +- [x] 2.3 Encode co-claim and silence rules directly from event order without LLM summaries. +- [x] 2.4 Add regression and property-style tests that verify bitwise reproducibility from a fresh rebuild. + +## 3. Rounds Mode SpeechEvent Backfill ✓ + +> Make the existing rounds discussion path write canonical SpeechEvents without changing current gameplay behavior. + +> Depends on: speech-event-bus-foundation + +- [x] 3.1 Route rounds-mode utterance generation through the SpeechEvent store. +- [x] 3.2 Preserve existing log entries and llm_speech_counts side effects for rounds mode. +- [x] 3.3 Add regression coverage that proves current rounds behavior remains unchanged. + +## 4. Master Speech Control Surface ✓ + +> Add the master-owned protocol, endpoints, registry, and audit-table schema that external voice workers connect to. + +> Depends on: public-discussion-state + +- [x] 4.1 Define the shared WebSocket envelope, message schemas, and PSK handshake rules. +- [x] 4.2 Add additive migrations and repository hooks for npc_speak_requests, npc_speak_results, and npc_playback_events. +- [x] 4.3 Implement the master WebSocket server, NPC registration and heartbeat tracking, and localhost ingest and registry endpoints. +- [x] 4.4 Add fakes and protocol-focused tests for registration, heartbeats, and endpoint contracts. + +## 5. Master Arbitration And Recovery ✓ + +> Build the master logic and arbiter that selects NPC speech, enforces serial gating, and finalizes stale or interrupted work. + +> Depends on: public-discussion-state, master-speech-control-surface + +- [x] 5.1 Build logic packet generation from PublicDiscussionState and seat pressure inputs. +- [x] 5.2 Implement SpeakArbiter dispatch, authorization and rejection flow, serial speech gates, and stale result handling. +- [x] 5.3 Finalize restart recovery rules for pending speak and playback rows plus in-memory gate rebuild. +- [x] 5.4 Add master-side tests for serial speech, stale responses, and restart rebuild behavior. + +## 6. Voice Ingest Worker ✓ + +> Capture human voice, filter NPC audio, run VAD and STT, and send canonical speech events to the master. + +> Depends on: speech-event-bus-foundation, master-speech-control-surface + +- [x] 6.1 Add VAD, STT, and master-client protocols plus configuration for the voice-ingest worker. +- [x] 6.2 Implement NPC registry refresh and Discord packet filtering for bot user IDs. +- [x] 6.3 Implement VAD buffering, STT submission, and speech_event ingestion with explicit drop paths for low-confidence and error cases. +- [x] 6.4 Add tests for one-human-one-event, NPC exclusion, and low-confidence or error drops. + +## 7. NPC Voice Worker ✓ + +> Run a per-NPC bot process that registers with the master, generates short utterances, and only plays after authorization. + +> Depends on: master-speech-control-surface + +- [x] 7.1 Implement the NPC-side master client with register, heartbeat, and request and result handling. +- [x] 7.2 Build short-utterance NPC generation, TTS synthesis, and in-memory audio cache services. +- [x] 7.3 Gate playback on explicit authorization and emit playback_finished and playback_failed events. +- [x] 7.4 Add integration tests with fake master, TTS, and playback services. + +## 8. Reactive Voice Mode Plumbing ✓ + +> Wire reactive_voice as a mode-fixed day-discussion path without regressing rounds mode. + +> Depends on: rounds-mode-speechevent-backfill, master-arbitration-and-recovery, voice-ingest-worker, npc-voice-worker + +- [x] 8.1 Add LLM_DISCUSSION_MODE selection and freeze the chosen discussion mode for each game instance. +- [x] 8.2 Dispatch DAY_DISCUSSION between rounds and reactive_voice without changing rounds semantics. +- [x] 8.3 Wire master, voice-ingest, and NPC worker lifecycle management for reactive_voice sessions. +- [x] 8.4 Add end-to-end reactive_voice and rounds-regression tests. + +## 9. Cross-Component Observability ✓ + +> Standardize structured logs and phase summaries across master, voice-ingest, and NPC workers for debugging and recovery. + +> Depends on: master-arbitration-and-recovery, voice-ingest-worker, npc-voice-worker, reactive-voice-mode-plumbing + +- [x] 9.1 Define a shared structured logging helper and required fields for trace, phase, request, seat, and source identifiers. +- [x] 9.2 Instrument master, voice-ingest, and NPC worker flows for drops, authorization, playback, and timeout events. +- [x] 9.3 Emit discussion_phase_summary at each phase end using speech_events and playback audit rows. +- [x] 9.4 Add log fixture tests that assert required events and per-phase speech counts. diff --git a/openspec/specs/day-discussion/spec.md b/openspec/specs/day-discussion/spec.md new file mode 100644 index 0000000..ab6762b --- /dev/null +++ b/openspec/specs/day-discussion/spec.md @@ -0,0 +1,100 @@ +# day-discussion Specification + +## Purpose + +Drives the `DAY_DISCUSSION` and `DAY_RUNOFF_SPEECH` phases of a 9-player Werewolf game. Coordinates day-time speech for both human seats (free Discord text) and LLM seats (batched generated speech), publishes utterances as `PLAYER_SPEECH` log entries, and tracks per-LLM-seat speech progress so that restarts and host force-skips resume correctly without double-posting. + +## Requirements + +### Requirement: Day discussion duration is fixed per day number + +`DAY_DISCUSSION` SHALL run for a deterministic duration depending on `Game.day_number`, computed by `domain.rules.day_discussion_duration(day)`. The duration MUST be 300 seconds on day 1, 240 seconds on day 2, and 180 seconds on day 3 and later. + +#### Scenario: Day 1 discussion length +- **WHEN** the state machine plans the transition into `DAY_DISCUSSION` for `day_number = 1` +- **THEN** the resulting `Transition.deadline_epoch` is exactly 300 seconds after the planning `now_epoch` + +#### Scenario: Day 3+ discussion length +- **WHEN** the state machine plans the transition into `DAY_DISCUSSION` for `day_number >= 3` +- **THEN** the resulting `Transition.deadline_epoch` is exactly 180 seconds after the planning `now_epoch` + +### Requirement: LLM seats speak twice per discussion phase + +For every `DAY_DISCUSSION` phase, each living LLM seat SHALL emit exactly two structured speech utterances. Progress for each seat MUST be tracked in the `llm_speech_counts.discussion_rounds_done` column so that an interrupted phase (process restart, deadline-driven advance, host `/wolf force-skip`) does not cause the same round to be repeated when execution resumes. + +#### Scenario: Two utterances per LLM seat in a fresh phase +- **WHEN** `DAY_DISCUSSION` starts for `day_number = 1` with 4 living LLM seats and the deadline is far enough in the future +- **THEN** the system schedules two utterances per LLM seat (8 total), each producing a `PLAYER_SPEECH` `LogEntry` + +#### Scenario: Resume after restart in mid-phase +- **WHEN** the bot restarts during `DAY_DISCUSSION` after one round of LLM speech has already been recorded for an LLM seat +- **AND** that seat's `llm_speech_counts.discussion_rounds_done` is `1` +- **THEN** on resumption the system schedules only the remaining round for that seat, not both rounds + +### Requirement: All speech is published as PLAYER_SPEECH log entries + +Every successful day-discussion utterance — whether produced by a human via Discord text or by an LLM seat via the structured-output pipeline — SHALL be persisted as a `LogEntry` with `kind = "PLAYER_SPEECH"`, attributed to the originating `seat_no` and `display_name`, and visible in the public main-text channel. + +#### Scenario: LLM speech is logged +- **WHEN** an LLM seat's `submit_llm_discussion_rounds` task produces an utterance +- **THEN** a `LogEntry(kind="PLAYER_SPEECH", actor_seat=, text=)` is appended via the public-log path and posted to the main channel + +#### Scenario: Human speech is logged +- **WHEN** a human player sends a message in the main text channel during `DAY_DISCUSSION` +- **THEN** the bot records the message as a `PLAYER_SPEECH` `LogEntry` for the speaker's seat + +### Requirement: LLM submissions never block the advance loop + +`LLMAdapter.submit_llm_discussion_rounds` and the runoff-speech equivalent SHALL schedule background `asyncio` tasks and return without awaiting the xAI round-trip. The `GameService.advance(game_id)` call site MUST NOT await LLM completion, and a slow xAI response MUST NOT delay deadline detection or phase transitions. + +#### Scenario: advance() returns before LLM responses arrive +- **WHEN** `GameService.advance` enters `DAY_DISCUSSION` and dispatches LLM submissions +- **THEN** the call returns once all background tasks are scheduled, regardless of xAI latency + +#### Scenario: Stale background task detects phase change +- **WHEN** a background LLM submission task is in flight and a `/wolf force-skip` advances the phase before the task completes +- **THEN** the task re-loads the game, observes the changed `phase`, `day_number`, or non-null `ended_at`, and silently aborts without writing a `PLAYER_SPEECH` log + +### Requirement: Concurrent LLM seats run in parallel; wolf night-chat stays serial + +Within a single discussion or runoff-speech batch, per-seat LLM work SHALL run concurrently via `asyncio.gather` so that multiple LLM seats hit xAI simultaneously rather than serially. The wolf night-chat coordination, however, MUST remain serial because later wolves rely on reading earlier wolves' messages from the shared wolves channel. + +#### Scenario: Discussion batch runs in parallel +- **WHEN** four LLM seats are alive and `submit_llm_discussion_rounds` schedules a round +- **THEN** the four xAI requests are issued concurrently rather than one after another + +#### Scenario: Wolf night-chat is serial +- **WHEN** multiple LLM wolves are alive during a NIGHT phase +- **THEN** their wolf-channel utterances are produced in seat order, each later wolf seeing the prior wolf's messages + +### Requirement: Runoff speeches occur only when LLM candidates are tied + +`DAY_RUNOFF_SPEECH` SHALL be entered only when `DAY_VOTE` produces a tie that includes at least one LLM-controlled candidate. In that phase, each tied LLM candidate MUST emit exactly one speech utterance, tracked by `llm_speech_counts.runoff_speech_done`. + +#### Scenario: Tie with LLM candidate triggers runoff speeches +- **WHEN** `DAY_VOTE` resolves with two seats tied and one of them is an LLM seat +- **THEN** the state machine transitions to `DAY_RUNOFF_SPEECH`, and the LLM seat produces exactly one runoff speech before `DAY_RUNOFF` begins + +#### Scenario: All-human tie skips runoff speech phase +- **WHEN** `DAY_VOTE` resolves with two human seats tied and no LLM candidate +- **THEN** the state machine transitions directly to `DAY_RUNOFF`, skipping `DAY_RUNOFF_SPEECH` + +### Requirement: LLM submissions re-validate phase before each per-seat write + +Inside every background LLM submission task, the system SHALL re-load the game and re-check `phase`, `day_number`, and `ended_at` immediately before writing each per-seat `PLAYER_SPEECH` log. A mismatch on any field MUST cause the per-seat write to be skipped. + +#### Scenario: Phase change mid-batch drops remaining seats +- **WHEN** a discussion batch has dispatched per-seat tasks and `/wolf force-skip` advances to `DAY_VOTE` before all tasks complete +- **THEN** any per-seat task that observes the new phase aborts before posting its utterance, and no late `PLAYER_SPEECH` is written for that day's discussion + +### Requirement: Discussion mode is configurable + +The day-discussion engine SHALL accept a runtime mode setting (e.g. via the `LLM_DISCUSSION_MODE` environment variable) that selects between alternative discussion strategies. The default mode MUST be the round-based strategy described above; alternative modes MAY replace the LLM-speech batching behavior while preserving the public-log contract (`PLAYER_SPEECH` entries) and the deadline contract. + +#### Scenario: Default mode runs fixed-round discussion +- **WHEN** the bot starts without `LLM_DISCUSSION_MODE` set +- **THEN** `DAY_DISCUSSION` runs the two-round LLM batching described in this spec + +#### Scenario: Alternative mode preserves the public-log contract +- **WHEN** an alternative discussion mode is selected +- **THEN** every recorded utterance is still emitted as a `PLAYER_SPEECH` `LogEntry` so downstream voting, recovery, and post-game replay are unaffected diff --git a/openspec/specs/discord-integration/spec.md b/openspec/specs/discord-integration/spec.md new file mode 100644 index 0000000..fc7cfd0 --- /dev/null +++ b/openspec/specs/discord-integration/spec.md @@ -0,0 +1,103 @@ +# discord-integration Specification + +## Purpose + +Defines the Discord-side surface of the bot: the `/wolf` slash-command set hosted by `WolfCog`, the per-phase channel-permission reconciliation performed by `PermissionManager` across the main / wolves / heaven channel triplet, the DM-based `VoteView` and `NightActionView` interactions used for private submissions, and the lifecycle of the per-game wolves and heaven channels (created on game start, deleted on game end). + +## Requirements + +### Requirement: /wolf slash commands are registered to a single guild + +The bot SHALL register the `/wolf` command set (`join`, `leave`, `start`, `abort`, `force-skip`, `extend`) inside a single Discord guild whose id is read from the `DISCORD_GUILD_ID` setting. Host-only commands (`start`, `abort`, `force-skip`, `extend`) MUST verify that the invoking user is the recorded game host before performing any state mutation. + +#### Scenario: Non-host abort attempt is rejected +- **WHEN** a user who is not the host invokes `/wolf abort` on an active game +- **THEN** the bot replies ephemerally with a permission error and does not call `GameService.host_abort` + +#### Scenario: Host abort tears down only on first call +- **WHEN** the host invokes `/wolf abort` while the game is still active +- **THEN** `GameService.host_abort` returns `True`, the cog detaches and stops the `GameEngine`, and posts the public `強制終了` message + +#### Scenario: Host abort on already-ended game is a no-op +- **WHEN** the host invokes `/wolf abort` on a game whose `ended_at` is non-null +- **THEN** `GameService.host_abort` returns `False`, the cog replies ephemerally, and no public message is posted and no engine teardown runs + +### Requirement: Lobby seat mutations use phase-guarded repo methods + +`/wolf join` and `/wolf leave` SHALL invoke `SqliteRepo.join_lobby` / `SqliteRepo.leave_lobby` respectively, never raw `insert_seat` / `delete_seat`. These repo methods MUST be phase-guarded in a single transaction so that a stale `/wolf join` arriving after `LOBBY → SETUP` does not corrupt the seat list. + +#### Scenario: Join after start is rejected atomically +- **WHEN** a user issues `/wolf join` between the moment `/wolf start` has flipped the phase to `SETUP` and the moment the bot has finished any post-start work +- **THEN** `SqliteRepo.join_lobby` observes `phase != LOBBY`, the transaction rolls back, and the user is shown an ephemeral error + +### Requirement: Channel permissions are reconciled per phase and idempotent + +`PermissionManager` SHALL maintain three classes of channel state — main text, wolves (private), and heaven (private) — and reconcile them whenever the game phase changes or `GameEngine` reattaches on restart. Living players see+send in the main channel; dead players see only. Living werewolves see the wolves channel always but send only during `NIGHT`. Dead players see+send in the heaven channel; living players cannot see it. The reconciliation MUST be idempotent: only diffs trigger Discord API calls. + +#### Scenario: Wolves can send only during NIGHT +- **WHEN** the game enters `NIGHT` with two living werewolves +- **THEN** the wolves-channel overwrites for both wolves grant send permission, and outside of `NIGHT` (e.g. after entering `DAY_DISCUSSION`) those overwrites grant view-only + +#### Scenario: Heaven hides from the living +- **WHEN** the game has at least one dead player +- **THEN** the heaven-channel overwrite for `@everyone` denies view, the bot's own overwrite allows view+send, and each dead player's overwrite allows view+send while no living player has any heaven overwrite + +#### Scenario: Repeat reconciliation is a no-op +- **WHEN** `PermissionManager.apply` is invoked twice in succession with no underlying state change +- **THEN** the second invocation issues no Discord API calls because all overwrites already match the desired state + +### Requirement: Wolves and heaven channels are deleted at game end + +When a game enters `GAME_OVER` (via execution victory, attack victory, or `/wolf abort`), the bot SHALL delete the per-game wolves and heaven channels (`Game.wolves_channel_id` and `Game.heaven_channel_id`) rather than merely clearing their permission overwrites. This deletion is required to prevent cross-game channel leak. + +#### Scenario: End-of-game cleanup deletes private channels +- **WHEN** a game reaches `GAME_OVER` +- **THEN** the bot deletes both the wolves channel referenced by `Game.wolves_channel_id` and the heaven channel referenced by `Game.heaven_channel_id` + +### Requirement: Wolf-attack splits show asymmetric public information + +While a wolf-attack split is unresolved during `NIGHT`, the main text channel SHALL announce only an aggregate `未確定: N件` count and MUST NOT reveal the per-target breakdown. The wolves private channel, by contrast, MAY display the real per-target tally so the wolves can coordinate. This asymmetry is intentional and MUST be preserved. + +#### Scenario: Main channel hides the breakdown +- **WHEN** two wolves submit different attack targets and the split is still unresolved +- **THEN** the main-channel announcement reads `未確定: 2件` (or equivalent) and does not name the targets + +#### Scenario: Wolves channel sees the split +- **WHEN** the same split is in progress +- **THEN** the wolves-channel message lists both targets and the count for each so the wolves can converge + +### Requirement: DM views capture day at send time + +`VoteView` and `NightActionView` sent to a player as a Discord DM SHALL embed the `Game.day_number` value as it was at the moment the DM was dispatched. When the player clicks a button on a DM after the game has advanced to a new day, `submit_vote` / `submit_night_action` MUST observe the captured `day` against the current `game.day_number` and reject the submission with `SubmitResult.STALE_PHASE` even if the current phase happens to coincidentally match. + +#### Scenario: Yesterday's DM is rejected today +- **WHEN** a player clicks the vote button on a DM that was sent during day 1's `DAY_VOTE` after the game has advanced into day 2's `DAY_VOTE` +- **THEN** `submit_vote(day=1)` is rejected with `SubmitResult.STALE_PHASE` and no vote is recorded + +### Requirement: DM resends cover both missing and unresolved seats + +`resend_pending_dms` (used by `/wolf extend` and the recovery path) SHALL re-send DMs to the union of `PendingSubmission.missing_seats` (never submitted) and `PendingSubmission.unresolved_seats` (submitted but unresolved, e.g. wolf attack split). This guarantees that an extend after a split lockout reaches the not-yet-submitted seat too without requiring the host to use `/wolf force-skip`. + +#### Scenario: Extend during attack split resends to both wolves +- **WHEN** during a wolf-attack split one wolf has submitted and one has not, and the host issues `/wolf extend` +- **THEN** the resend reaches both wolves, with the not-yet-submitted wolf seeing the full legal attack list + +### Requirement: Night DMs separate "who to DM" from "the legal target pool" + +`DiscordAdapter.send_night_action_dms(game, actors, alive_players, seats)` SHALL accept two separate player pools: `actors` (the recipients to DM, typically the still-pending subset) and `alive_players` (the full alive pool used to compute legal targets). Implementations MUST NOT collapse these into a single argument, because resends to a single not-yet-submitted wolf during a split still need the full legal attack list. + +#### Scenario: Single-wolf resend still offers full target list +- **WHEN** `send_night_action_dms` is called with `actors = [seat_5]` and `alive_players` containing all living non-wolf seats +- **THEN** the DM sent to seat 5 lists every legal attack target derived from `alive_players`, not only seats present in `actors` + +### Requirement: WAITING_HOST_DECISION requires explicit host intervention + +When `RecoveryService` finds a game whose `deadline_epoch < now` on startup, it SHALL park the game in `WAITING_HOST_DECISION` instead of silently auto-resolving stale submissions. The host MUST then run either `/wolf force-skip` or `/wolf extend` to unblock the game. + +#### Scenario: Past-deadline game on restart awaits host +- **WHEN** the bot restarts and finds an active game whose `deadline_epoch` already passed +- **THEN** the game is moved to `WAITING_HOST_DECISION` and remains there until the host runs `/wolf force-skip` or `/wolf extend` + +#### Scenario: Per-game isolation in recovery +- **WHEN** one game's recovery raises an exception during reconciliation +- **THEN** the exception is logged for that game only and other active games still complete their recovery diff --git a/openspec/specs/llm-seats/spec.md b/openspec/specs/llm-seats/spec.md new file mode 100644 index 0000000..d769deb --- /dev/null +++ b/openspec/specs/llm-seats/spec.md @@ -0,0 +1,95 @@ +# llm-seats Specification + +## Purpose + +Specifies how unfilled seats in a 9-player Werewolf game are played by LLM-driven NPC personas backed by the xAI Grok endpoint. Defines the structured-output contract (`LLMAction`), the per-actor system-prompt composition pipeline (`prompt_builder`), the persona registry, the role-strategy isolation rules, and the seat-token resolver that maps generated `target_name` strings back to specific seats. + +## Requirements + +### Requirement: Unfilled seats are auto-backfilled by LLM-driven NPCs + +When `/wolf start` runs and the lobby contains fewer than 9 human players, the system SHALL fill the remaining seats with LLM-controlled NPCs as part of the same atomic `claim_start_and_backfill` transaction. The number of LLM seats MUST equal `9 - ` so that the table always seats exactly 9 players. + +#### Scenario: Six humans triggers three LLM seats +- **WHEN** `/wolf start` is invoked with exactly 6 human seats present in `LOBBY` +- **THEN** the start transaction inserts 3 additional LLM seats so the game enters `SETUP` with 9 seats total + +#### Scenario: Solo lobby triggers eight LLM seats +- **WHEN** `/wolf start` is invoked with exactly 1 human seat in `LOBBY` +- **THEN** the start transaction inserts 8 additional LLM seats so the game enters `SETUP` with 9 seats total + +### Requirement: Each LLM seat is bound to a unique persona + +Each LLM seat SHALL be assigned a persona record from `wolfbot.llm.personas.PERSONAS_BY_KEY` carrying at minimum a katakana `display_name` prefixed by a distinguishing emoji, a free-form `style_guide` string for judgement and tone, and a structured `SpeechProfile` capturing speech reproduction (`first_person`, `self_reference_aliases`, `address_style`, `sentence_style`, `pause_style`, `signature_phrases`, `forbidden_overuse`, `narration_mode`). No two persona records in a single game MAY share the same `display_name`. + +#### Scenario: Persona display_name is never duplicated within a game +- **WHEN** the start transaction selects personas for the LLM seats of a single game +- **THEN** every chosen persona's `display_name` is unique across that game's seat list + +#### Scenario: Style and speech fields are isolated +- **WHEN** a persona record is read by the prompt builder +- **THEN** speech-reproduction data lives only in `speech_profile` and judgement/tone data lives only in `style_guide`; neither field carries content belonging to the other + +### Requirement: LLM actions are returned as a strict structured JSON + +LLM seats SHALL communicate with the xAI Grok endpoint using the OpenAI-compatible chat-completions API with `response_format` set to enforce the `LLMAction` JSON schema strictly. The schema MUST encode the four legal intents `speak`, `vote`, `night_action`, and `skip`, plus optional fields for `target_name` (a seat token), `text` (the utterance, if any), and reasoning metadata. Transient transport errors MUST be retried with exponential backoff via `tenacity`. + +#### Scenario: Strict structured output is requested +- **WHEN** an LLM seat issues a request via `XAILLMActionDecider` +- **THEN** the request includes `response_format` configured to require the `LLMAction` JSON schema and rejects any unparseable response + +#### Scenario: Transient errors retry with backoff +- **WHEN** the xAI endpoint returns a transient error (timeout, 5xx) on an LLMAction request +- **THEN** the request is retried using `tenacity` with exponential wait and a finite stop-after-attempt + +### Requirement: System prompt is composed per actor + +For every LLM call, the system prompt SHALL be composed dynamically by `prompt_builder.build_system_prompt` rather than loaded verbatim. The composition MUST layer (1) the markdown template at `src/wolfbot/prompts/llm_system_prompt.md` for base framing, output format, and hard invariants; (2) `_build_game_rules_block()` derived from `ROLE_DISTRIBUTION` and `VILLAGE_SIZE` plus shared CO-evaluation heuristics; (3) the role-specific entry from `_ROLE_STRATEGIES`; and (4) `_build_speech_profile_block(persona)` for the persona's speech reproduction. + +#### Scenario: Game-rules block reflects canonical 9-player distribution +- **WHEN** the prompt builder runs for any LLM seat +- **THEN** the rendered game-rules block lists the role counts and village size derived from `ROLE_DISTRIBUTION` / `VILLAGE_SIZE` constants without duplicating the numbers in the markdown template + +#### Scenario: Speech profile is rendered per persona +- **WHEN** the prompt builder runs for a seat whose persona uses `narration_mode = "silent_gesture"` +- **THEN** the speech-profile block renders gesture descriptions instead of a normal speech profile + +### Requirement: Role strategies do not leak across roles + +The role-strategy block injected into the system prompt SHALL include only the entry from `_ROLE_STRATEGIES` matching the actor's true role. Strategy text scoped to one role (e.g. wolf or madman fake-CO playbooks) MUST NOT appear in prompts built for any other role's actor. + +#### Scenario: Wolf strategy never appears for a seer prompt +- **WHEN** the prompt builder composes a system prompt for a seat whose role is `seer` +- **THEN** the wolf and madman fake-CO strategy text is absent from the rendered prompt + +#### Scenario: Villager strategy forbids self-CO declarations +- **WHEN** the prompt builder composes a system prompt for a seat whose role is `villager` +- **THEN** the rendered prompt forbids declaring `村人CO` / `素村CO` and equivalent self-declarations + +### Requirement: target_name resolves via seat token + +When an `LLMAction` carries a `target_name`, the resolver in `LLMAdapter` SHALL accept the canonical seat-token form `席{seat_no} {display_name}` (parsed by the `_SEAT_TOKEN_RE` pattern `^\s*席(\d+)\b`) and use the leading seat number as authoritative. A bare `display_name` without the seat-number prefix MUST resolve only when it is unambiguous across the alive seat list. An unresolvable or out-of-range token MUST cause the action to be treated as `skip` rather than be silently retargeted. + +#### Scenario: Seat-token prefix is authoritative +- **WHEN** an LLM returns `target_name = "席3 アリス"` and seat 3's `display_name` is `アリス` +- **THEN** the resolver maps the action to seat 3 + +#### Scenario: Duplicate display_name resolves by seat-token prefix +- **WHEN** two living seats both have `display_name = "アリス"` (one human, one persona) and an LLM returns `target_name = "席5 アリス"` +- **THEN** the resolver maps the action to seat 5, not seat 3 + +#### Scenario: Bare display_name with collision is rejected +- **WHEN** two living seats both have `display_name = "アリス"` and an LLM returns `target_name = "アリス"` without a seat-number prefix +- **THEN** the resolver does not pick a seat at random; the action is treated as `skip` + +### Requirement: Utterances respect length, language, and content invariants + +Every LLM-generated utterance SHALL be Japanese, between 80 and 300 characters inclusive, and SHALL NOT quote original Gnosia dialogue verbatim or contain meta-commentary about being an AI or about the input being structured data. + +#### Scenario: Over-length utterance is rejected upstream +- **WHEN** an LLM produces an utterance longer than 300 characters +- **THEN** the system prompt's hard rules cause the model to self-correct on retry, and a non-conforming response is logged and dropped rather than published as `PLAYER_SPEECH` + +#### Scenario: Original Gnosia dialogue is forbidden +- **WHEN** the prompt builder renders any persona's prompt +- **THEN** the rendered prompt explicitly forbids quoting original Gnosia dialogue and requires personality imitation by tone alone diff --git a/pyproject.toml b/pyproject.toml index 0529221..d1a4347 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "openai>=1.50", "httpx>=0.27", "tenacity>=8.2", + "websockets>=12", ] [project.scripts] @@ -71,5 +72,5 @@ mypy_path = "src" exclude = ["tests/.*"] [[tool.mypy.overrides]] -module = ["discord.*", "aiosqlite.*", "tenacity.*"] +module = ["discord.*", "aiosqlite.*", "tenacity.*", "websockets.*"] ignore_missing_imports = true diff --git a/src/wolfbot/config.py b/src/wolfbot/config.py index 7d48e3f..ae5f227 100644 --- a/src/wolfbot/config.py +++ b/src/wolfbot/config.py @@ -20,3 +20,9 @@ class Settings(BaseSettings): MAIN_VOICE_CHANNEL_ID: int WOLFBOT_DB_PATH: str = "./wolfbot.db" LOG_LEVEL: str = "INFO" + # Discussion mode applied to NEW games started under this process. + # Existing rows keep whichever mode was captured at their start time. + LLM_DISCUSSION_MODE: str = "rounds" + # Master ↔ NPC bot / voice-ingest WebSocket transport. + MASTER_WS_LISTEN: str = "127.0.0.1:8800" + MASTER_NPC_PSK: SecretStr | None = None diff --git a/src/wolfbot/domain/discussion.py b/src/wolfbot/domain/discussion.py new file mode 100644 index 0000000..49aff0c --- /dev/null +++ b/src/wolfbot/domain/discussion.py @@ -0,0 +1,130 @@ +"""Public discussion domain — SpeechEvent + PublicDiscussionState. + +Frozen Pydantic models that describe public utterances (human voice via STT, +human text, NPC-generated text) plus a sentinel `phase_baseline` row that +seeds the alive-seat baseline at the start of every public speech phase. + +Pure: no I/O, no asyncio. + +The `SpeechEvent` row is the unified public-utterance contract. Both +`LLM_DISCUSSION_MODE` modes (`rounds` and `reactive_voice`) write SpeechEvent +rows for every utterance, so persistence and downstream telemetry are mode- +independent. The `phase_baseline` sentinel makes the `PublicDiscussionState` +fold self-contained — the rebuild path reads only `speech_events`, never the +`seats` table — which is critical on Master restart. +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field + +from wolfbot.domain.enums import Phase + + +class SpeechSource(StrEnum): + """Origin of a `SpeechEvent` row. + + - `voice_stt`: human voice transcribed by voice-ingest's STT. + - `text`: human Discord text message captured by Master's main-channel listener. + - `npc_generated`: utterance produced by an LLM-controlled NPC seat. + - `phase_baseline`: sentinel inserted at the start of every public speech phase + to record the alive-seat set; consumed by `PublicDiscussionState` rebuild. + Excluded from public-log emission, channel posts, and downstream counts. + """ + + VOICE_STT = "voice_stt" + TEXT = "text" + NPC_GENERATED = "npc_generated" + PHASE_BASELINE = "phase_baseline" + + +class SpeakerKind(StrEnum): + HUMAN = "human" + NPC = "npc" + SYSTEM = "system" # only used by the phase_baseline sentinel + + +class SpeechEvent(BaseModel): + """A public utterance recorded by Master. + + Frozen (`ConfigDict(frozen=True)`) — never mutate after construction. Persisted + one-to-one with rows in the `speech_events` table. + + Fields specific to each `source`: + - `voice_stt`: `stt_confidence`, `audio_start_ms`, `audio_end_ms` populated; + `alive_seat_nos_json` NULL. + - `text`: `stt_confidence` / `audio_*` NULL; `alive_seat_nos_json` NULL. + - `npc_generated`: `stt_confidence` / `audio_*` NULL; `alive_seat_nos_json` NULL. + - `phase_baseline`: `text` is "" or a short marker; `speaker_seat` is None; + `speaker_kind = SpeakerKind.SYSTEM`; `alive_seat_nos_json` + populated with the JSON-encoded list of alive seat numbers. + """ + + model_config = ConfigDict(frozen=True) + + event_id: str + game_id: str + phase_id: str + day: int = Field(ge=0) + phase: Phase + source: SpeechSource + speaker_kind: SpeakerKind + speaker_seat: int | None = Field(default=None, ge=1, le=9) + text: str + stt_confidence: float | None = None + audio_start_ms: int | None = None + audio_end_ms: int | None = None + alive_seat_nos_json: str | None = None + created_at_ms: int + + def is_baseline(self) -> bool: + return self.source == SpeechSource.PHASE_BASELINE + + +def make_phase_id(game_id: str, day: int, phase: Phase, sequence: int = 1) -> str: + """Canonical `phase_id` format used across the master / voice-ingest / NPC bots. + + Example: ``g_abc::day1::DAY_DISCUSSION::1``. The `sequence` lets a single + `(game, day, phase)` triple have multiple phase_ids if a runoff causes the + discussion phase to be re-entered conceptually. + """ + return f"{game_id}::day{day}::{phase.value}::{sequence}" + + +class CoClaim(BaseModel): + """A single CO-style self-declaration captured from `SpeechEvent.text`. + + The `role_claim` follows canonical role keywords (`seer`, `medium`, `knight`). + """ + + model_config = ConfigDict(frozen=True) + + seat: int = Field(ge=1, le=9) + role_claim: str + declared_at_event_id: str + + +class PublicDiscussionState(BaseModel): + """Code-derived view of the live public discussion. + + Built deterministically from `SpeechEvent` rows of a single `phase_id`. + Never written to disk in MVP; rebuilt from the event log on Master restart. + The `phase_baseline` sentinel provides the `alive_seat_nos` baseline that + `silent_seats` is computed against — so the fold needs no access to the + `seats` table. + """ + + model_config = ConfigDict(frozen=False) + + game_id: str + phase_id: str + day: int + alive_seat_nos: frozenset[int] = frozenset() + co_claims: tuple[CoClaim, ...] = () + stances: dict[int, dict[int, float]] = Field(default_factory=dict) + pressure: dict[int, float] = Field(default_factory=dict) + open_topics: tuple[str, ...] = () + silent_seats: frozenset[int] = frozenset() + recent_speech_event_ids: tuple[str, ...] = () diff --git a/src/wolfbot/domain/models.py b/src/wolfbot/domain/models.py index e8b3b4e..c2f1cb8 100644 --- a/src/wolfbot/domain/models.py +++ b/src/wolfbot/domain/models.py @@ -54,6 +54,10 @@ class Game(BaseModel): created_at: int ended_at: int | None = None force_skip_pending: bool = False + # discussion_mode is captured at game-start time from `Settings.LLM_DISCUSSION_MODE` + # and never mutated mid-game (per the day-discussion spec). Default `rounds` + # preserves backwards-compatible behavior for old rows that lack the column. + discussion_mode: str = "rounds" class NightAction(BaseModel): diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py new file mode 100644 index 0000000..5edac89 --- /dev/null +++ b/src/wolfbot/domain/ws_messages.py @@ -0,0 +1,241 @@ +"""Master ↔ NPC and Master ↔ voice-ingest WebSocket message schemas. + +All messages share a common envelope with `type`, `ts`, and `trace_id` fields. +Pydantic v2 frozen models give us strict structural validation at the wire +boundary. Discriminator on `type` lets the server dispatch by message kind +without touching dict-shaped JSON. + +Design choices: +- One file holds every typed message so the protocol is reviewable in one diff. +- `BaseEnvelope` carries the cross-cutting fields; concrete messages inherit it. +- All payloads serialize/deserialize via `.model_dump_json()` / + `.model_validate_json()` — no hand-rolled dict shaping. +- The runtime PSK handshake is enforced by the WS server before message + parsing begins (so an unauthenticated peer cannot inject typed messages). +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class BaseEnvelope(BaseModel): + """Cross-cutting fields present on every message.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + ts: int = Field(description="Wall-clock millis at send time") + trace_id: str = Field(description="Per-flow correlation id") + + +# ---------------------------------------------------------------- NPC ↔ Master + + +class NpcRegister(BaseEnvelope): + type: Literal["npc_register"] = "npc_register" + npc_id: str + discord_bot_user_id: str + supported_voices: tuple[str, ...] = () + version: str = "0.0.0" + + +class NpcRegistered(BaseEnvelope): + type: Literal["npc_registered"] = "npc_registered" + npc_id: str + assigned_seat: int | None = None + game_id: str | None = None + phase_id: str | None = None + + +class Heartbeat(BaseEnvelope): + type: Literal["heartbeat"] = "heartbeat" + npc_id: str | None = None # populated by NPC bots; None for voice-ingest + + +class LogicCandidate(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + id: str + claim: str + support: tuple[str, ...] = () + counter: tuple[str, ...] = () + + +class LogicPacket(BaseEnvelope): + type: Literal["logic_packet"] = "logic_packet" + packet_id: str + phase_id: str + recipient_npc_id: str + public_state_summary: str + logic_candidates: tuple[LogicCandidate, ...] = () + pressure: dict[int, float] = Field(default_factory=dict) + expires_at_ms: int + + +class SpeakRequest(BaseEnvelope): + type: Literal["speak_request"] = "speak_request" + request_id: str + phase_id: str + npc_id: str + seat_no: int + logic_packet_id: str + suggested_intent: str + max_chars: int = 80 + max_duration_ms: int = 8000 + priority: int = 0 + expires_at_ms: int + + +class SpeakResult(BaseEnvelope): + type: Literal["speak_result"] = "speak_result" + request_id: str + npc_id: str + phase_id: str + status: Literal["accepted", "declined", "error"] + text: str | None = None + used_logic_ids: tuple[str, ...] = () + intent: str | None = None + estimated_duration_ms: int | None = None + failure_reason: str | None = None + + +class PlaybackAuthorized(BaseEnvelope): + type: Literal["playback_authorized"] = "playback_authorized" + request_id: str + npc_id: str + status: Literal["authorized"] = "authorized" + speech_event_id: str + playback_deadline_ms: int + + +class PlaybackRejected(BaseEnvelope): + type: Literal["playback_rejected"] = "playback_rejected" + request_id: str + npc_id: str + status: Literal["rejected"] = "rejected" + failure_reason: str + + +class TtsFinished(BaseEnvelope): + type: Literal["tts_finished"] = "tts_finished" + request_id: str + npc_id: str + tts_duration_ms: int + audio_size_bytes: int + + +class TtsFailed(BaseEnvelope): + type: Literal["tts_failed"] = "tts_failed" + request_id: str + npc_id: str + failure_reason: str + + +class PlaybackFinished(BaseEnvelope): + type: Literal["playback_finished"] = "playback_finished" + request_id: str + npc_id: str + started_at_ms: int + finished_at_ms: int + + +class PlaybackFailed(BaseEnvelope): + type: Literal["playback_failed"] = "playback_failed" + request_id: str + npc_id: str + failure_reason: str + + +# --------------------------------------------------- voice-ingest ↔ Master + + +class VadSpeechStarted(BaseEnvelope): + type: Literal["vad_speech_started"] = "vad_speech_started" + game_id: str + phase_id: str + speaker_discord_user_id: str + seat_no: int + segment_id: str + audio_start_ms: int + + +class VadSpeechEnded(BaseEnvelope): + type: Literal["vad_speech_ended"] = "vad_speech_ended" + game_id: str + phase_id: str + speaker_discord_user_id: str + seat_no: int + segment_id: str + audio_end_ms: int + + +class SpeechEventPayload(BaseEnvelope): + type: Literal["speech_event_payload"] = "speech_event_payload" + game_id: str + phase_id: str + seat_no: int + speaker_discord_user_id: str + segment_id: str + text: str + confidence: float + duration_ms: int + audio_start_ms: int + audio_end_ms: int + + +class SttFailed(BaseEnvelope): + type: Literal["stt_failed"] = "stt_failed" + game_id: str + phase_id: str + speaker_discord_user_id: str + seat_no: int + segment_id: str + failure_reason: str + + +class RegistrySnapshot(BaseEnvelope): + type: Literal["registry_snapshot"] = "registry_snapshot" + npc_user_ids: tuple[str, ...] + + +class RegistryUpdate(BaseEnvelope): + type: Literal["registry_update"] = "registry_update" + added: tuple[str, ...] = () + removed: tuple[str, ...] = () + + +# ---------------------------------------------------------------- Errors + + +class HandshakeError(BaseEnvelope): + """Sent before connection close on PSK failure or malformed handshake.""" + + type: Literal["handshake_error"] = "handshake_error" + failure_reason: str + + +__all__ = [ + "BaseEnvelope", + "HandshakeError", + "Heartbeat", + "LogicCandidate", + "LogicPacket", + "NpcRegister", + "NpcRegistered", + "PlaybackAuthorized", + "PlaybackFailed", + "PlaybackFinished", + "PlaybackRejected", + "RegistrySnapshot", + "RegistryUpdate", + "SpeakRequest", + "SpeakResult", + "SpeechEventPayload", + "SttFailed", + "TtsFailed", + "TtsFinished", + "VadSpeechEnded", + "VadSpeechStarted", +] diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 1d2dde8..524d0b6 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -6,6 +6,7 @@ import contextlib import logging import signal +from typing import Any import discord from discord.ext import commands @@ -15,6 +16,7 @@ from wolfbot.persistence.schema import migrate from wolfbot.persistence.sqlite_repo import SqliteRepo from wolfbot.services.discord_service import DiscordBotAdapter, WolfCog +from wolfbot.services.discussion_service import DiscussionService, SqliteSpeechEventStore from wolfbot.services.game_service import GameService from wolfbot.services.llm_service import LLMAdapter, make_xai_decider from wolfbot.services.recovery_service import RecoveryService @@ -42,15 +44,60 @@ async def _run() -> None: registry = EngineRegistry() discord_adapter = DiscordBotAdapter(bot=bot, repo=repo, settings=settings) + + speech_store = SqliteSpeechEventStore(repo._db) + + class _DiscussionPoster: + """Adapts DiscordBotAdapter.post_public(Game, ...) to the + SpeechMessagePoster protocol which takes a bare game_id string. + Loads the Game from the repo so the adapter gets the channel id + it needs. + """ + + async def post_public(self, game_id: str, text: str, kind: str) -> None: + game = await repo.load_game(game_id) + if game is None: + return + await discord_adapter.post_public(game, text, kind) + + discussion_service = DiscussionService( + store=speech_store, + log_sink=repo, + message_poster=_DiscussionPoster(), + ) + decider = make_xai_decider( api_key=settings.XAI_API_KEY.get_secret_value(), model=settings.XAI_MODEL, ) - llm_adapter = LLMAdapter(repo=repo, decider=decider, message_poster=discord_adapter) - game_service = GameService(repo=repo, discord=discord_adapter, llm=llm_adapter, wake=registry) + llm_adapter = LLMAdapter( + repo=repo, + decider=decider, + message_poster=discord_adapter, + discussion_service=discussion_service, + ) + # The reactive_phase_enter callback is set below once the arbiter exists. + _reactive_phase_cb: list[Any] = [] + + async def _on_reactive_phase_enter(game_id: str) -> None: + if _reactive_phase_cb: + await _reactive_phase_cb[0].try_dispatch_next(game_id) + + game_service = GameService( + repo=repo, + discord=discord_adapter, + llm=llm_adapter, + wake=registry, + on_reactive_phase_enter=_on_reactive_phase_enter, + ) discord_adapter.set_game_service(game_service) llm_adapter.set_game_service(game_service) + async def _on_speech_recorded(game_id: str) -> None: + """After a text SpeechEvent is recorded, try dispatching an NPC reply.""" + if _reactive_phase_cb: + await _reactive_phase_cb[0].try_dispatch_next(game_id) + cog = WolfCog( bot=bot, repo=repo, @@ -59,6 +106,8 @@ async def _run() -> None: llm_adapter=llm_adapter, registry=registry, settings=settings, + discussion_service=discussion_service, + on_speech_recorded=_on_speech_recorded, ) await bot.add_cog(cog) bot.tree.add_command(cog.wolf, guild=discord.Object(id=settings.DISCORD_GUILD_ID)) @@ -71,6 +120,136 @@ async def _run() -> None: ) recovery_done = asyncio.Event() + # ---- reactive_voice pipeline (Master WS + NPC registry + arbiter) ---- + # Constructed when MASTER_NPC_PSK is set, which signals that the operator + # wants the WS transport running. Without a PSK the pipeline stays off and + # reactive_voice games simply rely on the deadline gate (no NPC speech). + ws_server: Any = None + if settings.MASTER_NPC_PSK is not None: + from wolfbot.services.master_ingest_service import MasterIngestService + from wolfbot.services.master_ws_server import ( + MasterHandlers, + WebsocketsMasterWsServer, + ) + from wolfbot.services.npc_registry import InMemoryNpcRegistry + from wolfbot.services.speak_arbiter import SpeakArbiter + + npc_registry = InMemoryNpcRegistry() + + arbiter = SpeakArbiter( + repo=repo, + registry=npc_registry, + discussion=discussion_service, + ) + _reactive_phase_cb.append(arbiter) + recovery._reactive_voice_sweep = arbiter.reactive_voice_recovery_sweep + + class _RepoPhase: + """Adapts SqliteRepo to MasterIngestService.PhaseLookup.""" + + async def get_phase(self, game_id: str) -> tuple[Any, int] | None: + g = await repo.load_game(game_id) + if g is None or g.ended_at is not None: + return None + return (g.phase, g.day_number) + + async def get_alive_seat_nos(self, game_id: str) -> list[int]: + players = await repo.load_players(game_id) + return sorted(p.seat_no for p in players if p.alive) + + ingest_service = MasterIngestService( + registry=npc_registry, + discussion=discussion_service, + phase_lookup=_RepoPhase(), + ) + + async def _on_speak_result(msg: Any, _ctx: Any) -> None: + g = await repo.load_game(msg.game_id) if hasattr(msg, "game_id") else None + # Resolve game from the pending request inside the arbiter + from wolfbot.domain.discussion import make_phase_id as _mkpi + + pending = arbiter._pending.get(msg.request_id) + if pending is None: + return + g = await repo.load_game(pending.game_id) + if g is None or g.ended_at is not None: + return + accepted, _reason = await arbiter.handle_speak_result( + msg, + current_phase_id=_mkpi(g.id, g.day_number, g.phase), + day=g.day_number, + phase=g.phase, + ) + if accepted: + log.info("speak_result_accepted npc=%s game=%s", msg.npc_id, g.id) + + async def _on_tts_finished(msg: Any, _ctx: Any) -> None: + await arbiter.handle_tts_finished(msg) + + async def _on_tts_failed(msg: Any, _ctx: Any) -> None: + await arbiter.handle_tts_failed(msg) + # Gate cleared — try dispatching next NPC. + pending = arbiter._pending.get(msg.request_id) + if pending is not None: + await arbiter.try_dispatch_next(pending.game_id) + + async def _on_playback_finished(msg: Any, _ctx: Any) -> None: + # Resolve game_id before the pending entry is popped. + pending = arbiter._pending.get(msg.request_id) + game_id = pending.game_id if pending is not None else None + await arbiter.handle_playback_finished(msg) + # Gate cleared — try dispatching next NPC. + if game_id is not None: + await arbiter.try_dispatch_next(game_id) + + async def _on_playback_failed(msg: Any, _ctx: Any) -> None: + pending = arbiter._pending.get(msg.request_id) + game_id = pending.game_id if pending is not None else None + await arbiter.handle_playback_failed(msg) + if game_id is not None: + await arbiter.try_dispatch_next(game_id) + + async def _on_speech_payload(msg: Any, _ctx: Any) -> None: + await ingest_service.ingest_voice(msg) + # After a new human speech event lands, try dispatching an NPC reply. + if hasattr(msg, "game_id") and msg.game_id: + await arbiter.try_dispatch_next(msg.game_id) + + async def _on_vad_started(msg: Any, _ctx: Any) -> None: + segment_id = getattr(msg, "segment_id", None) or "unknown" + arbiter.mark_human_speaking(segment_id) + + async def _on_vad_ended(msg: Any, _ctx: Any) -> None: + segment_id = getattr(msg, "segment_id", None) or "unknown" + arbiter.clear_human_speaking(segment_id) + # Human stopped speaking — after STT completes, the speech_event + # callback will trigger dispatch. But also try now in case no STT + # event follows (e.g. low-confidence drop). + if hasattr(msg, "game_id") and msg.game_id: + await arbiter.try_dispatch_next(msg.game_id) + + master_handlers = MasterHandlers( + registry=npc_registry, + on_speak_result=_on_speak_result, + on_tts_finished=_on_tts_finished, + on_tts_failed=_on_tts_failed, + on_playback_finished=_on_playback_finished, + on_playback_failed=_on_playback_failed, + on_speech_event_payload=_on_speech_payload, + on_vad_started=_on_vad_started, + on_vad_ended=_on_vad_ended, + ) + + host, port_str = settings.MASTER_WS_LISTEN.rsplit(":", 1) + ws_server = WebsocketsMasterWsServer( + host=host, + port=int(port_str), + psk=settings.MASTER_NPC_PSK.get_secret_value(), + registry=npc_registry, + handlers=master_handlers, + ) + log.info("reactive_voice pipeline wired, WS will listen on %s", settings.MASTER_WS_LISTEN) + @bot.event async def on_ready() -> None: guild = discord.Object(id=settings.DISCORD_GUILD_ID) @@ -82,6 +261,9 @@ async def on_ready() -> None: if recovery_done.is_set(): return recovery_done.set() + if ws_server is not None: + await ws_server.start() + log.info("master WS server started on %s", settings.MASTER_WS_LISTEN) recovered = await recovery.recover_all() log.info("recovered %d game(s)", len(recovered)) @@ -104,6 +286,9 @@ def _handle_sigterm() -> None: log.info("stopping engines...") await registry.stop_all() + if ws_server is not None: + log.info("stopping master WS server...") + await ws_server.stop() log.info("closing bot...") await bot.close() bot_task.cancel() diff --git a/src/wolfbot/npc_bot_main.py b/src/wolfbot/npc_bot_main.py new file mode 100644 index 0000000..e153ae3 --- /dev/null +++ b/src/wolfbot/npc_bot_main.py @@ -0,0 +1,69 @@ +"""NPC bot worker entrypoint. + +Reads `NPC_*` env vars, builds an `NpcClient`, connects to Master, and +parks on the asyncio loop. The actual Discord-side bot connection is +deferred to a real implementation; this entrypoint focuses on the +configurable wiring and is exercised by integration tests via the +`NpcClient` API directly. + +Run with: + + uv run python -m wolfbot.npc_bot_main + +(after exporting the NPC env vars described in the proposal — `NPC_ID`, +`NPC_DISCORD_TOKEN`, `MASTER_WS_URL`, `MASTER_NPC_PSK`, `TTS_VOICE_ID`, +`TTS_PROVIDER`, etc.) +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os + +log = logging.getLogger(__name__) + + +def _read_env(name: str, *, required: bool = True, default: str | None = None) -> str: + val = os.environ.get(name, default) + if required and not val: + raise SystemExit(f"missing required env var {name}") + return val or "" + + +async def _main() -> None: + """Wire the worker. Implementation deliberately minimal — production code + will plug in the real Discord client + websockets transport. This + function is exercised in tests via component-level setups.""" + npc_id = _read_env("NPC_ID") + discord_token = _read_env("NPC_DISCORD_TOKEN") + master_ws_url = _read_env("MASTER_WS_URL") + psk_set = bool(_read_env("MASTER_NPC_PSK")) + voice_id = _read_env("TTS_VOICE_ID", required=False, default="ja-JP-Standard-A") + + log.info( + "npc_bot_starting npc_id=%s ws_url=%s discord_token_set=%s psk_set=%s voice=%s", + npc_id, + master_ws_url, + bool(discord_token), + psk_set, + voice_id, + ) + log.info("npc_bot_main is a wiring stub — production wiring is implemented elsewhere") + # The production wiring is deferred to a follow-up change; the worker + # is fully exercised through `NpcClient` from unit / integration tests. + raise SystemExit(0) + + +def main() -> None: + logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO")) + with contextlib.suppress(KeyboardInterrupt): + asyncio.run(_main()) + + +if __name__ == "__main__": # pragma: no cover + main() + + +__all__ = ["main"] diff --git a/src/wolfbot/persistence/schema.py b/src/wolfbot/persistence/schema.py index 7b359af..e49f335 100644 --- a/src/wolfbot/persistence/schema.py +++ b/src/wolfbot/persistence/schema.py @@ -24,7 +24,8 @@ wolves_channel_id TEXT, created_at INTEGER NOT NULL, ended_at INTEGER, - force_skip_pending INTEGER NOT NULL DEFAULT 0 + force_skip_pending INTEGER NOT NULL DEFAULT 0, + discussion_mode TEXT NOT NULL DEFAULT 'rounds' ) """, """ @@ -150,6 +151,93 @@ PRIMARY KEY (game_id, day, seat_no) ) """, + """ + CREATE TABLE IF NOT EXISTS speech_events ( + event_id TEXT PRIMARY KEY, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + phase_id TEXT NOT NULL, + day INTEGER NOT NULL, + phase TEXT NOT NULL, + source TEXT NOT NULL, + speaker_kind TEXT NOT NULL, + speaker_seat INTEGER, + text TEXT NOT NULL, + stt_confidence REAL, + audio_start_ms INTEGER, + audio_end_ms INTEGER, + alive_seat_nos_json TEXT, + created_at_ms INTEGER NOT NULL + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_speech_events_phase + ON speech_events(game_id, phase_id, created_at_ms) + """, + """ + CREATE INDEX IF NOT EXISTS idx_speech_events_seat + ON speech_events(game_id, phase_id, speaker_seat) + """, + """ + CREATE TABLE IF NOT EXISTS npc_speak_requests ( + request_id TEXT PRIMARY KEY, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + phase_id TEXT NOT NULL, + npc_id TEXT NOT NULL, + seat_no INTEGER NOT NULL, + logic_packet_id TEXT NOT NULL, + suggested_intent TEXT NOT NULL, + max_chars INTEGER NOT NULL, + max_duration_ms INTEGER NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + expires_at_ms INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_npc_speak_requests_game_phase + ON npc_speak_requests(game_id, phase_id, expires_at_ms) + """, + """ + CREATE TABLE IF NOT EXISTS npc_speak_results ( + request_id TEXT PRIMARY KEY, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + phase_id TEXT NOT NULL, + npc_id TEXT NOT NULL, + status TEXT NOT NULL, + text TEXT, + used_logic_ids_json TEXT, + intent TEXT, + estimated_duration_ms INTEGER, + failure_reason TEXT, + received_at_ms INTEGER NOT NULL + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_npc_speak_results_phase + ON npc_speak_results(game_id, phase_id, received_at_ms) + """, + """ + CREATE TABLE IF NOT EXISTS npc_playback_events ( + request_id TEXT PRIMARY KEY, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + phase_id TEXT NOT NULL, + npc_id TEXT NOT NULL, + speech_event_id TEXT, + authorized_at_ms INTEGER NOT NULL, + playback_deadline_ms INTEGER NOT NULL, + finished_at_ms INTEGER, + outcome TEXT, + failure_reason TEXT, + tts_outcome TEXT, + tts_duration_ms INTEGER, + tts_failure_reason TEXT + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_npc_playback_events_open + ON npc_playback_events(game_id, finished_at_ms) + WHERE finished_at_ms IS NULL + """, ] @@ -173,6 +261,10 @@ async def migrate(db_path: str | Path) -> None: await db.execute( "ALTER TABLE games ADD COLUMN force_skip_pending INTEGER NOT NULL DEFAULT 0" ) + if "discussion_mode" not in cols: + await db.execute( + "ALTER TABLE games ADD COLUMN discussion_mode TEXT NOT NULL DEFAULT 'rounds'" + ) async with db.execute("PRAGMA table_info(seats)") as cur: cols = {row[1] async for row in cur} if "dm_channel_id" not in cols: diff --git a/src/wolfbot/persistence/sqlite_repo.py b/src/wolfbot/persistence/sqlite_repo.py index 53e8b9c..a392440 100644 --- a/src/wolfbot/persistence/sqlite_repo.py +++ b/src/wolfbot/persistence/sqlite_repo.py @@ -53,6 +53,13 @@ class LeaveLobbyResult(StrEnum): def _row_to_game(row: aiosqlite.Row) -> Game: + discussion_mode = "rounds" + try: + # Old DBs migrated in place may lack the column momentarily during + # boot — guard so we keep the default in that race. + discussion_mode = row["discussion_mode"] or "rounds" + except (IndexError, KeyError): + discussion_mode = "rounds" return Game( id=row["id"], guild_id=row["guild_id"], @@ -67,6 +74,7 @@ def _row_to_game(row: aiosqlite.Row) -> Game: created_at=row["created_at"], ended_at=row["ended_at"], force_skip_pending=bool(row["force_skip_pending"]), + discussion_mode=discussion_mode, ) @@ -204,8 +212,9 @@ async def create_game(self, game: Game) -> None: INSERT INTO games (id, guild_id, host_user_id, phase, day_number, deadline_epoch, main_text_channel_id, main_vc_channel_id, heaven_channel_id, wolves_channel_id, - created_at, ended_at, force_skip_pending) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + created_at, ended_at, force_skip_pending, + discussion_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( game.id, @@ -221,6 +230,7 @@ async def create_game(self, game: Game) -> None: game.created_at, game.ended_at, int(game.force_skip_pending), + game.discussion_mode, ), ) except aiosqlite.IntegrityError as e: @@ -933,6 +943,201 @@ async def claim_start_and_backfill( return False return True + # ---------------------------------------------------- npc audit tables + # + # Master is the sole writer for npc_speak_requests / npc_speak_results / + # npc_playback_events. These tables are append-or-update audit trails; + # the live arbiter does not depend on them after a restart, but the + # recovery sweep needs them to close in-flight rows on Master restart. + + async def insert_npc_speak_request( + self, + *, + request_id: str, + game_id: str, + phase_id: str, + npc_id: str, + seat_no: int, + logic_packet_id: str, + suggested_intent: str, + max_chars: int, + max_duration_ms: int, + priority: int, + expires_at_ms: int, + created_at_ms: int, + ) -> None: + await self._db.execute( + """ + INSERT INTO npc_speak_requests ( + request_id, game_id, phase_id, npc_id, seat_no, + logic_packet_id, suggested_intent, max_chars, max_duration_ms, + priority, expires_at_ms, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + request_id, + game_id, + phase_id, + npc_id, + seat_no, + logic_packet_id, + suggested_intent, + max_chars, + max_duration_ms, + priority, + expires_at_ms, + created_at_ms, + ), + ) + await self._db.commit() + + async def insert_npc_speak_result( + self, + *, + request_id: str, + game_id: str, + phase_id: str, + npc_id: str, + status: str, + text: str | None, + used_logic_ids: Sequence[str] | None, + intent: str | None, + estimated_duration_ms: int | None, + failure_reason: str | None, + received_at_ms: int, + ) -> None: + await self._db.execute( + """ + INSERT OR REPLACE INTO npc_speak_results ( + request_id, game_id, phase_id, npc_id, status, text, + used_logic_ids_json, intent, estimated_duration_ms, + failure_reason, received_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + request_id, + game_id, + phase_id, + npc_id, + status, + text, + json.dumps(list(used_logic_ids)) if used_logic_ids else None, + intent, + estimated_duration_ms, + failure_reason, + received_at_ms, + ), + ) + await self._db.commit() + + async def open_npc_playback( + self, + *, + request_id: str, + game_id: str, + phase_id: str, + npc_id: str, + speech_event_id: str, + authorized_at_ms: int, + playback_deadline_ms: int, + ) -> None: + await self._db.execute( + """ + INSERT INTO npc_playback_events ( + request_id, game_id, phase_id, npc_id, speech_event_id, + authorized_at_ms, playback_deadline_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + request_id, + game_id, + phase_id, + npc_id, + speech_event_id, + authorized_at_ms, + playback_deadline_ms, + ), + ) + await self._db.commit() + + async def update_npc_playback_tts( + self, + request_id: str, + *, + outcome: str, + duration_ms: int | None, + failure_reason: str | None, + ) -> None: + await self._db.execute( + """ + UPDATE npc_playback_events + SET tts_outcome=?, tts_duration_ms=?, tts_failure_reason=? + WHERE request_id=? + """, + (outcome, duration_ms, failure_reason, request_id), + ) + await self._db.commit() + + async def close_npc_playback( + self, + request_id: str, + *, + finished_at_ms: int, + outcome: str, + failure_reason: str | None, + ) -> None: + await self._db.execute( + """ + UPDATE npc_playback_events + SET finished_at_ms=?, outcome=?, failure_reason=? + WHERE request_id=? AND finished_at_ms IS NULL + """, + (finished_at_ms, outcome, failure_reason, request_id), + ) + await self._db.commit() + + async def load_open_npc_speak_requests(self, game_id: str) -> list[dict[str, Any]]: + async with self._db.execute( + """ + SELECT r.request_id, r.npc_id, r.phase_id, r.expires_at_ms + FROM npc_speak_requests r + LEFT JOIN npc_speak_results s USING (request_id) + WHERE r.game_id=? AND s.request_id IS NULL + """, + (game_id,), + ) as cur: + rows = await cur.fetchall() + return [ + { + "request_id": row[0], + "npc_id": row[1], + "phase_id": row[2], + "expires_at_ms": row[3], + } + for row in rows + ] + + async def load_open_npc_playback(self, game_id: str) -> list[dict[str, Any]]: + async with self._db.execute( + """ + SELECT request_id, npc_id, phase_id, speech_event_id, playback_deadline_ms + FROM npc_playback_events + WHERE game_id=? AND finished_at_ms IS NULL + """, + (game_id,), + ) as cur: + rows = await cur.fetchall() + return [ + { + "request_id": row[0], + "npc_id": row[1], + "phase_id": row[2], + "speech_event_id": row[3], + "playback_deadline_ms": row[4], + } + for row in rows + ] + class _LobbyClaimAborted(Exception): """Base for conditions that roll back a LOBBY-claim tx to False. diff --git a/src/wolfbot/services/discord_service.py b/src/wolfbot/services/discord_service.py index d042110..4cba56c 100644 --- a/src/wolfbot/services/discord_service.py +++ b/src/wolfbot/services/discord_service.py @@ -15,8 +15,9 @@ import asyncio import logging import time -from collections.abc import Sequence +from collections.abc import Awaitable, Callable, Sequence from random import Random +from typing import TYPE_CHECKING import discord from discord import app_commands @@ -48,6 +49,9 @@ from wolfbot.services.timer_service import EngineRegistry, GameEngine from wolfbot.ui.views import NightActionView, VoteView +if TYPE_CHECKING: + from wolfbot.services.discussion_service import DiscussionService + log = logging.getLogger(__name__) @@ -410,6 +414,8 @@ def __init__( registry: EngineRegistry, settings: Settings, rng: Random | None = None, + discussion_service: DiscussionService | None = None, + on_speech_recorded: Callable[[str], Awaitable[None]] | None = None, ) -> None: super().__init__() self.bot = bot @@ -421,6 +427,8 @@ def __init__( self.settings = settings self.rng = rng or Random() self._create_locks: dict[str, asyncio.Lock] = {} + self._discussion_service = discussion_service + self._on_speech_recorded = on_speech_recorded def _create_lock_for(self, guild_id: str) -> asyncio.Lock: lock = self._create_locks.get(guild_id) @@ -449,24 +457,66 @@ async def on_message(self, message: discord.Message) -> None: author_seat = await self.repo.seat_of_user(game.id, str(message.author.id)) players = await self.repo.load_players(game.id) - if is_main and game.phase is Phase.DAY_DISCUSSION: + if is_main and game.phase in (Phase.DAY_DISCUSSION, Phase.DAY_RUNOFF_SPEECH): if not _main_channel_should_llm_react(author_seat, players): return - try: - await self.repo.insert_log_public( - LogEntry( + if self._discussion_service is not None and author_seat is not None: + # Full SpeechEvent path: record() persists the event row AND + # emits the PLAYER_SPEECH LogEntry (for source=text it skips + # the channel post since the original message is already visible). + try: + from wolfbot.domain.discussion import make_phase_id + from wolfbot.services.discussion_service import make_human_text_event + + alive_seat_nos = sorted(p.seat_no for p in players if p.alive) + await self._discussion_service.begin_phase_if_absent( game_id=game.id, day=game.day_number, phase=game.phase, - kind="PLAYER_SPEECH", - actor_seat=author_seat, - visibility="PUBLIC", + alive_seat_nos=alive_seat_nos, + ) + phase_id = make_phase_id(game.id, game.day_number, game.phase) + event = make_human_text_event( + game_id=game.id, + phase_id=phase_id, + day=game.day_number, + phase=game.phase, + speaker_seat=author_seat, text=message.content, - created_at=int(time.time()), ) - ) - except Exception: - log.exception("PLAYER_SPEECH log insert failed for %s", game.id) + await self._discussion_service.record(event) + # Trigger arbiter dispatch so NPCs can respond to new text. + if self._on_speech_recorded is not None: + try: + await self._on_speech_recorded(game.id) + except Exception: + log.exception( + "on_speech_recorded callback failed game=%s", game.id + ) + except Exception: + log.exception( + "SpeechEvent(text) write failed for game=%s seat=%s", + game.id, + author_seat, + ) + else: + # Legacy path: no DiscussionService wired (or spectator with + # no seat). Fall back to direct PLAYER_SPEECH log insert. + try: + await self.repo.insert_log_public( + LogEntry( + game_id=game.id, + day=game.day_number, + phase=game.phase, + kind="PLAYER_SPEECH", + actor_seat=author_seat, + visibility="PUBLIC", + text=message.content, + created_at=int(time.time()), + ) + ) + except Exception: + log.exception("PLAYER_SPEECH log insert failed for %s", game.id) return if is_wolves and game.phase is Phase.NIGHT and author_seat is not None: @@ -556,6 +606,13 @@ async def create(self, interaction: discord.Interaction) -> None: await interaction.followup.send(create_failed_msg) return + discussion_mode = self.settings.LLM_DISCUSSION_MODE + if discussion_mode not in ("rounds", "reactive_voice"): + log.warning( + "LLM_DISCUSSION_MODE=%r invalid, falling back to 'rounds'", + discussion_mode, + ) + discussion_mode = "rounds" game = Game( id=new_game_id(), guild_id=guild_id, @@ -567,6 +624,7 @@ async def create(self, interaction: discord.Interaction) -> None: heaven_channel_id=str(heaven.id), wolves_channel_id=str(wolves.id), created_at=int(time.time()), + discussion_mode=discussion_mode, ) try: await self.repo.create_game(game) diff --git a/src/wolfbot/services/discussion_phase_summary.py b/src/wolfbot/services/discussion_phase_summary.py new file mode 100644 index 0000000..36e7d09 --- /dev/null +++ b/src/wolfbot/services/discussion_phase_summary.py @@ -0,0 +1,138 @@ +"""Compute and emit the `discussion_phase_summary` event for a finished phase. + +Aggregates counts from `speech_events` (human vs npc) and the npc-audit +tables (`npc_speak_*`, `npc_playback_events`) so the summary reflects the +full reactive_voice flow. Emit at the end of every public-speech phase +(`DAY_DISCUSSION`, `DAY_RUNOFF_SPEECH`) regardless of mode. +""" + +from __future__ import annotations + +import logging + +from wolfbot.domain.discussion import SpeechSource +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discussion_service import DiscussionService +from wolfbot.services.structured_logging import ( + COMPONENT_MASTER, + build_discussion_phase_summary, + emit_event, +) + +logger = logging.getLogger("wolfbot") + + +async def emit_phase_summary( + *, + repo: SqliteRepo, + discussion: DiscussionService, + game_id: str, + phase_id: str, + mode: str, +) -> dict[str, int]: + """Read speech_events + npc_* audit rows, build + emit the summary. + + Returns the counts dict so tests can assert without re-parsing logs. + """ + events = await discussion.load_phase(game_id, phase_id) + human = sum(1 for e in events if e.source in (SpeechSource.TEXT, SpeechSource.VOICE_STT)) + npc = sum(1 for e in events if e.source == SpeechSource.NPC_GENERATED) + total = human + npc # phase_baseline excluded + + # Reactive-voice telemetry from audit tables. Counts come from joining + # over the request_id; we do simple SQL aggregates here to avoid + # loading every row. + async with repo._db.execute( + """ + SELECT + COUNT(*) AS speak_requests_sent, + (SELECT COUNT(*) FROM npc_speak_results + WHERE game_id=? AND phase_id=? AND status='accepted') + AS speak_results_accepted, + (SELECT COUNT(*) FROM npc_speak_results + WHERE game_id=? AND phase_id=? AND status='rejected') + AS speak_results_rejected, + (SELECT COUNT(*) FROM npc_playback_events + WHERE game_id=? AND phase_id=?) + AS playback_authorized, + (SELECT COUNT(*) FROM npc_playback_events + WHERE game_id=? AND phase_id=? AND tts_outcome='success') + AS tts_success, + (SELECT COUNT(*) FROM npc_playback_events + WHERE game_id=? AND phase_id=? AND tts_outcome='failed') + AS tts_failed, + (SELECT COUNT(*) FROM npc_playback_events + WHERE game_id=? AND phase_id=? AND outcome='succeeded') + AS playback_success, + (SELECT COUNT(*) FROM npc_playback_events + WHERE game_id=? AND phase_id=? AND outcome='failed') + AS playback_failed, + (SELECT COUNT(*) FROM npc_speak_results + WHERE game_id=? AND phase_id=? AND failure_reason IN ('stale_phase','expired_request','master_restart')) + AS stale_dropped + FROM npc_speak_requests + WHERE game_id=? AND phase_id=? + """, + ( + game_id, + phase_id, # speak_results_accepted + game_id, + phase_id, # speak_results_rejected + game_id, + phase_id, # playback_authorized + game_id, + phase_id, # tts_success + game_id, + phase_id, # tts_failed + game_id, + phase_id, # playback_success + game_id, + phase_id, # playback_failed + game_id, + phase_id, # stale_dropped + game_id, + phase_id, # speak_requests_sent (outer) + ), + ) as cur: + row = await cur.fetchone() + + aggregates: dict[str, int] = {} + if row is not None: + aggregates = { + "speak_requests_sent": row[0] or 0, + "speak_results_accepted": row[1] or 0, + "speak_results_rejected": row[2] or 0, + "playback_authorized": row[3] or 0, + "tts_success": row[4] or 0, + "tts_failed": row[5] or 0, + "playback_success": row[6] or 0, + "playback_failed": row[7] or 0, + "stale_dropped": row[8] or 0, + } + + payload = build_discussion_phase_summary( + game_id=game_id, + phase_id=phase_id, + mode=mode, + speech_events_total=total, + human_speech_events=human, + npc_speech_events=npc, + **aggregates, + ) + emit_event( + logger, + component=COMPONENT_MASTER, + event="discussion_phase_summary", + game_id=game_id, + phase_id=phase_id, + **{k: v for k, v in payload.items() if k not in ("game_id", "phase_id")}, + ) + return { + "speech_events_total": total, + "human_speech_events": human, + "npc_speech_events": npc, + **aggregates, + } + + +__all__ = ["emit_phase_summary"] diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py new file mode 100644 index 0000000..6122f00 --- /dev/null +++ b/src/wolfbot/services/discussion_service.py @@ -0,0 +1,576 @@ +"""SpeechEvent persistence + write hooks for `PLAYER_SPEECH` log emission. + +This module is the on-Master ingestion seam for every public utterance, +regardless of source. The contract: + + write(SpeechEvent) → INSERT speech_events row + → emit PLAYER_SPEECH `LogEntry` + → post to main text channel via MessagePoster + (both sub-effects skipped per source — see below) + +`source = phase_baseline` rows are sentinels; they receive no LogEntry and no +channel post, and are filtered out of every downstream consumer count. + +`source = text` rows already exist in Discord as the player's original main- +channel message, so the channel post is skipped to avoid duplication. The +`PLAYER_SPEECH` LogEntry is still emitted so post-game replay sees a single +canonical timeline regardless of source. + +`source ∈ {voice_stt, npc_generated}` rows always emit both the LogEntry and +the channel post — these utterances do NOT yet exist in the main channel and +need to be visible to text-only observers. + +The Sqlite implementation reuses `SqliteRepo`'s open connection so writes +serialize naturally with the rest of Master's state. +""" + +from __future__ import annotations + +import json +import logging +import time +import uuid +from collections.abc import Iterable, Sequence +from typing import Any, Protocol, runtime_checkable + +import aiosqlite + +from wolfbot.domain.discussion import ( + PublicDiscussionState, + SpeakerKind, + SpeechEvent, + SpeechSource, + make_phase_id, +) +from wolfbot.domain.enums import Phase +from wolfbot.domain.models import LogEntry + +log = logging.getLogger(__name__) + + +# ---------------------------------------------------------------------- Protocols + + +@runtime_checkable +class SpeechEventStore(Protocol): + """Persistence surface for `SpeechEvent`. Tests substitute an in-memory fake.""" + + async def insert(self, event: SpeechEvent) -> None: ... + + async def load_phase(self, game_id: str, phase_id: str) -> Sequence[SpeechEvent]: ... + + async def load_for_game(self, game_id: str) -> Sequence[SpeechEvent]: ... + + +@runtime_checkable +class SpeechMessagePoster(Protocol): + """Subset of DiscordBotAdapter used by the write hook for main-channel posts.""" + + async def post_public(self, game_id: str, text: str, kind: str) -> None: ... + + +@runtime_checkable +class PublicLogSink(Protocol): + """`SqliteRepo.insert_log_public` shape, decoupled for testability.""" + + async def insert_log_public(self, entry: LogEntry) -> None: ... + + +# ---------------------------------------------------------------------- Sqlite store + + +class SqliteSpeechEventStore: + """SQLite-backed `SpeechEventStore`. Reuses an existing aiosqlite connection.""" + + def __init__(self, conn: aiosqlite.Connection) -> None: + self._conn = conn + + async def insert(self, event: SpeechEvent) -> None: + await self._conn.execute( + """ + INSERT INTO speech_events ( + event_id, game_id, phase_id, day, phase, source, speaker_kind, + speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, + alive_seat_nos_json, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event.event_id, + event.game_id, + event.phase_id, + event.day, + event.phase.value, + event.source.value, + event.speaker_kind.value, + event.speaker_seat, + event.text, + event.stt_confidence, + event.audio_start_ms, + event.audio_end_ms, + event.alive_seat_nos_json, + event.created_at_ms, + ), + ) + await self._conn.commit() + + async def load_phase(self, game_id: str, phase_id: str) -> Sequence[SpeechEvent]: + async with self._conn.execute( + """ + SELECT event_id, game_id, phase_id, day, phase, source, speaker_kind, + speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, + alive_seat_nos_json, created_at_ms + FROM speech_events + WHERE game_id=? AND phase_id=? + ORDER BY created_at_ms ASC, event_id ASC + """, + (game_id, phase_id), + ) as cur: + rows = await cur.fetchall() + return [_row_to_event(row) for row in rows] + + async def load_for_game(self, game_id: str) -> Sequence[SpeechEvent]: + async with self._conn.execute( + """ + SELECT event_id, game_id, phase_id, day, phase, source, speaker_kind, + speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, + alive_seat_nos_json, created_at_ms + FROM speech_events + WHERE game_id=? + ORDER BY created_at_ms ASC, event_id ASC + """, + (game_id,), + ) as cur: + rows = await cur.fetchall() + return [_row_to_event(row) for row in rows] + + +def _row_to_event(row: Any) -> SpeechEvent: + return SpeechEvent( + event_id=row[0], + game_id=row[1], + phase_id=row[2], + day=row[3], + phase=Phase(row[4]), + source=SpeechSource(row[5]), + speaker_kind=SpeakerKind(row[6]), + speaker_seat=row[7], + text=row[8], + stt_confidence=row[9], + audio_start_ms=row[10], + audio_end_ms=row[11], + alive_seat_nos_json=row[12], + created_at_ms=row[13], + ) + + +# ---------------------------------------------------------------------- Helpers + + +def new_event_id() -> str: + """ULID-shaped opaque id; uuid4 hex is acceptable for MVP uniqueness.""" + return uuid.uuid4().hex + + +def now_ms() -> int: + return int(time.time() * 1000) + + +def make_phase_baseline( + *, + game_id: str, + phase_id: str, + day: int, + phase: Phase, + alive_seat_nos: Iterable[int], + created_at_ms: int | None = None, +) -> SpeechEvent: + """Build the sentinel SpeechEvent inserted at the start of every public speech phase. + + Per accepted spec conflict AC2, the sentinel records the alive-seat baseline + so `PublicDiscussionState` rebuild reads only `speech_events` (never `seats`). + Consumers MUST filter `source != phase_baseline` for human-facing counts. + """ + seats_sorted = sorted(set(alive_seat_nos)) + return SpeechEvent( + event_id=new_event_id(), + game_id=game_id, + phase_id=phase_id, + day=day, + phase=phase, + source=SpeechSource.PHASE_BASELINE, + speaker_kind=SpeakerKind.SYSTEM, + speaker_seat=None, + text="", + stt_confidence=None, + audio_start_ms=None, + audio_end_ms=None, + alive_seat_nos_json=json.dumps(seats_sorted), + created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), + ) + + +def make_human_text_event( + *, + game_id: str, + phase_id: str, + day: int, + phase: Phase, + speaker_seat: int, + text: str, + created_at_ms: int | None = None, +) -> SpeechEvent: + return SpeechEvent( + event_id=new_event_id(), + game_id=game_id, + phase_id=phase_id, + day=day, + phase=phase, + source=SpeechSource.TEXT, + speaker_kind=SpeakerKind.HUMAN, + speaker_seat=speaker_seat, + text=text, + created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), + ) + + +def make_npc_generated_event( + *, + game_id: str, + phase_id: str, + day: int, + phase: Phase, + speaker_seat: int, + text: str, + created_at_ms: int | None = None, +) -> SpeechEvent: + return SpeechEvent( + event_id=new_event_id(), + game_id=game_id, + phase_id=phase_id, + day=day, + phase=phase, + source=SpeechSource.NPC_GENERATED, + speaker_kind=SpeakerKind.NPC, + speaker_seat=speaker_seat, + text=text, + created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), + ) + + +def make_voice_stt_event( + *, + game_id: str, + phase_id: str, + day: int, + phase: Phase, + speaker_seat: int, + text: str, + stt_confidence: float, + audio_start_ms: int, + audio_end_ms: int, + created_at_ms: int | None = None, +) -> SpeechEvent: + return SpeechEvent( + event_id=new_event_id(), + game_id=game_id, + phase_id=phase_id, + day=day, + phase=phase, + source=SpeechSource.VOICE_STT, + speaker_kind=SpeakerKind.HUMAN, + speaker_seat=speaker_seat, + text=text, + stt_confidence=stt_confidence, + audio_start_ms=audio_start_ms, + audio_end_ms=audio_end_ms, + created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), + ) + + +# ---------------------------------------------------------------------- Service + + +class DiscussionService: + """Front door for SpeechEvent ingestion + PLAYER_SPEECH side-effect dispatch. + + Every call to :meth:`record` performs the same three steps in order so the + contract is uniform across all ingestion origins: + + 1. Insert into `speech_events` (always). + 2. Append a `LogEntry(kind="PLAYER_SPEECH")` (skipped for `phase_baseline`). + 3. Post to the main text channel (skipped for `phase_baseline` and `text`). + + The PLAYER_SPEECH log preserves the existing public-log contract so post-game + replay remains a single timeline. The skip rules are documented in the + module docstring above. + """ + + PLAYER_SPEECH_KIND = "PLAYER_SPEECH" + + def __init__( + self, + store: SpeechEventStore, + log_sink: PublicLogSink | None = None, + message_poster: SpeechMessagePoster | None = None, + ) -> None: + self._store = store + self._log_sink = log_sink + self._poster = message_poster + + async def record_persist_only(self, event: SpeechEvent) -> None: + """Persist the SpeechEvent without invoking PLAYER_SPEECH log + channel post. + + Use from rounds-mode backfill where the existing legacy path has already + posted the message and inserted the LogEntry; this method captures the + canonical event row for the speech-event-bus without duplication. + """ + await self._store.insert(event) + + async def record(self, event: SpeechEvent) -> None: + """Persist the event then run the post-write side-effects per source.""" + await self._store.insert(event) + + if event.source == SpeechSource.PHASE_BASELINE: + return + + if self._log_sink is not None and event.speaker_seat is not None: + log_entry = LogEntry( + game_id=event.game_id, + day=event.day, + phase=event.phase, + kind=self.PLAYER_SPEECH_KIND, + actor_seat=event.speaker_seat, + visibility="PUBLIC", + text=event.text, + created_at=event.created_at_ms // 1000, + ) + try: + await self._log_sink.insert_log_public(log_entry) + except Exception: + log.exception( + "PLAYER_SPEECH log insert failed", + extra={"event_id": event.event_id, "source": event.source.value}, + ) + + if self._poster is not None and event.source != SpeechSource.TEXT and event.text: + try: + await self._poster.post_public(event.game_id, event.text, self.PLAYER_SPEECH_KIND) + except Exception: + log.exception( + "PLAYER_SPEECH channel post failed", + extra={"event_id": event.event_id, "source": event.source.value}, + ) + + async def begin_phase( + self, + *, + game_id: str, + day: int, + phase: Phase, + alive_seat_nos: Iterable[int], + sequence: int = 1, + ) -> str: + """Insert the `phase_baseline` sentinel and return the resulting `phase_id`. + + Call exactly once at the start of every public speech phase + (`DAY_DISCUSSION`, `DAY_RUNOFF_SPEECH`). Subsequent `record()` calls in + that phase MUST use the returned `phase_id`. + """ + phase_id = make_phase_id(game_id, day, phase, sequence) + sentinel = make_phase_baseline( + game_id=game_id, + phase_id=phase_id, + day=day, + phase=phase, + alive_seat_nos=alive_seat_nos, + ) + await self.record(sentinel) + return phase_id + + async def begin_phase_if_absent( + self, + *, + game_id: str, + day: int, + phase: Phase, + alive_seat_nos: Iterable[int], + sequence: int = 1, + ) -> str: + """Idempotent baseline insert: returns the canonical phase_id and only + writes the sentinel if no events already exist for that phase_id. + + Use from background workers (LLM discussion / runoff) so a worker re- + dispatch (recovery, force-skip, restart) does not duplicate the + baseline; the canonical phase_id stays stable so downstream `record`s + attach to the same phase fold. + """ + phase_id = make_phase_id(game_id, day, phase, sequence) + existing = await self._store.load_phase(game_id, phase_id) + if existing: + return phase_id + sentinel = make_phase_baseline( + game_id=game_id, + phase_id=phase_id, + day=day, + phase=phase, + alive_seat_nos=alive_seat_nos, + ) + await self.record(sentinel) + return phase_id + + async def load_phase(self, game_id: str, phase_id: str) -> Sequence[SpeechEvent]: + return await self._store.load_phase(game_id, phase_id) + + +# ---------------------------------------------------------------------- Rebuild + + +def apply_speech_event( + state: PublicDiscussionState | None, + event: SpeechEvent, +) -> PublicDiscussionState | None: + """Stepwise fold: produce the next `PublicDiscussionState` from a single event. + + The first event of a phase MUST be a `phase_baseline` sentinel; passing + `state=None` with a non-sentinel event returns `None` (the caller is asking + to fold an event into a phase whose baseline has not been seeded). + + A returned state is a *new* object; the input is not mutated. This makes + the function safe to use as a `functools.reduce` step. + """ + from wolfbot.domain.discussion import CoClaim + + if event.source == SpeechSource.PHASE_BASELINE: + if event.alive_seat_nos_json is None: + return None + try: + alive_list = json.loads(event.alive_seat_nos_json) + except json.JSONDecodeError: + return None + return PublicDiscussionState( + game_id=event.game_id, + phase_id=event.phase_id, + day=event.day, + alive_seat_nos=frozenset(int(s) for s in alive_list), + ) + + if state is None: + return None + + speaker = event.speaker_seat + silent = set(state.silent_seats) if state.silent_seats else set(state.alive_seat_nos) + if state.silent_seats == frozenset() and not state.recent_speech_event_ids: + # First non-baseline event: seed silent_seats from alive baseline. + silent = set(state.alive_seat_nos) + if speaker is not None: + silent.discard(speaker) + + co_claims = list(state.co_claims) + seen_co = {(c.seat, c.role_claim) for c in co_claims} + if speaker is not None: + for role_key, marker in _CO_MARKERS: + if marker in event.text: + key = (speaker, role_key) + if key not in seen_co: + seen_co.add(key) + co_claims.append( + CoClaim( + seat=speaker, + role_claim=role_key, + declared_at_event_id=event.event_id, + ) + ) + break + + recent = [*state.recent_speech_event_ids, event.event_id][-10:] + + return PublicDiscussionState( + game_id=state.game_id, + phase_id=state.phase_id, + day=state.day, + alive_seat_nos=state.alive_seat_nos, + co_claims=tuple(co_claims), + stances=state.stances, + pressure=state.pressure, + open_topics=state.open_topics, + silent_seats=frozenset(silent), + recent_speech_event_ids=tuple(recent), + ) + + +def rebuild_public_state_from_events( + events: Sequence[SpeechEvent], +) -> PublicDiscussionState | None: + """Pure fold over a single phase's `SpeechEvent` rows. + + Returns ``None`` if `events` is empty or contains no sentinel — the caller + decides whether to treat that as "phase not yet started" or as an error. + + MVP rules (per the spec delta + AC2): + * `alive_seat_nos` is read from the sentinel's `alive_seat_nos_json`. + * `co_claims` extracts seat-attributed CO mentions in declaration order. + * `silent_seats` = `alive_seat_nos` minus seats with ≥1 non-sentinel event. + * `recent_speech_event_ids` keeps the last 10 non-sentinel ids in arrival order. + * `stances` / `pressure` / `open_topics` remain empty in MVP — design defers. + """ + if not events: + return None + + sentinel = next( + (e for e in events if e.source == SpeechSource.PHASE_BASELINE), + None, + ) + if sentinel is None or sentinel.alive_seat_nos_json is None: + return None + + try: + alive_list = json.loads(sentinel.alive_seat_nos_json) + except json.JSONDecodeError: + return None + alive_seats: frozenset[int] = frozenset(int(s) for s in alive_list) + + state = PublicDiscussionState( + game_id=sentinel.game_id, + phase_id=sentinel.phase_id, + day=sentinel.day, + alive_seat_nos=alive_seats, + ) + + from wolfbot.domain.discussion import CoClaim + + spoken_seats: set[int] = set() + co_claims: list[CoClaim] = [] + recent_ids: list[str] = [] + seen_co: set[tuple[int, str]] = set() + for event in events: + if event.source == SpeechSource.PHASE_BASELINE: + continue + if event.speaker_seat is not None: + spoken_seats.add(event.speaker_seat) + recent_ids.append(event.event_id) + for role_key, marker in _CO_MARKERS: + if marker in event.text and event.speaker_seat is not None: + key = (event.speaker_seat, role_key) + if key in seen_co: + continue + seen_co.add(key) + co_claims.append( + CoClaim( + seat=event.speaker_seat, + role_claim=role_key, + declared_at_event_id=event.event_id, + ) + ) + break + + state.co_claims = tuple(co_claims) + state.silent_seats = frozenset(alive_seats - spoken_seats) + state.recent_speech_event_ids = tuple(recent_ids[-10:]) + return state + + +_CO_MARKERS: tuple[tuple[str, str], ...] = ( + ("seer", "占いCO"), + ("medium", "霊媒CO"), + ("knight", "騎士CO"), +) diff --git a/src/wolfbot/services/game_service.py b/src/wolfbot/services/game_service.py index dcceafc..1f210db 100644 --- a/src/wolfbot/services/game_service.py +++ b/src/wolfbot/services/game_service.py @@ -15,7 +15,7 @@ import logging import time import uuid -from collections.abc import Callable, Sequence +from collections.abc import Awaitable, Callable, Sequence from random import Random from typing import Protocol, runtime_checkable @@ -143,6 +143,7 @@ def __init__( wake: WakeSink, clock: Callable[[], int] = lambda: int(time.time()), rng: Random | None = None, + on_reactive_phase_enter: Callable[[str], Awaitable[None]] | None = None, ) -> None: self.repo = repo self.discord = discord @@ -151,6 +152,7 @@ def __init__( self.clock = clock self.rng = rng or Random() self._advance_locks: dict[str, asyncio.Lock] = {} + self._on_reactive_phase_enter = on_reactive_phase_enter def _lock_for(self, game_id: str) -> asyncio.Lock: lock = self._advance_locks.get(game_id) @@ -245,6 +247,10 @@ async def _advance_once(self, game_id: str) -> None: except Exception: log.exception("waiting announce failed for %s", game_id) + # 4.5. Emit discussion_phase_summary when leaving a public-speech phase. + if previous_phase in (Phase.DAY_DISCUSSION, Phase.DAY_RUNOFF_SPEECH): + await self._emit_discussion_phase_summary(game, previous_phase) + # 5. On entering DAY_VOTE / DAY_RUNOFF / NIGHT / DAY_DISCUSSION / # DAY_RUNOFF_SPEECH, kick off DMs and LLM tasks. await self._dispatch_submissions(new_game, players, seats, transition, previous_phase) @@ -276,6 +282,14 @@ async def _plan_next( # alive LLM seat has completed both speech rounds. Otherwise either # park (no-op return None — engine sleeps to deadline) or commit a # short same-phase grace transition. + # + # Under reactive_voice mode there are no fixed rounds; LLMs speak + # event-driven via SpeakArbiter, so the deadline is the sole gate. + deadline_passed = game.deadline_epoch is not None and now >= game.deadline_epoch + if game.discussion_mode == "reactive_voice": + if deadline_passed: + return plan_day_discussion_to_vote(game, now) + return None seats_by_no = {s.seat_no: s for s in seats} alive_llm_seats = [ p.seat_no @@ -290,7 +304,6 @@ async def _plan_next( if progress[3] < 2: # discussion_rounds_done rounds_done = False break - deadline_passed = game.deadline_epoch is not None and now >= game.deadline_epoch if deadline_passed and rounds_done: return plan_day_discussion_to_vote(game, now) if deadline_passed and not rounds_done: @@ -435,11 +448,59 @@ async def _dispatch_submissions( if previous_phase is Phase.DAY_DISCUSSION: return alive_players = [p for p in players_after if p.alive] + # Mode-fixed dispatch: under reactive_voice we skip the rounds-mode + # LLM batch entirely. Reactive speech is driven event-by-event by + # SpeakArbiter via the WS server, not by the timer-driven engine. + if new_game.discussion_mode == "reactive_voice": + log.info( + "reactive_voice_phase_entered game=%s day=%d", + new_game.id, + new_game.day_number, + ) + if self._on_reactive_phase_enter is not None: + try: + await self._on_reactive_phase_enter(new_game.id) + except Exception: + log.exception( + "reactive_phase_enter callback failed for %s", new_game.id + ) + return try: await self.llm.submit_llm_discussion_rounds(new_game, alive_players, seats) except Exception: log.exception("llm discussion rounds dispatch failed for %s", new_game.id) + async def _emit_discussion_phase_summary(self, game: Game, phase: Phase) -> None: + """Emit the discussion_phase_summary structured-log event. + + Called when transitioning away from DAY_DISCUSSION or DAY_RUNOFF_SPEECH. + Best-effort: failures are logged but do not block the advance. + """ + try: + from wolfbot.domain.discussion import make_phase_id + from wolfbot.services.discussion_phase_summary import emit_phase_summary + from wolfbot.services.discussion_service import DiscussionService + + # Access the DiscussionService and repo through the LLM adapter + # which holds a reference. The summary emitter needs both. + ds: DiscussionService | None = getattr(self.llm, "discussion_service", None) + if ds is None: + return + phase_id = make_phase_id(game.id, game.day_number, phase) + await emit_phase_summary( + repo=self.repo, + discussion=ds, + game_id=game.id, + phase_id=phase_id, + mode=game.discussion_mode, + ) + except Exception: + log.exception( + "discussion_phase_summary emission failed for game=%s phase=%s", + game.id, + phase.value, + ) + async def _safe_post_public(self, game: Game, text: str, kind: str) -> None: try: await self.discord.post_public(game, text, kind) @@ -748,6 +809,12 @@ async def resume_llm_speech_progress(self, game_id: str) -> None: game = await self.repo.load_game(game_id) if game is None: return + # Under reactive_voice, LLM speech is driven event-by-event by the + # SpeakArbiter via the WS server — there are no fixed rounds to + # resume. Skip entirely so we don't spawn a legacy two-round batch + # that would violate the per-game mode contract. + if game.discussion_mode == "reactive_voice": + return seats = await self.repo.load_seats(game_id) seats_by_no = {s.seat_no: s for s in seats} players = await self.repo.load_players(game_id) diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index 63a8472..9c36d23 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -48,6 +48,7 @@ from openai import AsyncOpenAI from wolfbot.persistence.sqlite_repo import SqliteRepo + from wolfbot.services.discussion_service import DiscussionService from wolfbot.services.game_service import GameService # Sleep ranges between consecutive LLM speeches inside DAY_DISCUSSION rounds. @@ -191,6 +192,7 @@ def __init__( game_service_ref: dict[str, GameService] | None = None, rng: random.Random | None = None, clock: Callable[[], int] | None = None, + discussion_service: DiscussionService | None = None, ) -> None: import time as _time @@ -201,6 +203,12 @@ def __init__( self.rng = rng or random.Random() self._clock: Callable[[], int] = clock or (lambda: int(_time.time())) self._background_tasks: set[asyncio.Task[None]] = set() + # Optional SpeechEvent emission. When None, behavior matches pre- + # speech-event-bus rounds mode: only PLAYER_SPEECH log + Discord post + # are produced. When set, every accepted utterance is also recorded + # as SpeechEvent(source=npc_generated) so PublicDiscussionState can + # rebuild a uniform timeline across rounds + reactive_voice modes. + self.discussion_service = discussion_service def set_game_service(self, gs: GameService) -> None: self._gs_slot["gs"] = gs @@ -575,6 +583,22 @@ async def submit_llm_discussion_rounds( for p in players if p.alive and p.seat_no in seats_by_no and seats_by_no[p.seat_no].is_llm ] + # Seed the phase baseline even when there are no LLM seats (all-human + # game). Human text events recorded by on_message need the sentinel to + # exist so PublicDiscussionState rebuild works. + if self.discussion_service is not None: + try: + alive_seat_nos = sorted(p.seat_no for p in players if p.alive) + await self.discussion_service.begin_phase_if_absent( + game_id=game.id, + day=game.day_number, + phase=game.phase, + alive_seat_nos=alive_seat_nos, + ) + except Exception: + log.exception( + "phase_baseline insert failed for %s day=%d", game.id, game.day_number + ) if not llm_players: return task = asyncio.create_task( @@ -592,6 +616,8 @@ async def _run_discussion_rounds( ) -> None: seats_by_no = {s.seat_no: s for s in seats} ordered = sorted(llm_players, key=lambda p: p.seat_no) + # Phase baseline is seeded by submit_llm_discussion_rounds before the + # background task starts — no need to duplicate it here. for round_idx in (1, 2): for llm in ordered: # Re-read live game state before each per-seat attempt so a @@ -718,6 +744,7 @@ async def _do_one_discussion_speech( ) except Exception: log.exception("PLAYER_SPEECH log insert failed for seat %s", player.seat_no) + await self._emit_npc_speech_event(fresh, player.seat_no, message) # --------------------------------------------------- runoff candidate speech async def submit_llm_runoff_candidate_speeches( @@ -761,6 +788,21 @@ async def _run_runoff_candidate_speeches( ) -> None: seats_by_no = {s.seat_no: s for s in seats} ordered = sorted(llm_players, key=lambda p: p.seat_no) + # Phase baseline for runoff: alive seats at runoff entry. + if self.discussion_service is not None: + try: + all_players = await self.repo.load_players(game.id) + alive_seat_nos = sorted(p.seat_no for p in all_players if p.alive) + await self.discussion_service.begin_phase_if_absent( + game_id=game.id, + day=game.day_number, + phase=game.phase, + alive_seat_nos=alive_seat_nos, + ) + except Exception: + log.exception( + "runoff phase_baseline insert failed %s day=%d", game.id, game.day_number + ) for llm in ordered: fresh = await self.repo.load_game(game.id) if ( @@ -852,6 +894,44 @@ async def _do_one_runoff_speech( ) except Exception: log.exception("PLAYER_SPEECH log insert failed for seat %s", player.seat_no) + await self._emit_npc_speech_event(fresh, player.seat_no, message) + + async def _emit_npc_speech_event(self, game: Game, speaker_seat: int, text: str) -> None: + """Persist a SpeechEvent(source=npc_generated) for an LLM utterance. + + Wrapped in try/except so a SpeechEvent persistence hiccup does not + rollback the already-completed Discord post + PLAYER_SPEECH log. + Skips DiscussionService's own MessagePoster + LogSink hooks (they + would duplicate the LLM speech) by going through `_store.insert` + directly when available. + """ + if self.discussion_service is None: + return + try: + from wolfbot.domain.discussion import make_phase_id + from wolfbot.services.discussion_service import ( + make_npc_generated_event, + ) + + phase_id = make_phase_id(game.id, game.day_number, game.phase) + event = make_npc_generated_event( + game_id=game.id, + phase_id=phase_id, + day=game.day_number, + phase=game.phase, + speaker_seat=speaker_seat, + text=text, + ) + # Persist-only avoids re-posting the message to Discord (the + # rounds-mode path already posted it via MessagePoster) and + # avoids re-inserting the PLAYER_SPEECH LogEntry (already inserted). + await self.discussion_service.record_persist_only(event) + except Exception: + log.exception( + "SpeechEvent(npc_generated) write failed for game=%s seat=%s", + game.id, + speaker_seat, + ) # ------------------------------------------------------ helpers async def _ask( diff --git a/src/wolfbot/services/master_ingest_service.py b/src/wolfbot/services/master_ingest_service.py new file mode 100644 index 0000000..0d1b50a --- /dev/null +++ b/src/wolfbot/services/master_ingest_service.py @@ -0,0 +1,135 @@ +"""Master-side ingestion of SpeechEvent payloads from voice-ingest. + +Handles the boundary where Discord audio becomes a `SpeechEvent`: + +1. Voice-ingest sends a typed ``speech_event_payload`` over WS. +2. ``MasterIngestService.ingest_voice`` validates the speaker is NOT a + registered NPC bot (the "fail-closed STT discard" rule from design.md), + builds a `SpeechEvent(source=voice_stt)`, and delegates to + `DiscussionService.record(...)` so the canonical PLAYER_SPEECH log entry + and main-channel post are emitted. + +Keeping this in its own service (rather than inline in the WS handler) makes +the path easy to test with a Fake WS context and easy to call from the human- +text-message capture path on `WolfCog` (which also produces SpeechEvents). +""" + +from __future__ import annotations + +import logging +from typing import Protocol, runtime_checkable + +from wolfbot.domain.discussion import ( + SpeechEvent, + SpeechSource, + make_phase_id, +) +from wolfbot.domain.enums import Phase +from wolfbot.domain.ws_messages import SpeechEventPayload +from wolfbot.services.discussion_service import ( + DiscussionService, + new_event_id, +) +from wolfbot.services.discussion_service import ( + now_ms as default_now_ms, +) +from wolfbot.services.npc_registry import NpcRegistry + +log = logging.getLogger(__name__) + + +@runtime_checkable +class PhaseLookup(Protocol): + """Resolves the current phase / day / alive seats for a game id. + + Defined as a protocol so tests can substitute an in-memory map without + spinning up `SqliteRepo`. The real implementation is a thin shim that + delegates to the repo. + """ + + async def get_phase(self, game_id: str) -> tuple[Phase, int] | None: ... + + async def get_alive_seat_nos(self, game_id: str) -> list[int]: ... + + +class MasterIngestService: + """Boundary handler for voice-ingest payloads.""" + + def __init__( + self, + *, + registry: NpcRegistry, + discussion: DiscussionService, + phase_lookup: PhaseLookup, + ) -> None: + self.registry = registry + self.discussion = discussion + self.phase_lookup = phase_lookup + + async def ingest_voice( + self, payload: SpeechEventPayload + ) -> tuple[SpeechEvent | None, str | None]: + """Try to record a `SpeechEvent(source=voice_stt)`. + + Returns ``(event, None)`` on success and ``(None, reason)`` when the + payload is dropped. Reasons are the canonical voice-ingest / + Master-side `failure_reason` values (`npc_stt_discarded`, + `stale_phase`, `unknown_game`). + """ + npc_user_ids = self.registry.discord_bot_user_ids() + if payload.speaker_discord_user_id in npc_user_ids: + log.info( + "npc_stt_discarded game=%s speaker=%s segment=%s", + payload.game_id, + payload.speaker_discord_user_id, + payload.segment_id, + ) + return (None, "npc_stt_discarded") + + phase_info = await self.phase_lookup.get_phase(payload.game_id) + if phase_info is None: + log.info("voice_ingest_unknown_game game=%s", payload.game_id) + return (None, "unknown_game") + phase, day = phase_info + if phase not in (Phase.DAY_DISCUSSION, Phase.DAY_RUNOFF_SPEECH): + log.info( + "voice_ingest_stale_phase game=%s phase=%s", + payload.game_id, + phase, + ) + return (None, "stale_phase") + + # Compute the canonical phase_id on Master rather than trusting + # the caller-supplied value. This ensures a delayed STT result + # from an older phase cannot be written under a stale phase_id. + canonical_phase_id = make_phase_id(payload.game_id, day, phase) + + # Seed the phase baseline so PublicDiscussionState rebuild works. + alive_seat_nos = await self.phase_lookup.get_alive_seat_nos(payload.game_id) + await self.discussion.begin_phase_if_absent( + game_id=payload.game_id, + day=day, + phase=phase, + alive_seat_nos=alive_seat_nos, + ) + + event = SpeechEvent( + event_id=new_event_id(), + game_id=payload.game_id, + phase_id=canonical_phase_id, + day=day, + phase=phase, + source=SpeechSource.VOICE_STT, + speaker_kind="human", # type: ignore[arg-type] + speaker_seat=payload.seat_no, + text=payload.text, + stt_confidence=payload.confidence, + audio_start_ms=payload.audio_start_ms, + audio_end_ms=payload.audio_end_ms, + created_at_ms=default_now_ms(), + ) + await self.discussion.record(event) + return (event, None) + + +__all__ = ["MasterIngestService", "PhaseLookup"] diff --git a/src/wolfbot/services/master_logic_service.py b/src/wolfbot/services/master_logic_service.py new file mode 100644 index 0000000..95c0ac7 --- /dev/null +++ b/src/wolfbot/services/master_logic_service.py @@ -0,0 +1,82 @@ +"""MasterLogicBuilder — turns `PublicDiscussionState` into per-NPC LogicPackets. + +The full design has rich logic candidates (claim chains, support / counter +links, per-seat pressure scores). MVP per the proposal restricts the +deterministic fields to `co_claims` (extracted from text) and `silent_seats` +(alive set minus speakers); `stances` / `pressure` / `open_topics` remain +skeletons and are passed through empty. + +This module produces a `LogicPacket` that: + +* enumerates the recipient's seat-aware view of CO claims as candidate + entries (one per CO, each empty support/counter list — the NPC bot uses + its own persona+role to weight them); +* echoes `silent_seats` as a textual `public_state_summary`; +* sets `expires_at_ms` per the current phase deadline; +* leaves `pressure` empty. + +Builder is pure: no I/O, no asyncio. The arbiter passes in the deadline. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterable + +from wolfbot.domain.discussion import PublicDiscussionState +from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket + + +def _new_packet_id() -> str: + return f"lp_{uuid.uuid4().hex[:12]}" + + +def build_logic_packet( + *, + state: PublicDiscussionState, + recipient_npc_id: str, + expires_at_ms: int, + now_ms: int, + pressure: dict[int, float] | None = None, + additional_candidates: Iterable[LogicCandidate] = (), +) -> LogicPacket: + """Construct a `LogicPacket` for `recipient_npc_id`. + + The packet is deterministic given the same `state` + `now_ms` save for + the random `packet_id`. Tests should pin `now_ms` and inspect the rest of + the payload directly. + """ + candidates: list[LogicCandidate] = [] + for claim in state.co_claims: + candidates.append( + LogicCandidate( + id=f"co-{claim.seat}-{claim.role_claim}", + claim=f"席{claim.seat} {claim.role_claim}CO", + ) + ) + candidates.extend(additional_candidates) + + silent_repr = ( + f"silent_seats={sorted(state.silent_seats)}" if state.silent_seats else "silent_seats=[]" + ) + co_repr = ( + ", ".join(f"席{c.seat}={c.role_claim}" for c in state.co_claims) + if state.co_claims + else "(none)" + ) + summary = f"phase_id={state.phase_id} day={state.day} co_claims=[{co_repr}] {silent_repr}" + + return LogicPacket( + ts=now_ms, + trace_id=f"lp-{state.phase_id}-{recipient_npc_id}", + packet_id=_new_packet_id(), + phase_id=state.phase_id, + recipient_npc_id=recipient_npc_id, + public_state_summary=summary, + logic_candidates=tuple(candidates), + pressure=pressure or {}, + expires_at_ms=expires_at_ms, + ) + + +__all__ = ["build_logic_packet"] diff --git a/src/wolfbot/services/master_ws_server.py b/src/wolfbot/services/master_ws_server.py new file mode 100644 index 0000000..1277521 --- /dev/null +++ b/src/wolfbot/services/master_ws_server.py @@ -0,0 +1,436 @@ +"""Master ↔ NPC / voice-ingest WebSocket transport. + +Listens on `127.0.0.1` and authenticates each connection with the shared +`MASTER_NPC_PSK` value. Two connection roles are accepted: + +- ``role=npc`` — an NPC bot worker. Identified by `npc_id`. Master responds + with ``npc_registered`` once its ``npc_register`` message arrives, and + routes subsequent ``logic_packet`` / ``speak_request`` messages back via + the per-connection ``send`` callback. +- ``role=voice-ingest`` — the voice ingest worker. Master pushes the current + ``registry_snapshot`` as soon as the connection authenticates and a + ``registry_update`` whenever the NPC bot identity set changes. + +The transport is intentionally narrow: it parses incoming JSON envelopes, hands +off typed messages to a handler, and exposes a back-channel ``send`` callable +that serializes typed Pydantic messages back over the WS. Higher-level +behaviors (arbitration, authorization, audit-row writes) live in +``master_ingest_service`` / ``speak_arbiter`` and call into this module only +through the back-channel. + +Testability: +- ``MasterWsServer`` is a Protocol; production code uses + ``WebsocketsMasterWsServer`` (real `websockets` library) and tests use + ``FakeMasterWsServer`` from ``tests.fakes``. +- The server exposes a `dispatch(raw_json, ctx)` entry point that the Fake + also calls, so unit tests can simulate inbound messages without standing + up a real socket. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any, Literal, Protocol, runtime_checkable + +from wolfbot.domain.ws_messages import ( + Heartbeat, + NpcRegister, + NpcRegistered, + PlaybackFailed, + PlaybackFinished, + RegistrySnapshot, + RegistryUpdate, + SpeakResult, + SpeechEventPayload, + SttFailed, + TtsFailed, + TtsFinished, + VadSpeechEnded, + VadSpeechStarted, +) +from wolfbot.services.npc_registry import NpcRegistry + +log = logging.getLogger(__name__) + + +ConnectionRole = Literal["npc", "voice-ingest"] + + +@dataclass +class ConnectionContext: + """Per-connection state passed to message handlers. + + Holds the connection role, an arbitrary tag (npc_id or "voice-ingest"), + and the back-channel `send` coroutine so handlers can push typed + responses without depending on the underlying WS library. + """ + + role: ConnectionRole + tag: str + send: Callable[[str], Awaitable[None]] + closed: bool = False + extra: dict[str, Any] = field(default_factory=dict) + + +# Handler shape: takes (parsed_json: dict, ctx: ConnectionContext) and returns +# nothing. The dispatcher selects the right handler from the `type` field. +MessageHandler = Callable[[dict[str, Any], ConnectionContext], Awaitable[None]] + + +@runtime_checkable +class MasterWsServer(Protocol): + """Operational surface used by the orchestrator (`main.py` / tests).""" + + async def start(self) -> None: ... + + async def stop(self) -> None: ... + + def add_handler(self, message_type: str, handler: MessageHandler) -> None: ... + + async def broadcast_to_voice_ingest(self, message_json: str) -> None: ... + + +class HandlerRegistry: + """Routes message types to their async handlers. + + Kept as a tiny independent class so ``WebsocketsMasterWsServer`` and + ``FakeMasterWsServer`` can both delegate to the same dispatch path. + """ + + def __init__(self) -> None: + self._handlers: dict[str, MessageHandler] = {} + + def add(self, message_type: str, handler: MessageHandler) -> None: + self._handlers[message_type] = handler + + async def dispatch(self, raw_json: str, ctx: ConnectionContext) -> None: + try: + payload: Any = json.loads(raw_json) + except json.JSONDecodeError: + log.warning("master_ws_invalid_json role=%s tag=%s", ctx.role, ctx.tag) + return + if not isinstance(payload, dict) or "type" not in payload: + log.warning( + "master_ws_missing_type role=%s tag=%s payload=%s", + ctx.role, + ctx.tag, + payload, + ) + return + message_type = payload["type"] + handler = self._handlers.get(message_type) + if handler is None: + log.info( + "master_ws_unhandled_message type=%s role=%s tag=%s", + message_type, + ctx.role, + ctx.tag, + ) + return + try: + await handler(payload, ctx) + except Exception: + log.exception( + "master_ws_handler_failed type=%s role=%s tag=%s", + message_type, + ctx.role, + ctx.tag, + ) + + +# ---------------------------------------------------------------- handlers + + +def _now_ms_default() -> int: + import time + + return int(time.time() * 1000) + + +@dataclass +class MasterHandlers: + """The collection of typed handlers Master needs. + + Each handler reads from / writes to a small set of collaborators: + ``NpcRegistry`` (presence), the ``ingest_service`` for voice-ingest + messages, and the ``arbiter`` (set later in the apply flow) for + speak-result handling. + + Keeping the handlers in a dataclass lets ``main.py`` wire them once and + ``tests/test_master_ws_server.py`` substitute Fakes per call. + """ + + registry: NpcRegistry + on_speak_result: Callable[[SpeakResult, ConnectionContext], Awaitable[None]] | None = None + on_tts_finished: Callable[[TtsFinished, ConnectionContext], Awaitable[None]] | None = None + on_tts_failed: Callable[[TtsFailed, ConnectionContext], Awaitable[None]] | None = None + on_playback_finished: ( + Callable[[PlaybackFinished, ConnectionContext], Awaitable[None]] | None + ) = None + on_playback_failed: Callable[[PlaybackFailed, ConnectionContext], Awaitable[None]] | None = None + on_speech_event_payload: ( + Callable[[SpeechEventPayload, ConnectionContext], Awaitable[None]] | None + ) = None + on_vad_started: Callable[[VadSpeechStarted, ConnectionContext], Awaitable[None]] | None = None + on_vad_ended: Callable[[VadSpeechEnded, ConnectionContext], Awaitable[None]] | None = None + on_stt_failed: Callable[[SttFailed, ConnectionContext], Awaitable[None]] | None = None + now_ms: Callable[[], int] = field(default=_now_ms_default) + + def install(self, registry_: HandlerRegistry) -> None: + registry_.add("npc_register", self._handle_register) + registry_.add("heartbeat", self._handle_heartbeat) + registry_.add("speak_result", self._handle_speak_result) + registry_.add("tts_finished", self._handle_tts_finished) + registry_.add("tts_failed", self._handle_tts_failed) + registry_.add("playback_finished", self._handle_playback_finished) + registry_.add("playback_failed", self._handle_playback_failed) + registry_.add("speech_event_payload", self._handle_speech_payload) + registry_.add("vad_speech_started", self._handle_vad_started) + registry_.add("vad_speech_ended", self._handle_vad_ended) + registry_.add("stt_failed", self._handle_stt_failed) + + async def _handle_register(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = NpcRegister.model_validate(payload) + ctx.tag = msg.npc_id + entry = self.registry.register( + npc_id=msg.npc_id, + discord_bot_user_id=msg.discord_bot_user_id, + supported_voices=msg.supported_voices, + version=msg.version, + send=ctx.send, + now_ms=self.now_ms(), + ) + reply = NpcRegistered( + ts=self.now_ms(), + trace_id=msg.trace_id, + npc_id=msg.npc_id, + assigned_seat=entry.assigned_seat, + game_id=entry.game_id, + phase_id=entry.phase_id, + ) + await ctx.send(reply.model_dump_json()) + + async def _handle_heartbeat(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = Heartbeat.model_validate(payload) + if msg.npc_id is not None: + self.registry.heartbeat(msg.npc_id, self.now_ms()) + + async def _handle_speak_result(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = SpeakResult.model_validate(payload) + if self.on_speak_result is not None: + await self.on_speak_result(msg, ctx) + + async def _handle_tts_finished(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = TtsFinished.model_validate(payload) + if self.on_tts_finished is not None: + await self.on_tts_finished(msg, ctx) + + async def _handle_tts_failed(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = TtsFailed.model_validate(payload) + if self.on_tts_failed is not None: + await self.on_tts_failed(msg, ctx) + + async def _handle_playback_finished( + self, payload: dict[str, Any], ctx: ConnectionContext + ) -> None: + msg = PlaybackFinished.model_validate(payload) + if self.on_playback_finished is not None: + await self.on_playback_finished(msg, ctx) + + async def _handle_playback_failed( + self, payload: dict[str, Any], ctx: ConnectionContext + ) -> None: + msg = PlaybackFailed.model_validate(payload) + if self.on_playback_failed is not None: + await self.on_playback_failed(msg, ctx) + + async def _handle_speech_payload(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = SpeechEventPayload.model_validate(payload) + if self.on_speech_event_payload is not None: + await self.on_speech_event_payload(msg, ctx) + + async def _handle_vad_started(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = VadSpeechStarted.model_validate(payload) + if self.on_vad_started is not None: + await self.on_vad_started(msg, ctx) + + async def _handle_vad_ended(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = VadSpeechEnded.model_validate(payload) + if self.on_vad_ended is not None: + await self.on_vad_ended(msg, ctx) + + async def _handle_stt_failed(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = SttFailed.model_validate(payload) + if self.on_stt_failed is not None: + await self.on_stt_failed(msg, ctx) + + +# ---------------------------------------------------------------- real WS server + + +class WebsocketsMasterWsServer: + """Production WebSocket server. + + Loads the `websockets` library lazily so tests do not need it. The + handshake reads two query parameters from the WebSocket URL: ``role`` + (``npc`` | ``voice-ingest``) and ``psk``. Mismatch on either rejects the + connection with a typed ``HandshakeError`` written before close. Behaviour + of the protocol after handshake is delegated to ``HandlerRegistry``. + + The server is intentionally minimal — only the connection lifecycle plus + a back-channel push API for voice-ingest registry updates. Higher-level + state (NPC presence, audit rows) lives in ``NpcRegistry`` / + ``master_ingest_service`` / ``speak_arbiter``. + """ + + def __init__( + self, + *, + host: str = "127.0.0.1", + port: int = 8800, + psk: str, + registry: NpcRegistry, + handlers: MasterHandlers, + ) -> None: + self.host = host + self.port = port + self.psk = psk + self.registry = registry + self.handler_registry = HandlerRegistry() + handlers.install(self.handler_registry) + self._handlers = handlers + self._server: Any = None + self._voice_ingest_conns: set[ConnectionContext] = set() + # Subscribe to registry deltas so we can push registry_update to all + # voice-ingest peers in real time. + add_listener = getattr(self.registry, "add_listener", None) + if add_listener is not None: + add_listener(self._on_registry_delta) + + async def start(self) -> None: + # Lazy import — the live websockets library is a heavy dep we only + # exercise when actually serving production traffic. Tests use the + # Fake and never call start(). + import websockets + + async def _conn_handler(ws: Any) -> None: + ctx = await self._authenticate(ws) + if ctx is None: + return + try: + if ctx.role == "voice-ingest": + self._voice_ingest_conns.add(ctx) + await self._send_initial_registry_snapshot(ctx) + async for raw in ws: + if isinstance(raw, bytes): + raw = raw.decode("utf-8", "replace") + await self.handler_registry.dispatch(raw, ctx) + finally: + ctx.closed = True + self._voice_ingest_conns.discard(ctx) + if ctx.role == "npc": + self.registry.unregister(ctx.tag, reason="ws_closed") + + self._server = await websockets.serve(_conn_handler, self.host, self.port) + log.info( + "master_ws_listening host=%s port=%d psk_set=%s", + self.host, + self.port, + bool(self.psk), + ) + + async def stop(self) -> None: + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + + async def _authenticate(self, ws: Any) -> ConnectionContext | None: + from urllib.parse import parse_qs, urlparse + + # websockets ≥13 (including 16.0) exposes the HTTP request as + # `ws.request` (a `Request` with `.path`). Older versions exposed + # `ws.path` directly. Try the modern attribute first. + request = getattr(ws, "request", None) + if request is not None: + path = getattr(request, "path", None) or "" + else: + path = getattr(ws, "path", "") or "" + query = parse_qs(urlparse(path).query) + role_raw = (query.get("role") or [""])[0] + psk_raw = (query.get("psk") or [""])[0] + role: ConnectionRole | None = None + if role_raw == "npc": + role = "npc" + elif role_raw == "voice-ingest": + role = "voice-ingest" + if role is None or psk_raw != self.psk: + log.warning( + "master_ws_auth_rejected role=%s psk_match=%s", + role_raw, + psk_raw == self.psk, + ) + with contextlib.suppress(Exception): + await ws.close(code=4401, reason="auth_failed") + return None + + async def _send(msg: str) -> None: + try: + await ws.send(msg) + except Exception: + log.exception("master_ws_send_failed role=%s", role) + + return ConnectionContext(role=role, tag=role_raw, send=_send) + + def add_handler(self, message_type: str, handler: MessageHandler) -> None: + self.handler_registry.add(message_type, handler) + + async def broadcast_to_voice_ingest(self, message_json: str) -> None: + for ctx in list(self._voice_ingest_conns): + if ctx.closed: + self._voice_ingest_conns.discard(ctx) + continue + try: + await ctx.send(message_json) + except Exception: + log.exception("voice_ingest_broadcast_failed tag=%s", ctx.tag) + + async def _on_registry_delta(self, added: set[str], removed: set[str]) -> None: + if not added and not removed: + return + update = RegistryUpdate( + ts=self._handlers.now_ms(), + trace_id="registry-update", + added=tuple(sorted(added)), + removed=tuple(sorted(removed)), + ) + await self.broadcast_to_voice_ingest(update.model_dump_json()) + + async def _send_initial_registry_snapshot(self, ctx: ConnectionContext) -> None: + snapshot = RegistrySnapshot( + ts=self._handlers.now_ms(), + trace_id="registry-snapshot", + npc_user_ids=tuple(sorted(self.registry.discord_bot_user_ids())), + ) + await ctx.send(snapshot.model_dump_json()) + + +__all__ = [ + "ConnectionContext", + "ConnectionRole", + "HandlerRegistry", + "MasterHandlers", + "MasterWsServer", + "MessageHandler", + "WebsocketsMasterWsServer", +] + + +# Force a non-static reference so unused-import linters keep `asyncio` here for +# implementations that subclass / extend this module without re-importing. +_ = asyncio diff --git a/src/wolfbot/services/npc_client.py b/src/wolfbot/services/npc_client.py new file mode 100644 index 0000000..6370f0d --- /dev/null +++ b/src/wolfbot/services/npc_client.py @@ -0,0 +1,265 @@ +"""NPC-side master client. + +Drives the WS connection from the NPC bot's perspective: + +1. Register with Master via `npc_register` and wait for `npc_registered`. +2. Send periodic heartbeats. +3. Receive `logic_packet` (cached by `packet_id`) and `speak_request`. +4. Compose a `SpeakResult` via `NpcSpeechService` and send it back. +5. On `playback_authorized`: synthesize via TTS, call playback, then emit + `tts_finished` / `tts_failed` and `playback_finished` / `playback_failed`. +6. On `playback_rejected`: drop the queued utterance silently (per spec). + +The class exposes `process_message(payload)` so unit tests can drive +inbound traffic deterministically without standing up a WS connection. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field + +from wolfbot.domain.ws_messages import ( + Heartbeat, + LogicPacket, + NpcRegister, + NpcRegistered, + PlaybackAuthorized, + PlaybackFailed, + PlaybackFinished, + PlaybackRejected, + SpeakRequest, + TtsFailed, + TtsFinished, +) +from wolfbot.services.npc_speech_service import NpcSpeechService +from wolfbot.services.tts_service import ( + InMemoryTtsCache, + TtsProviderError, + TtsRequest, + TtsService, +) +from wolfbot.services.voice_playback_service import ( + VoicePlayback, + VoicePlaybackError, +) + +log = logging.getLogger(__name__) + + +@dataclass +class NpcClientConfig: + npc_id: str + discord_bot_user_id: str + voice_id: str + supported_voices: tuple[str, ...] = () + version: str = "0.0.1" + + +@dataclass +class _AuthorizedPlayback: + request_id: str + text: str + + +@dataclass +class _PendingForPlayback: + """Tracks the SpeakResult we sent so we can find its text on authorization.""" + + text: str + voice_id: str + + +@dataclass +class NpcClient: + config: NpcClientConfig + speech: NpcSpeechService + tts: TtsService + playback: VoicePlayback + send: Callable[[str], Awaitable[None]] + now_ms: Callable[[], int] + cache: InMemoryTtsCache = field(default_factory=lambda: InMemoryTtsCache(max_entries=64)) + + _logic_cache: dict[str, LogicPacket] = field(default_factory=dict) + _pending_playback: dict[str, _PendingForPlayback] = field(default_factory=dict) + pending_authorizations: list[_AuthorizedPlayback] = field(default_factory=list) + registered: bool = False + + # ---------------------------------------------------------- registration + + async def register(self, trace_id: str = "register") -> None: + msg = NpcRegister( + ts=self.now_ms(), + trace_id=trace_id, + npc_id=self.config.npc_id, + discord_bot_user_id=self.config.discord_bot_user_id, + supported_voices=self.config.supported_voices, + version=self.config.version, + ) + await self.send(msg.model_dump_json()) + + async def heartbeat(self) -> None: + await self.send( + Heartbeat(ts=self.now_ms(), trace_id="hb", npc_id=self.config.npc_id).model_dump_json() + ) + + # ---------------------------------------------------------- inbound + + async def process_message(self, raw_json: str) -> None: + try: + payload = json.loads(raw_json) + except json.JSONDecodeError: + log.warning("npc_client_invalid_json") + return + if not isinstance(payload, dict) or "type" not in payload: + log.warning("npc_client_missing_type") + return + t = payload["type"] + if t == "npc_registered": + self._on_registered(NpcRegistered.model_validate(payload)) + elif t == "logic_packet": + self._on_logic_packet(LogicPacket.model_validate(payload)) + elif t == "speak_request": + await self._on_speak_request(SpeakRequest.model_validate(payload)) + elif t == "playback_authorized": + await self._on_playback_authorized(PlaybackAuthorized.model_validate(payload)) + elif t == "playback_rejected": + self._on_playback_rejected(PlaybackRejected.model_validate(payload)) + else: + log.info("npc_client_unhandled_type type=%s", t) + + def _on_registered(self, msg: NpcRegistered) -> None: + self.registered = True + log.info( + "npc_registered npc_id=%s seat=%s game=%s", + msg.npc_id, + msg.assigned_seat, + msg.game_id, + ) + + def _on_logic_packet(self, packet: LogicPacket) -> None: + self._logic_cache[packet.packet_id] = packet + + async def _on_speak_request(self, request: SpeakRequest) -> None: + logic = self._logic_cache.get(request.logic_packet_id) + if logic is None: + # No matching LogicPacket — best-effort generate without context. + log.warning("npc_speak_request_missing_logic packet=%s", request.logic_packet_id) + logic = LogicPacket( + ts=self.now_ms(), + trace_id=request.trace_id, + packet_id=request.logic_packet_id, + phase_id=request.phase_id, + recipient_npc_id=request.npc_id, + public_state_summary="", + logic_candidates=(), + pressure={}, + expires_at_ms=request.expires_at_ms, + ) + result = await self.speech.respond(logic=logic, request=request, now_ms=self.now_ms()) + if result.status == "accepted" and result.text is not None: + self._pending_playback[result.request_id] = _PendingForPlayback( + text=result.text, voice_id=self.config.voice_id + ) + await self.send(result.model_dump_json()) + + async def _on_playback_authorized(self, auth: PlaybackAuthorized) -> None: + pending = self._pending_playback.pop(auth.request_id, None) + if pending is None: + log.warning( + "npc_playback_authorized_unknown request=%s", + auth.request_id, + ) + return + self.pending_authorizations.append( + _AuthorizedPlayback(request_id=auth.request_id, text=pending.text) + ) + # Synthesize. + req = TtsRequest(text=pending.text, voice_id=pending.voice_id) + cached = self.cache.get(req) + try: + if cached is not None: + tts_result = cached + tts_duration_ms = cached.duration_ms + else: + tts_result = await self.tts.synthesize(req) + self.cache.put(req, tts_result) + tts_duration_ms = tts_result.duration_ms + except TtsProviderError as exc: + await self.send( + TtsFailed( + ts=self.now_ms(), + trace_id=auth.trace_id, + request_id=auth.request_id, + npc_id=auth.npc_id, + failure_reason=exc.failure_reason, + ).model_dump_json() + ) + return + await self.send( + TtsFinished( + ts=self.now_ms(), + trace_id=auth.trace_id, + request_id=auth.request_id, + npc_id=auth.npc_id, + tts_duration_ms=tts_duration_ms, + audio_size_bytes=len(tts_result.audio), + ).model_dump_json() + ) + # Playback (gated — never plays without authorization). + try: + started, finished = await self.playback.play( + audio=tts_result.audio, sample_rate=tts_result.sample_rate + ) + except VoicePlaybackError as exc: + await self.send( + PlaybackFailed( + ts=self.now_ms(), + trace_id=auth.trace_id, + request_id=auth.request_id, + npc_id=auth.npc_id, + failure_reason=exc.failure_reason, + ).model_dump_json() + ) + return + except Exception: + log.exception("npc_playback_unexpected_error request=%s", auth.request_id) + await self.send( + PlaybackFailed( + ts=self.now_ms(), + trace_id=auth.trace_id, + request_id=auth.request_id, + npc_id=auth.npc_id, + failure_reason="discord_playback_error", + ).model_dump_json() + ) + return + await self.send( + PlaybackFinished( + ts=self.now_ms(), + trace_id=auth.trace_id, + request_id=auth.request_id, + npc_id=auth.npc_id, + started_at_ms=started, + finished_at_ms=finished, + ).model_dump_json() + ) + + def _on_playback_rejected(self, msg: PlaybackRejected) -> None: + # Drop the pending playback silently — no audio plays per spec. + self._pending_playback.pop(msg.request_id, None) + log.info( + "npc_playback_rejected request=%s reason=%s", + msg.request_id, + msg.failure_reason, + ) + + +__all__ = ["NpcClient", "NpcClientConfig"] + + +# Force imports referenced for typing extensions. +_ = (asyncio,) diff --git a/src/wolfbot/services/npc_registry.py b/src/wolfbot/services/npc_registry.py new file mode 100644 index 0000000..41b7853 --- /dev/null +++ b/src/wolfbot/services/npc_registry.py @@ -0,0 +1,233 @@ +"""In-memory NPC bot registry maintained by Master. + +Tracks every connected NPC bot's `npc_id`, Discord identity, assigned seat, +heartbeat freshness, and back-channel send target. Voice-ingest reads the +`discord_bot_user_id` set from this registry to filter NPC TTS audio out of +the STT pipeline (see voice-ingest spec). + +Lifecycle: +- `register(...)` is called when a connecting NPC presents a valid + `npc_register` payload over WebSocket. +- `heartbeat(npc_id, ts)` is called on every heartbeat message. +- `prune_offline(now_ts, timeout_ms)` is called periodically (or by the + registry itself) to mark stale NPCs offline; offline NPCs are NOT removed + from the registry — `is_online` flips to False so the arbiter can skip + them, and reconnection re-flips to True via the next register call. +- `unregister(npc_id, reason)` is the explicit teardown path; emits the + registry_update needed by voice-ingest. + +Thread/asyncio model: +- The registry is intentionally single-threaded. Master is async-first; all + operations run on the same event loop, so plain dicts are safe without a + lock. +""" + +from __future__ import annotations + +import contextlib +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + +log = logging.getLogger(__name__) + + +@dataclass +class NpcEntry: + """One row in the registry.""" + + npc_id: str + discord_bot_user_id: str + supported_voices: tuple[str, ...] + version: str + assigned_seat: int | None = None + game_id: str | None = None + phase_id: str | None = None + last_heartbeat_ms: int = 0 + is_online: bool = True + # Back-channel send target. Each NPC connection registers a coroutine + # that takes a JSON-encoded message and writes it to the WS. Tests can + # substitute a list-appender; real code wires this from MasterWsServer. + send: Callable[[str], Awaitable[None]] | None = field(default=None, repr=False) + + +@runtime_checkable +class NpcRegistry(Protocol): + """Protocol for testability — Fakes substitute as needed.""" + + def register( + self, + *, + npc_id: str, + discord_bot_user_id: str, + supported_voices: tuple[str, ...], + version: str, + send: Callable[[str], Awaitable[None]] | None, + now_ms: int, + ) -> NpcEntry: ... + + def unregister(self, npc_id: str, reason: str) -> None: ... + + def heartbeat(self, npc_id: str, ts: int) -> None: ... + + def get(self, npc_id: str) -> NpcEntry | None: ... + + def all_online(self) -> list[NpcEntry]: ... + + def discord_bot_user_ids(self) -> set[str]: ... + + def prune_offline(self, now_ms: int, timeout_ms: int) -> list[str]: ... + + +class InMemoryNpcRegistry: + """The default registry — production and test both use this.""" + + def __init__(self) -> None: + self._entries: dict[str, NpcEntry] = {} + # Subscribers receive (added: set[str], removed: set[str]) of + # discord_bot_user_id deltas; voice-ingest connections register here + # to receive registry_update pushes. + self._listeners: list[Callable[[set[str], set[str]], Awaitable[None]]] = [] + # Strong references to scheduled listener tasks so the GC does not + # drop them mid-flight (ruff RUF006). + self._listener_tasks: set[object] = set() + + # ---------------------------------------------------------- registration + + def register( + self, + *, + npc_id: str, + discord_bot_user_id: str, + supported_voices: tuple[str, ...], + version: str, + send: Callable[[str], Awaitable[None]] | None, + now_ms: int, + ) -> NpcEntry: + previous = self._entries.get(npc_id) + previous_uid = previous.discord_bot_user_id if previous is not None else None + + entry = NpcEntry( + npc_id=npc_id, + discord_bot_user_id=discord_bot_user_id, + supported_voices=supported_voices, + version=version, + assigned_seat=previous.assigned_seat if previous is not None else None, + game_id=previous.game_id if previous is not None else None, + phase_id=previous.phase_id if previous is not None else None, + last_heartbeat_ms=now_ms, + is_online=True, + send=send, + ) + self._entries[npc_id] = entry + + added: set[str] = set() + removed: set[str] = set() + if previous_uid is None: + added.add(discord_bot_user_id) + elif previous_uid != discord_bot_user_id: + removed.add(previous_uid) + added.add(discord_bot_user_id) + if added or removed: + self._notify_listeners_sync(added, removed) + return entry + + def unregister(self, npc_id: str, reason: str) -> None: + entry = self._entries.pop(npc_id, None) + if entry is None: + return + log.info("npc_unregister npc_id=%s reason=%s", npc_id, reason) + self._notify_listeners_sync(set(), {entry.discord_bot_user_id}) + + # ---------------------------------------------------------- heartbeat + + def heartbeat(self, npc_id: str, ts: int) -> None: + entry = self._entries.get(npc_id) + if entry is None: + return + entry.last_heartbeat_ms = ts + if not entry.is_online: + entry.is_online = True + log.info("npc_recovered npc_id=%s", npc_id) + + # ---------------------------------------------------------- queries + + def get(self, npc_id: str) -> NpcEntry | None: + return self._entries.get(npc_id) + + def all_online(self) -> list[NpcEntry]: + return [e for e in self._entries.values() if e.is_online] + + def discord_bot_user_ids(self) -> set[str]: + return {e.discord_bot_user_id for e in self._entries.values()} + + # ---------------------------------------------------------- pruning + + def prune_offline(self, now_ms: int, timeout_ms: int) -> list[str]: + """Mark stale NPCs offline; return the list of just-marked-offline ids. + + Stale = `now_ms - last_heartbeat_ms > timeout_ms`. Only flips state once + per stay-offline window; subsequent prunes return an empty list until + the NPC reconnects and goes stale again. + """ + marked: list[str] = [] + for npc_id, entry in self._entries.items(): + if entry.is_online and now_ms - entry.last_heartbeat_ms > timeout_ms: + entry.is_online = False + marked.append(npc_id) + if marked: + log.info("npc_offline_marked count=%d", len(marked)) + return marked + + # ---------------------------------------------------------- assignment + + def assign( + self, + npc_id: str, + *, + seat: int, + game_id: str, + phase_id: str, + ) -> None: + entry = self._entries.get(npc_id) + if entry is None: + return + entry.assigned_seat = seat + entry.game_id = game_id + entry.phase_id = phase_id + + # ---------------------------------------------------------- listeners + + def add_listener(self, fn: Callable[[set[str], set[str]], Awaitable[None]]) -> None: + self._listeners.append(fn) + + def remove_listener(self, fn: Callable[[set[str], set[str]], Awaitable[None]]) -> None: + with contextlib.suppress(ValueError): + self._listeners.remove(fn) + + def _notify_listeners_sync(self, added: set[str], removed: set[str]) -> None: + """Schedule async notifications without blocking the registry path. + + Listeners are typically WS pushers; they may take time to write. + We schedule them on the running loop so callers (handlers + tests) + do not have to await listener completion. + """ + if not self._listeners: + return + import asyncio as _aio + + try: + loop = _aio.get_running_loop() + except RuntimeError: + # No loop — listeners cannot run. Tests that exercise the sync + # path can still observe the entry state. + return + for listener in list(self._listeners): + coro = listener(added, removed) + task: object = loop.create_task(coro) # type: ignore[arg-type] + self._listener_tasks.add(task) + task.add_done_callback(self._listener_tasks.discard) # type: ignore[attr-defined] + + +__all__ = ["InMemoryNpcRegistry", "NpcEntry", "NpcRegistry"] diff --git a/src/wolfbot/services/npc_speech_service.py b/src/wolfbot/services/npc_speech_service.py new file mode 100644 index 0000000..05dc080 --- /dev/null +++ b/src/wolfbot/services/npc_speech_service.py @@ -0,0 +1,132 @@ +"""NPC-side: take a `LogicPacket` + `SpeakRequest` and return a `SpeakResult`. + +This is the NPC bot's Grok prompt-builder + structured-output call wrapped +in a deterministic Protocol so tests can substitute a `FakeNpcGenerator`. + +The persona registry, role-strategy isolation, structured-output `LLMAction` +schema, and seat-token resolver come from the existing `wolfbot.llm` package +— this module only adds the LogicPacket-aware prompt assembly. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +from wolfbot.domain.ws_messages import LogicPacket, SpeakRequest, SpeakResult + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class NpcGeneratedSpeech: + text: str + intent: str + used_logic_ids: tuple[str, ...] + estimated_duration_ms: int + + +@runtime_checkable +class NpcGenerator(Protocol): + async def generate( + self, + *, + logic: LogicPacket, + request: SpeakRequest, + ) -> NpcGeneratedSpeech | None: ... + + +class FakeNpcGenerator: + """Returns a scripted utterance, or None to simulate decline.""" + + def __init__( + self, + scripted: list[NpcGeneratedSpeech | None] | None = None, + default: NpcGeneratedSpeech | None = None, + ) -> None: + self._scripted = list(scripted or []) + self._default = default + self.call_count = 0 + self.received_logic: list[LogicPacket] = [] + self.received_requests: list[SpeakRequest] = [] + + async def generate( + self, + *, + logic: LogicPacket, + request: SpeakRequest, + ) -> NpcGeneratedSpeech | None: + self.call_count += 1 + self.received_logic.append(logic) + self.received_requests.append(request) + if self._scripted: + return self._scripted.pop(0) + return self._default + + +class NpcSpeechService: + """Compose `SpeakResult` from a generator's output. + + Encapsulates length-cap enforcement and decline-on-empty so the NPC + bot's main loop stays tidy. + """ + + def __init__(self, generator: NpcGenerator) -> None: + self.generator = generator + + async def respond( + self, + *, + logic: LogicPacket, + request: SpeakRequest, + now_ms: int, + ) -> SpeakResult: + try: + speech = await self.generator.generate(logic=logic, request=request) + except Exception: + log.exception( + "npc_generate_failed npc_id=%s req=%s", request.npc_id, request.request_id + ) + return SpeakResult( + ts=now_ms, + trace_id=request.trace_id, + request_id=request.request_id, + npc_id=request.npc_id, + phase_id=request.phase_id, + status="error", + failure_reason="generator_error", + ) + if speech is None or not speech.text.strip(): + return SpeakResult( + ts=now_ms, + trace_id=request.trace_id, + request_id=request.request_id, + npc_id=request.npc_id, + phase_id=request.phase_id, + status="declined", + failure_reason="speaker_declined", + ) + text = speech.text.strip() + if len(text) > request.max_chars: + text = text[: request.max_chars] + return SpeakResult( + ts=now_ms, + trace_id=request.trace_id, + request_id=request.request_id, + npc_id=request.npc_id, + phase_id=request.phase_id, + status="accepted", + text=text, + used_logic_ids=speech.used_logic_ids, + intent=speech.intent, + estimated_duration_ms=speech.estimated_duration_ms, + ) + + +__all__ = [ + "FakeNpcGenerator", + "NpcGeneratedSpeech", + "NpcGenerator", + "NpcSpeechService", +] diff --git a/src/wolfbot/services/recovery_service.py b/src/wolfbot/services/recovery_service.py index 837041d..abf17c1 100644 --- a/src/wolfbot/services/recovery_service.py +++ b/src/wolfbot/services/recovery_service.py @@ -39,6 +39,12 @@ async def reconcile( async def announce_recovery(self, game: Game, pending: PendingDecision | None) -> None: ... +class ReactiveVoiceRecoverySweep(Protocol): + """Callable that closes in-flight npc_speak_requests and npc_playback_events.""" + + async def __call__(self, game_id: str) -> None: ... + + class RecoveryService: def __init__( self, @@ -47,12 +53,14 @@ def __init__( registry: EngineRegistry, discord: RecoveryDiscordAdapter, clock: Callable[[], int] = lambda: int(time.time()), + reactive_voice_sweep: ReactiveVoiceRecoverySweep | None = None, ) -> None: self.repo = repo self.game_service = game_service self.registry = registry self.discord = discord self.clock = clock + self._reactive_voice_sweep = reactive_voice_sweep async def recover_all(self) -> list[str]: games = await self.repo.load_active_games() @@ -102,6 +110,15 @@ async def _recover_one(self, game: Game) -> None: except Exception: log.exception("announce_recovery failed for %s", game.id) + # Step 2.5: sweep open reactive_voice audit rows so in-flight requests + # and playback windows from before the restart are closed with + # failure_reason=master_restart (npc-voice-pipeline spec §Recovery). + if game.discussion_mode == "reactive_voice" and self._reactive_voice_sweep is not None: + try: + await self._reactive_voice_sweep(game.id) + except Exception: + log.exception("reactive_voice recovery sweep failed for %s", game.id) + # Step 3: attach and start engine. Pass the recovery clock so tests # using FakeClock get deterministic timing — otherwise the engine # uses time.time() and may race ahead of the test's logical clock, diff --git a/src/wolfbot/services/speak_arbiter.py b/src/wolfbot/services/speak_arbiter.py new file mode 100644 index 0000000..2b6795d --- /dev/null +++ b/src/wolfbot/services/speak_arbiter.py @@ -0,0 +1,511 @@ +"""SpeakArbiter — Master-side reactive_voice arbitration. + +Responsibilities, in spec order: + +1. Pick a candidate NPC for the current `PublicDiscussionState` and ensure + they are alive (NOT in `newly_dead`) and online (heartbeat fresh). +2. Reject the candidate when serial-speech is already busy: + - `human_currently_speaking` — a VAD window is open. + - `queue_busy` — another NPC has an authorized playback window open. + - `npc_offline` — the candidate's WS connection or heartbeat is stale. +3. Build a `LogicPacket` (via `master_logic_service.build_logic_packet`) + for the picked NPC, send it, then dispatch a `SpeakRequest` and + persist a row in `npc_speak_requests`. +4. On `SpeakResult` arrival, validate `phase_id` + `request_id` freshness + + length cap, persist a row in `npc_speak_results`, and if accepted, + write a `SpeechEvent(source=npc_generated)`, open the + `npc_playback_events` row, and reply with `PlaybackAuthorized`. +5. On `tts_finished` / `tts_failed` / `playback_finished` / `playback_failed` + update the audit row and release the serial-speech gate. + +A real `MasterWsServer` connection plus a real `SqliteRepo` are required at +runtime. Tests substitute `FakeMasterWsServer` and a tempfile-backed repo. +""" + +from __future__ import annotations + +import logging +import uuid +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass + +from wolfbot.domain.discussion import ( + PublicDiscussionState, + SpeechEvent, + SpeechSource, + make_phase_id, +) +from wolfbot.domain.enums import Phase +from wolfbot.domain.ws_messages import ( + PlaybackAuthorized, + PlaybackFailed, + PlaybackFinished, + SpeakRequest, + SpeakResult, + TtsFailed, + TtsFinished, +) +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discussion_service import ( + DiscussionService, + new_event_id, +) +from wolfbot.services.discussion_service import ( + now_ms as default_now_ms, +) +from wolfbot.services.master_logic_service import build_logic_packet +from wolfbot.services.npc_registry import NpcRegistry + +log = logging.getLogger(__name__) + + +@dataclass +class SpeakArbiterConfig: + max_chars_reactive: int = 80 + request_ttl_ms: int = 8000 + playback_deadline_ms: int = 12_000 + heartbeat_timeout_ms: int = 5000 + + +@dataclass +class _PendingRequest: + request_id: str + npc_id: str + seat_no: int + phase_id: str + game_id: str + expires_at_ms: int + + +class SpeakArbiter: + """Single-game arbiter — `SpeakArbiter.dispatch_for_phase` is called by + the discussion mode plumbing (Bundle 8) once the phase enters + DAY_DISCUSSION under reactive_voice. + + The arbiter is intentionally not a long-running loop; it exposes + discrete operations the dispatcher / WS handlers call. + """ + + def __init__( + self, + *, + repo: SqliteRepo, + registry: NpcRegistry, + discussion: DiscussionService, + config: SpeakArbiterConfig | None = None, + now_ms: Callable[[], int] = default_now_ms, + ) -> None: + self.repo = repo + self.registry = registry + self.discussion = discussion + self.config = config or SpeakArbiterConfig() + self._now_ms = now_ms + self._pending: dict[str, _PendingRequest] = {} + # Serial-speech gate: a request_id is in `_active_playback` between + # PlaybackAuthorized and the closing tts_failed / playback_finished / + # playback_failed event. While non-empty, no new SpeakRequest is sent. + self._active_playback: set[str] = set() + # human_currently_speaking gate; the WS handler flips this on + # vad_speech_started / vad_speech_ended (handled in voice-ingest + # plumbing in Bundle 8). Empty by default. + self._human_speaking_segments: set[str] = set() + + # ------------------------------------------------------------- gates + + def mark_human_speaking(self, segment_id: str) -> None: + self._human_speaking_segments.add(segment_id) + + def clear_human_speaking(self, segment_id: str) -> None: + self._human_speaking_segments.discard(segment_id) + + def is_blocked(self) -> str | None: + if self._human_speaking_segments: + return "human_currently_speaking" + if self._active_playback: + return "queue_busy" + return None + + # ------------------------------------------------------------- dispatch + + async def dispatch_request( + self, + *, + state: PublicDiscussionState, + candidate_npc_id: str, + seat_no: int, + game_id: str, + suggested_intent: str = "speak", + ) -> tuple[SpeakRequest | None, str | None]: + """Try to send a SpeakRequest to `candidate_npc_id`. + + Returns ``(request, None)`` on success, ``(None, reason)`` on skip. + Reasons cover every documented `failure_reason` for arbiter + suppression / candidate skip. + """ + block = self.is_blocked() + if block is not None: + log.info( + "speak_request_suppressed npc=%s seat=%s reason=%s", + candidate_npc_id, + seat_no, + block, + ) + return (None, block) + + entry = self.registry.get(candidate_npc_id) + if entry is None or not entry.is_online or entry.send is None: + log.info( + "speak_candidate_skipped npc=%s reason=npc_offline", + candidate_npc_id, + ) + return (None, "npc_offline") + + now = self._now_ms() + if (now - entry.last_heartbeat_ms) > self.config.heartbeat_timeout_ms: + log.info( + "speak_candidate_skipped npc=%s reason=npc_offline_heartbeat", + candidate_npc_id, + ) + return (None, "npc_offline") + + # Build LogicPacket (sent first so the NPC has context for the + # subsequent speak_request). + packet = build_logic_packet( + state=state, + recipient_npc_id=candidate_npc_id, + expires_at_ms=now + self.config.request_ttl_ms, + now_ms=now, + ) + try: + await entry.send(packet.model_dump_json()) + except Exception: + log.exception("logic_packet_send_failed npc=%s", candidate_npc_id) + return (None, "ws_send_failed") + + request = SpeakRequest( + ts=now, + trace_id=packet.trace_id, + request_id=f"sr_{uuid.uuid4().hex[:12]}", + phase_id=state.phase_id, + npc_id=candidate_npc_id, + seat_no=seat_no, + logic_packet_id=packet.packet_id, + suggested_intent=suggested_intent, + max_chars=self.config.max_chars_reactive, + max_duration_ms=self.config.playback_deadline_ms, + priority=0, + expires_at_ms=now + self.config.request_ttl_ms, + ) + + await self.repo.insert_npc_speak_request( + request_id=request.request_id, + game_id=game_id, + phase_id=request.phase_id, + npc_id=candidate_npc_id, + seat_no=seat_no, + logic_packet_id=request.logic_packet_id, + suggested_intent=suggested_intent, + max_chars=self.config.max_chars_reactive, + max_duration_ms=self.config.playback_deadline_ms, + priority=0, + expires_at_ms=request.expires_at_ms, + created_at_ms=now, + ) + try: + await entry.send(request.model_dump_json()) + except Exception: + log.exception("speak_request_send_failed npc=%s", candidate_npc_id) + await self.repo.insert_npc_speak_result( + request_id=request.request_id, + game_id=game_id, + phase_id=request.phase_id, + npc_id=candidate_npc_id, + status="rejected", + text=None, + used_logic_ids=None, + intent=None, + estimated_duration_ms=None, + failure_reason="ws_send_failed", + received_at_ms=now, + ) + return (None, "ws_send_failed") + + self._pending[request.request_id] = _PendingRequest( + request_id=request.request_id, + npc_id=candidate_npc_id, + seat_no=seat_no, + phase_id=request.phase_id, + game_id=game_id, + expires_at_ms=request.expires_at_ms, + ) + return (request, None) + + # ------------------------------------------------------------- handle result + + async def handle_speak_result( + self, + result: SpeakResult, + *, + current_phase_id: str, + day: int, + phase: Phase, + ) -> tuple[bool, str | None]: + """Validate and persist a SpeakResult. + + On success returns ``(True, None)`` and emits a `PlaybackAuthorized` + on the NPC's back-channel. On failure returns ``(False, reason)`` + and emits a `PlaybackRejected`. + """ + from wolfbot.domain.ws_messages import PlaybackRejected + + now = self._now_ms() + pending = self._pending.get(result.request_id) + entry = self.registry.get(result.npc_id) + + async def _send(payload: str) -> None: + if entry is not None and entry.send is not None: + try: + await entry.send(payload) + except Exception: + log.exception("speak_result_response_send_failed npc=%s", result.npc_id) + + async def _record_rejection(reason: str) -> None: + await self.repo.insert_npc_speak_result( + request_id=result.request_id, + game_id=pending.game_id if pending is not None else "", + phase_id=result.phase_id, + npc_id=result.npc_id, + status="rejected", + text=result.text, + used_logic_ids=list(result.used_logic_ids), + intent=result.intent, + estimated_duration_ms=result.estimated_duration_ms, + failure_reason=reason, + received_at_ms=now, + ) + rejection = PlaybackRejected( + ts=now, + trace_id=result.trace_id, + request_id=result.request_id, + npc_id=result.npc_id, + failure_reason=reason, + ) + await _send(rejection.model_dump_json()) + + if pending is None: + await _record_rejection("unknown_request") + return (False, "unknown_request") + if result.phase_id != current_phase_id: + await _record_rejection("stale_phase") + return (False, "stale_phase") + if now > pending.expires_at_ms: + await _record_rejection("expired_request") + return (False, "expired_request") + if result.status != "accepted" or not result.text: + await _record_rejection("speaker_declined") + return (False, "speaker_declined") + if len(result.text) > self.config.max_chars_reactive: + await _record_rejection("utterance_too_long") + return (False, "utterance_too_long") + + # Accepted. Persist result + SpeechEvent + open playback row. + await self.repo.insert_npc_speak_result( + request_id=result.request_id, + game_id=pending.game_id, + phase_id=result.phase_id, + npc_id=result.npc_id, + status="accepted", + text=result.text, + used_logic_ids=list(result.used_logic_ids), + intent=result.intent, + estimated_duration_ms=result.estimated_duration_ms, + failure_reason=None, + received_at_ms=now, + ) + speech_event = SpeechEvent( + event_id=new_event_id(), + game_id=pending.game_id, + phase_id=result.phase_id, + day=day, + phase=phase, + source=SpeechSource.NPC_GENERATED, + speaker_kind="npc", # type: ignore[arg-type] + speaker_seat=pending.seat_no, + text=result.text, + created_at_ms=now, + ) + await self.discussion.record(speech_event) + deadline = now + self.config.playback_deadline_ms + await self.repo.open_npc_playback( + request_id=result.request_id, + game_id=pending.game_id, + phase_id=result.phase_id, + npc_id=result.npc_id, + speech_event_id=speech_event.event_id, + authorized_at_ms=now, + playback_deadline_ms=deadline, + ) + self._active_playback.add(result.request_id) + authorized = PlaybackAuthorized( + ts=now, + trace_id=result.trace_id, + request_id=result.request_id, + npc_id=result.npc_id, + speech_event_id=speech_event.event_id, + playback_deadline_ms=deadline, + ) + await _send(authorized.model_dump_json()) + return (True, None) + + # ------------------------------------------------------------- TTS / playback + + async def handle_tts_finished(self, msg: TtsFinished) -> None: + await self.repo.update_npc_playback_tts( + msg.request_id, + outcome="success", + duration_ms=msg.tts_duration_ms, + failure_reason=None, + ) + + async def handle_tts_failed(self, msg: TtsFailed) -> None: + now = self._now_ms() + await self.repo.update_npc_playback_tts( + msg.request_id, + outcome="failed", + duration_ms=None, + failure_reason=msg.failure_reason, + ) + await self.repo.close_npc_playback( + msg.request_id, + finished_at_ms=now, + outcome="failed", + failure_reason=msg.failure_reason, + ) + self._active_playback.discard(msg.request_id) + self._pending.pop(msg.request_id, None) + + async def handle_playback_finished(self, msg: PlaybackFinished) -> None: + await self.repo.close_npc_playback( + msg.request_id, + finished_at_ms=msg.finished_at_ms, + outcome="succeeded", + failure_reason=None, + ) + self._active_playback.discard(msg.request_id) + self._pending.pop(msg.request_id, None) + + async def handle_playback_failed(self, msg: PlaybackFailed) -> None: + now = self._now_ms() + await self.repo.close_npc_playback( + msg.request_id, + finished_at_ms=now, + outcome="failed", + failure_reason=msg.failure_reason, + ) + self._active_playback.discard(msg.request_id) + self._pending.pop(msg.request_id, None) + + # ------------------------------------------------------------- auto-dispatch + + async def try_dispatch_next(self, game_id: str) -> None: + """Auto-pick the next candidate NPC and dispatch a SpeakRequest. + + Called on phase entry, after each new public speech event, and after + playback completes. No-op when the serial-speech gate is blocked, no + NPC is online, or no game is in a reactive_voice discussion phase. + """ + game = await self.repo.load_game(game_id) + if game is None or game.ended_at is not None: + return + if game.discussion_mode != "reactive_voice": + return + if game.phase not in (Phase.DAY_DISCUSSION, Phase.DAY_RUNOFF_SPEECH): + return + + block = self.is_blocked() + if block is not None: + return + + state = await self.rebuild_public_state( + game_id=game_id, day=game.day_number, phase=game.phase + ) + if state is None: + return + + # Pick the first online NPC whose assigned seat is alive and silent. + online = self.registry.all_online() + for entry in sorted(online, key=lambda e: e.assigned_seat or 99): + if entry.assigned_seat is None or entry.game_id != game_id: + continue + if entry.assigned_seat not in state.alive_seat_nos: + continue + # Prefer silent seats, but fall back to any alive NPC. + await self.dispatch_request( + state=state, + candidate_npc_id=entry.npc_id, + seat_no=entry.assigned_seat, + game_id=game_id, + ) + return + + # ------------------------------------------------------------- restart sweep + + async def reactive_voice_recovery_sweep(self, game_id: str) -> None: + """Mark every in-flight request rejected and every open playback failed. + + Called once on Master restart from `RecoveryService`. The + ``failure_reason=master_restart`` value is mandated by the + npc-voice-pipeline spec. + """ + now = self._now_ms() + open_reqs = await self.repo.load_open_npc_speak_requests(game_id) + for row in open_reqs: + await self.repo.insert_npc_speak_result( + request_id=row["request_id"], + game_id=game_id, + phase_id=row["phase_id"], + npc_id=row["npc_id"], + status="rejected", + text=None, + used_logic_ids=None, + intent=None, + estimated_duration_ms=None, + failure_reason="master_restart", + received_at_ms=now, + ) + open_play = await self.repo.load_open_npc_playback(game_id) + for row in open_play: + await self.repo.close_npc_playback( + row["request_id"], + finished_at_ms=now, + outcome="failed", + failure_reason="master_restart", + ) + + async def rebuild_public_state( + self, + *, + game_id: str, + day: int, + phase: Phase, + ) -> PublicDiscussionState | None: + """Re-fold `speech_events` for the active phase. + + Used after Master restart to seed the in-memory `PublicDiscussionState` + before re-entering the arbitration loop. + """ + from wolfbot.services.discussion_service import ( + rebuild_public_state_from_events, + ) + + phase_id = make_phase_id(game_id, day, phase) + events: Sequence[SpeechEvent] = await self.discussion.load_phase(game_id, phase_id) + return rebuild_public_state_from_events(events) + + +__all__ = ["SpeakArbiter", "SpeakArbiterConfig"] + + +# Force a non-static reference so the linter keeps Awaitable / Callable +# imports for downstream typing extensions. +_ = (Awaitable, Callable) diff --git a/src/wolfbot/services/structured_logging.py b/src/wolfbot/services/structured_logging.py new file mode 100644 index 0000000..c283470 --- /dev/null +++ b/src/wolfbot/services/structured_logging.py @@ -0,0 +1,164 @@ +"""Shared structured logging helper for master / voice-ingest / NPC workers. + +Per the day-discussion + voice-ingest + npc-voice-pipeline specs, every +in-band log entry must carry the cross-cutting fields `ts`, `level`, +`component`, `event`, `game_id`, `phase_id`, `trace_id`, `span_id` (when +known) plus event-specific fields. We standardize on Python's stdlib +`logging` with a small adapter that injects required fields and produces +JSON-shaped messages. + +This module is dependency-light so all three components can import it. +The `discussion_phase_summary` event is emitted from the Master at every +public-speech phase end; helper builders return the fully-shaped dict so +tests can assert presence and specific counts. + +Test surface: ``CapturingHandler`` records every emitted event so tests +can assert ``events_with(name=..., game_id=...)`` without mocking stdlib +logging primitives. +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Iterator +from dataclasses import dataclass, field +from typing import Any + +# Standardized component identifiers used in `component=` log fields. +COMPONENT_MASTER = "master" +COMPONENT_VOICE_INGEST = "voice-ingest" +COMPONENT_NPC_BOT = "npc-bot" + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def emit_event( + logger: logging.Logger, + *, + event: str, + component: str, + level: int = logging.INFO, + game_id: str | None = None, + phase_id: str | None = None, + trace_id: str | None = None, + span_id: str | None = None, + **fields: Any, +) -> None: + """Emit a structured log event with the canonical envelope fields. + + Underlying logging keeps the event in `extra` so `CapturingHandler` + can introspect it without parsing a JSON-formatted message. + """ + payload: dict[str, Any] = { + "ts": _now_ms(), + "component": component, + "event": event, + "game_id": game_id, + "phase_id": phase_id, + "trace_id": trace_id, + "span_id": span_id, + **fields, + } + logger.log(level, "%s", event, extra={"structured_event": payload}) + + +@dataclass +class _CapturedEvent: + name: str + payload: dict[str, Any] = field(default_factory=dict) + + +class CapturingHandler(logging.Handler): + """Test-only handler that records every structured event. + + Use via: + + handler = CapturingHandler() + logging.getLogger("wolfbot").addHandler(handler) + ... + handler.events_with(name="discussion_phase_summary", game_id="g1") + """ + + def __init__(self) -> None: + super().__init__(level=logging.DEBUG) + self.events: list[_CapturedEvent] = [] + + def emit(self, record: logging.LogRecord) -> None: + payload = getattr(record, "structured_event", None) + if not isinstance(payload, dict): + return + name = payload.get("event") + if name is None: + return + self.events.append(_CapturedEvent(name=str(name), payload=dict(payload))) + + def events_with(self, *, name: str, **filters: Any) -> Iterator[_CapturedEvent]: + for ev in self.events: + if ev.name != name: + continue + if all(ev.payload.get(k) == v for k, v in filters.items()): + yield ev + + +# ---------------------------------------------------------------- builders + + +def build_discussion_phase_summary( + *, + game_id: str, + phase_id: str, + mode: str, + speech_events_total: int, + human_speech_events: int, + npc_speech_events: int, + stt_success: int = 0, + stt_failed: int = 0, + logic_packets_built: int = 0, + speak_requests_sent: int = 0, + speak_results_accepted: int = 0, + speak_results_rejected: int = 0, + playback_authorized: int = 0, + tts_success: int = 0, + tts_failed: int = 0, + playback_success: int = 0, + playback_failed: int = 0, + stale_dropped: int = 0, +) -> dict[str, Any]: + """Build the `discussion_phase_summary` payload (without emitting). + + Master calls ``emit_event(... event="discussion_phase_summary", **payload)`` + once per phase end with this shape. + """ + return { + "game_id": game_id, + "phase_id": phase_id, + "mode": mode, + "speech_events_total": speech_events_total, + "human_speech_events": human_speech_events, + "npc_speech_events": npc_speech_events, + "stt_success": stt_success, + "stt_failed": stt_failed, + "logic_packets_built": logic_packets_built, + "speak_requests_sent": speak_requests_sent, + "speak_results_accepted": speak_results_accepted, + "speak_results_rejected": speak_results_rejected, + "playback_authorized": playback_authorized, + "tts_success": tts_success, + "tts_failed": tts_failed, + "playback_success": playback_success, + "playback_failed": playback_failed, + "stale_dropped": stale_dropped, + } + + +__all__ = [ + "COMPONENT_MASTER", + "COMPONENT_NPC_BOT", + "COMPONENT_VOICE_INGEST", + "CapturingHandler", + "build_discussion_phase_summary", + "emit_event", +] diff --git a/src/wolfbot/services/stt_service.py b/src/wolfbot/services/stt_service.py new file mode 100644 index 0000000..07b70c7 --- /dev/null +++ b/src/wolfbot/services/stt_service.py @@ -0,0 +1,136 @@ +"""Speech-to-text adapter Protocol + Gemini-backed implementation skeleton. + +The MVP STT provider is the Gemini API audio-input feature (per the +voice-ingest spec). We define a Protocol so unit tests can substitute +`FakeSttService` without making real HTTP calls. The Gemini implementation +is intentionally a skeleton — it lifts configuration from env vars and +shows the call site, but cannot be exercised end-to-end without live API +credentials. +""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class SttResult: + """Outcome of a single STT call. + + `text` is empty on a low-confidence drop; `confidence` is set so callers + can apply their own threshold check before deciding to emit a SpeechEvent. + Hard provider failures raise SttProviderError instead. + """ + + text: str + confidence: float + duration_ms: int + + +class SttProviderError(RuntimeError): + """Raised on hard STT failures (timeout, 5xx, malformed response). + + Carries a `failure_reason` matching the canonical voice-ingest enum + (`stt_provider_error`, `stt_timeout`, etc.). + """ + + def __init__(self, failure_reason: str) -> None: + super().__init__(failure_reason) + self.failure_reason = failure_reason + + +@runtime_checkable +class SttService(Protocol): + """Async STT adapter. + + Implementations MUST be cancellable and MUST NOT block the asyncio loop + on the network call (use `asyncio.to_thread` or an async HTTP client). + """ + + async def transcribe( + self, + *, + audio: bytes, + language: str, + timeout_s: float, + ) -> SttResult: ... + + +class FakeSttService: + """In-memory STT for tests. + + Either return a scripted sequence of results or raise scripted errors. + """ + + def __init__( + self, + scripted: list[SttResult | Exception] | None = None, + default: SttResult | None = None, + ) -> None: + self._scripted: list[SttResult | Exception] = list(scripted or []) + self._default: SttResult | None = default + self.call_count = 0 + + async def transcribe( + self, + *, + audio: bytes, + language: str, + timeout_s: float, + ) -> SttResult: + self.call_count += 1 + if self._scripted: + head = self._scripted.pop(0) + if isinstance(head, Exception): + raise head + return head + if self._default is None: + raise SttProviderError("stt_no_script") + return self._default + + +class GeminiSttService: + """Production Gemini API STT adapter. + + The real API call is delegated to a user-supplied `transcribe_fn` so + the actual transport (Google Generative SDK or raw HTTP) is configurable + and the hot path stays test-friendly. This module deliberately does NOT + import the `google.generativeai` SDK at module level so test environments + without those credentials still load this file. + """ + + def __init__( + self, + *, + api_key: str, + model: str, + transcribe_fn: Callable[[bytes, str, str, float], Awaitable[SttResult]] | None = None, + ) -> None: + self.api_key = api_key + self.model = model + self._transcribe_fn = transcribe_fn + + async def transcribe( + self, + *, + audio: bytes, + language: str, + timeout_s: float, + ) -> SttResult: + if self._transcribe_fn is None: + raise SttProviderError("stt_provider_not_configured") + return await self._transcribe_fn(audio, language, self.model, timeout_s) + + +__all__ = [ + "FakeSttService", + "GeminiSttService", + "SttProviderError", + "SttResult", + "SttService", +] diff --git a/src/wolfbot/services/tts_service.py b/src/wolfbot/services/tts_service.py new file mode 100644 index 0000000..69f6b6e --- /dev/null +++ b/src/wolfbot/services/tts_service.py @@ -0,0 +1,145 @@ +"""TTS adapter Protocol + cost-minimized default skeleton. + +The MVP TTS provider is the Google Cloud TTS Standard voices (per design.md), +but we want NPC bots to be configurable. Define a Protocol so production +plugs in any provider and tests substitute `FakeTtsService`. + +Each NPC bot keeps a small in-memory cache keyed by `(provider, voice_id, +sha256(text), speed, pitch)` to avoid re-synthesizing the same utterance. +""" + +from __future__ import annotations + +import hashlib +import logging +from collections import OrderedDict +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class TtsRequest: + text: str + voice_id: str + speed: float = 1.0 + pitch: float = 0.0 + language: str = "ja-JP" + + +@dataclass(frozen=True) +class TtsResult: + audio: bytes + duration_ms: int + sample_rate: int = 48_000 + + +class TtsProviderError(RuntimeError): + """Hard TTS failure (timeout, 5xx, malformed). Carries a `failure_reason`.""" + + def __init__(self, failure_reason: str) -> None: + super().__init__(failure_reason) + self.failure_reason = failure_reason + + +@runtime_checkable +class TtsService(Protocol): + async def synthesize(self, req: TtsRequest) -> TtsResult: ... + + +class FakeTtsService: + """In-memory TTS for tests.""" + + def __init__( + self, + scripted: list[TtsResult | Exception] | None = None, + default: TtsResult | None = None, + ) -> None: + self._scripted = list(scripted or []) + self._default = default or TtsResult(audio=b"audio-fake", duration_ms=500) + self.call_count = 0 + self.requests: list[TtsRequest] = [] + + async def synthesize(self, req: TtsRequest) -> TtsResult: + self.requests.append(req) + self.call_count += 1 + if self._scripted: + head = self._scripted.pop(0) + if isinstance(head, Exception): + raise head + return head + return self._default + + +class GoogleCloudTtsService: + """Production cost-minimized adapter — delegates synthesis to a user-supplied callable. + + Like the STT adapter, this class deliberately does NOT import the + `google-cloud-texttospeech` SDK at module-load time. The real bot wires + a `synth_fn` that issues the actual API call. + """ + + def __init__( + self, + *, + project: str, + synth_fn: Callable[[TtsRequest], Awaitable[TtsResult]] | None = None, + ) -> None: + self.project = project + self._synth_fn = synth_fn + + async def synthesize(self, req: TtsRequest) -> TtsResult: + if self._synth_fn is None: + raise TtsProviderError("tts_provider_not_configured") + return await self._synth_fn(req) + + +class InMemoryTtsCache: + """Bounded LRU cache for synthesized audio. + + The cache is per-process; a Master restart drops it. Keys hash on + `(voice_id, text, speed, pitch)` so distinct utterances are kept + distinct even if their text matches another voice's cache entry. + """ + + def __init__(self, *, max_entries: int = 256) -> None: + self.max_entries = max_entries + self._entries: OrderedDict[str, TtsResult] = OrderedDict() + self.hits = 0 + self.misses = 0 + + @staticmethod + def _key(req: TtsRequest) -> str: + digest = hashlib.sha256(req.text.encode("utf-8")).hexdigest() + return f"{req.voice_id}|{req.speed}|{req.pitch}|{digest}" + + def get(self, req: TtsRequest) -> TtsResult | None: + key = self._key(req) + result = self._entries.get(key) + if result is None: + self.misses += 1 + return None + self.hits += 1 + # Re-insert to mark as recently used. + self._entries.move_to_end(key) + return result + + def put(self, req: TtsRequest, result: TtsResult) -> None: + key = self._key(req) + self._entries[key] = result + self._entries.move_to_end(key) + while len(self._entries) > self.max_entries: + self._entries.popitem(last=False) + + +__all__ = [ + "FakeTtsService", + "GoogleCloudTtsService", + "InMemoryTtsCache", + "TtsProviderError", + "TtsRequest", + "TtsResult", + "TtsService", +] diff --git a/src/wolfbot/services/voice_ingest_client.py b/src/wolfbot/services/voice_ingest_client.py new file mode 100644 index 0000000..5ec8e25 --- /dev/null +++ b/src/wolfbot/services/voice_ingest_client.py @@ -0,0 +1,235 @@ +"""voice-ingest → Master WebSocket client. + +Sends `vad_speech_started` / `vad_speech_ended` / `speech_event_payload` +/ `stt_failed` / `heartbeat` to Master and consumes `registry_snapshot` / +`registry_update`. + +The transport is a `websockets` client connection; tests substitute +`FakeMasterIngestionClient`. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import Awaitable, Callable +from typing import Protocol, runtime_checkable + +from wolfbot.domain.ws_messages import ( + Heartbeat, + SpeechEventPayload, + SttFailed, + VadSpeechEnded, + VadSpeechStarted, +) + +log = logging.getLogger(__name__) + + +@runtime_checkable +class MasterIngestionClient(Protocol): + """The voice-ingest worker's outbound API to Master.""" + + async def send_vad_started(self, msg: VadSpeechStarted) -> None: ... + async def send_vad_ended(self, msg: VadSpeechEnded) -> None: ... + async def send_speech_event_payload(self, msg: SpeechEventPayload) -> None: ... + async def send_stt_failed(self, msg: SttFailed) -> None: ... + async def send_heartbeat(self, msg: Heartbeat) -> None: ... + + +@runtime_checkable +class NpcRegistryView(Protocol): + """Read-side view kept locally on the voice-ingest worker. + + Updated by `apply_snapshot` / `apply_update` triggered by Master pushes. + """ + + def is_npc(self, discord_user_id: str) -> bool: ... + + def npc_user_ids(self) -> set[str]: ... + + +class InMemoryNpcRegistryView: + """The default `NpcRegistryView` used by the voice-ingest worker. + + Fail-closed: if no snapshot has arrived yet, the set is empty and all + audio is processed (the Master-side `npc_stt_discarded` guard prevents + any STT->SpeechEvent leakage during this window — see voice-ingest + spec). + """ + + def __init__(self) -> None: + self._ids: set[str] = set() + + def apply_snapshot(self, npc_user_ids: tuple[str, ...]) -> None: + self._ids = set(npc_user_ids) + + def apply_update(self, added: tuple[str, ...], removed: tuple[str, ...]) -> None: + for uid in removed: + self._ids.discard(uid) + for uid in added: + self._ids.add(uid) + + def is_npc(self, discord_user_id: str) -> bool: + return discord_user_id in self._ids + + def npc_user_ids(self) -> set[str]: + return set(self._ids) + + +class FakeMasterIngestionClient: + """Captures every outbound message in-memory for assertion-based tests.""" + + def __init__(self) -> None: + self.vad_started: list[VadSpeechStarted] = [] + self.vad_ended: list[VadSpeechEnded] = [] + self.speech_payloads: list[SpeechEventPayload] = [] + self.stt_failures: list[SttFailed] = [] + self.heartbeats: list[Heartbeat] = [] + + async def send_vad_started(self, msg: VadSpeechStarted) -> None: + self.vad_started.append(msg) + + async def send_vad_ended(self, msg: VadSpeechEnded) -> None: + self.vad_ended.append(msg) + + async def send_speech_event_payload(self, msg: SpeechEventPayload) -> None: + self.speech_payloads.append(msg) + + async def send_stt_failed(self, msg: SttFailed) -> None: + self.stt_failures.append(msg) + + async def send_heartbeat(self, msg: Heartbeat) -> None: + self.heartbeats.append(msg) + + +class WebsocketsMasterIngestionClient: + """Production client using the `websockets` library. + + Connects to Master's localhost endpoint with `role=voice-ingest&psk=...` + and serializes outbound messages as JSON. Inbound `registry_snapshot` + / `registry_update` events are dispatched to user-supplied callbacks. + """ + + def __init__( + self, + *, + url: str, + psk: str, + on_registry_snapshot: Callable[[tuple[str, ...]], None], + on_registry_update: Callable[[tuple[str, ...], tuple[str, ...]], None], + ) -> None: + self.url = url + self.psk = psk + self._on_snapshot = on_registry_snapshot + self._on_update = on_registry_update + self._ws: object | None = None + self._reader_task: asyncio.Task[None] | None = None + self._lock = asyncio.Lock() + + async def connect(self) -> None: + import websockets + + sep = "?" if "?" not in self.url else "&" + full = f"{self.url}{sep}role=voice-ingest&psk={self.psk}" + self._ws = await websockets.connect(full) + self._reader_task = asyncio.create_task(self._reader_loop()) + + async def close(self) -> None: + if self._reader_task is not None: + self._reader_task.cancel() + self._reader_task = None + if self._ws is not None: + close = getattr(self._ws, "close", None) + if close is not None: + await close() + self._ws = None + + async def _reader_loop(self) -> None: + ws = self._ws + if ws is None: + return + async for raw in ws: # type: ignore[attr-defined] + if isinstance(raw, bytes): + raw = raw.decode("utf-8", "replace") + try: + payload = json.loads(raw) + except json.JSONDecodeError: + continue + t = payload.get("type") + if t == "registry_snapshot": + self._on_snapshot(tuple(payload.get("npc_user_ids", ()))) + elif t == "registry_update": + self._on_update( + tuple(payload.get("added", ())), + tuple(payload.get("removed", ())), + ) + + async def _send(self, message_json: str) -> None: + ws = self._ws + if ws is None: + return + async with self._lock: + try: + await ws.send(message_json) # type: ignore[attr-defined] + except Exception: + log.exception("voice_ingest_send_failed") + + async def send_vad_started(self, msg: VadSpeechStarted) -> None: + await self._send(msg.model_dump_json()) + + async def send_vad_ended(self, msg: VadSpeechEnded) -> None: + await self._send(msg.model_dump_json()) + + async def send_speech_event_payload(self, msg: SpeechEventPayload) -> None: + await self._send(msg.model_dump_json()) + + async def send_stt_failed(self, msg: SttFailed) -> None: + await self._send(msg.model_dump_json()) + + async def send_heartbeat(self, msg: Heartbeat) -> None: + await self._send(msg.model_dump_json()) + + +# Listener helper used by the registry view during reconnects: ensure the +# view applies a snapshot once and caches deltas thereafter. The functional +# wiring lives in the orchestrator so we keep this module dependency-light. + +ListenerFactory = Callable[ + [InMemoryNpcRegistryView], + tuple[ + Callable[[tuple[str, ...]], None], + Callable[[tuple[str, ...], tuple[str, ...]], None], + ], +] + + +def make_default_listeners( + view: InMemoryNpcRegistryView, +) -> tuple[ + Callable[[tuple[str, ...]], None], + Callable[[tuple[str, ...], tuple[str, ...]], None], +]: + def on_snapshot(npc_user_ids: tuple[str, ...]) -> None: + view.apply_snapshot(npc_user_ids) + + def on_update(added: tuple[str, ...], removed: tuple[str, ...]) -> None: + view.apply_update(added, removed) + + return on_snapshot, on_update + + +__all__ = [ + "FakeMasterIngestionClient", + "InMemoryNpcRegistryView", + "ListenerFactory", + "MasterIngestionClient", + "NpcRegistryView", + "WebsocketsMasterIngestionClient", + "make_default_listeners", +] + + +# Re-bind Awaitable so type checkers keep the import. +_ = Awaitable diff --git a/src/wolfbot/services/voice_ingest_service.py b/src/wolfbot/services/voice_ingest_service.py new file mode 100644 index 0000000..b689a2c --- /dev/null +++ b/src/wolfbot/services/voice_ingest_service.py @@ -0,0 +1,320 @@ +"""voice-ingest worker — VAD + STT + Master ingestion. + +The worker pipeline: + +1. ``handle_voice_packet`` is called for every Discord-side voice packet. + - If the speaker's `discord_user_id` is in the NPC registry view, the + packet is discarded at the receive boundary (no VAD, no STT). +2. Otherwise the packet feeds a per-speaker buffer. ``begin_segment`` / + ``end_segment`` correspond to the VAD lifecycle. ``begin_segment`` + sends `vad_speech_started` to Master and assigns a `segment_id`; + ``end_segment`` sends `vad_speech_ended` and triggers async STT. +3. STT result handling: + - Below `confidence_threshold` → drop, log `stt_low_confidence`, + send `stt_failed` to Master with `failure_reason=stt_low_confidence`. + - Hard `SttProviderError` → drop, log `stt_request_failed`, + send `stt_failed` with the provider's failure_reason. + - Otherwise → send `speech_event_payload` to Master. + +VAD itself is provided by an injected `VadEngine` Protocol so unit tests +can replay scripted VAD transitions without driving real audio. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +import uuid +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + +from wolfbot.domain.ws_messages import ( + Heartbeat, + SpeechEventPayload, + SttFailed, + VadSpeechEnded, + VadSpeechStarted, +) +from wolfbot.services.stt_service import ( + SttProviderError, + SttResult, + SttService, +) +from wolfbot.services.voice_ingest_client import ( + MasterIngestionClient, + NpcRegistryView, +) + +log = logging.getLogger(__name__) + + +@dataclass +class VoiceIngestConfig: + confidence_threshold: float = 0.6 + stt_timeout_s: float = 10.0 + stt_language: str = "ja-JP" + vad_finalization_timeout_ms: int = 4000 + heartbeat_interval_s: float = 5.0 + + +@dataclass +class _OpenSegment: + """Per-speaker open VAD segment, awaiting ``end_segment``.""" + + segment_id: str + seat_no: int + speaker_user_id: str + audio_start_ms: int + audio_buffer: bytearray = field(default_factory=bytearray) + + +@runtime_checkable +class VadEngine(Protocol): + """VAD lifecycle events generator. Production VAD is webrtc-based; tests + drive the lifecycle manually.""" + + def push_pcm(self, *, speaker_user_id: str, pcm: bytes) -> tuple[str | None, bytes | None]: + """Push a PCM frame; return (event, audio) where event ∈ + {None, "started", "ended"} and audio is the buffered audio when + the segment ends.""" + ... + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +class VoiceIngestService: + """Single-process voice-ingest orchestrator. + + Has no Discord-specific code itself — wire it from the worker entrypoint + that subscribes to discord.VoiceClient events. The Discord packet shape + feeds ``handle_voice_packet``; the VAD lifecycle is driven by + ``begin_segment`` / ``end_segment`` (or by an injected VAD engine). + """ + + def __init__( + self, + *, + registry_view: NpcRegistryView, + master_client: MasterIngestionClient, + stt: SttService, + seat_lookup: Callable[[str], int | None], + phase_lookup: Callable[[], tuple[str, str] | None], + config: VoiceIngestConfig | None = None, + now_ms: Callable[[], int] = _now_ms, + ) -> None: + self.registry_view = registry_view + self.master_client = master_client + self.stt = stt + self.seat_lookup = seat_lookup + # phase_lookup returns (game_id, phase_id) for the current discussion phase, or None. + self.phase_lookup = phase_lookup + self.config = config or VoiceIngestConfig() + self._now_ms = now_ms + self._open_segments: dict[str, _OpenSegment] = {} + self.dropped_npc_packets = 0 + self.stt_low_confidence_count = 0 + self.stt_provider_error_count = 0 + + # ---------------------------------------------------------- packet boundary + + async def handle_voice_packet(self, *, speaker_user_id: str, pcm: bytes) -> bool: + """Returns True if the packet was forwarded to VAD, False if dropped.""" + if self.registry_view.is_npc(speaker_user_id): + self.dropped_npc_packets += 1 + return False + # Append to whichever segment is open for this speaker (if any). + seg = self._open_segments.get(speaker_user_id) + if seg is not None: + seg.audio_buffer.extend(pcm) + return True + + # ---------------------------------------------------------- VAD lifecycle + + async def begin_segment(self, *, speaker_user_id: str) -> str | None: + """Open a VAD window for `speaker_user_id` and notify Master. + + Returns the new `segment_id`, or None if a session is not active + (no game phase / unknown seat). + """ + if self.registry_view.is_npc(speaker_user_id): + return None + phase = self.phase_lookup() + if phase is None: + return None + game_id, phase_id = phase + seat_no = self.seat_lookup(speaker_user_id) + if seat_no is None: + return None + segment_id = f"seg_{uuid.uuid4().hex[:12]}" + now = self._now_ms() + self._open_segments[speaker_user_id] = _OpenSegment( + segment_id=segment_id, + seat_no=seat_no, + speaker_user_id=speaker_user_id, + audio_start_ms=now, + ) + msg = VadSpeechStarted( + ts=now, + trace_id=f"vi-{segment_id}", + game_id=game_id, + phase_id=phase_id, + speaker_discord_user_id=speaker_user_id, + seat_no=seat_no, + segment_id=segment_id, + audio_start_ms=now, + ) + await self.master_client.send_vad_started(msg) + return segment_id + + async def end_segment(self, *, speaker_user_id: str) -> None: + seg = self._open_segments.pop(speaker_user_id, None) + if seg is None: + return + phase = self.phase_lookup() + if phase is None: + return + game_id, phase_id = phase + now = self._now_ms() + await self.master_client.send_vad_ended( + VadSpeechEnded( + ts=now, + trace_id=f"vi-{seg.segment_id}", + game_id=game_id, + phase_id=phase_id, + speaker_discord_user_id=seg.speaker_user_id, + seat_no=seg.seat_no, + segment_id=seg.segment_id, + audio_end_ms=now, + ) + ) + await self._run_stt(seg, game_id, phase_id, audio_end_ms=now) + + async def abandon_open_segments(self) -> int: + """Restart-time cleanup. Returns count of abandoned segments.""" + n = len(self._open_segments) + self._open_segments.clear() + if n: + log.info("voice_ingest_restart abandoned_segments=%d", n) + return n + + # ---------------------------------------------------------- STT pipeline + + async def _run_stt( + self, + seg: _OpenSegment, + game_id: str, + phase_id: str, + *, + audio_end_ms: int, + ) -> None: + try: + result: SttResult = await self.stt.transcribe( + audio=bytes(seg.audio_buffer), + language=self.config.stt_language, + timeout_s=self.config.stt_timeout_s, + ) + except SttProviderError as exc: + self.stt_provider_error_count += 1 + log.info( + "stt_request_failed game=%s phase=%s segment=%s reason=%s", + game_id, + phase_id, + seg.segment_id, + exc.failure_reason, + ) + await self.master_client.send_stt_failed( + SttFailed( + ts=self._now_ms(), + trace_id=f"vi-{seg.segment_id}", + game_id=game_id, + phase_id=phase_id, + speaker_discord_user_id=seg.speaker_user_id, + seat_no=seg.seat_no, + segment_id=seg.segment_id, + failure_reason=exc.failure_reason, + ) + ) + return + except Exception: + log.exception( + "stt_request_failed_unexpected game=%s segment=%s", + game_id, + seg.segment_id, + ) + await self.master_client.send_stt_failed( + SttFailed( + ts=self._now_ms(), + trace_id=f"vi-{seg.segment_id}", + game_id=game_id, + phase_id=phase_id, + speaker_discord_user_id=seg.speaker_user_id, + seat_no=seg.seat_no, + segment_id=seg.segment_id, + failure_reason="stt_provider_error", + ) + ) + return + + if result.confidence < self.config.confidence_threshold: + self.stt_low_confidence_count += 1 + log.info( + "stt_low_confidence game=%s segment=%s conf=%.2f", + game_id, + seg.segment_id, + result.confidence, + ) + await self.master_client.send_stt_failed( + SttFailed( + ts=self._now_ms(), + trace_id=f"vi-{seg.segment_id}", + game_id=game_id, + phase_id=phase_id, + speaker_discord_user_id=seg.speaker_user_id, + seat_no=seg.seat_no, + segment_id=seg.segment_id, + failure_reason="stt_low_confidence", + ) + ) + return + + await self.master_client.send_speech_event_payload( + SpeechEventPayload( + ts=self._now_ms(), + trace_id=f"vi-{seg.segment_id}", + game_id=game_id, + phase_id=phase_id, + seat_no=seg.seat_no, + speaker_discord_user_id=seg.speaker_user_id, + segment_id=seg.segment_id, + text=result.text, + confidence=result.confidence, + duration_ms=result.duration_ms, + audio_start_ms=seg.audio_start_ms, + audio_end_ms=audio_end_ms, + ) + ) + + # ---------------------------------------------------------- heartbeat + + async def heartbeat_loop(self, stop_event: asyncio.Event) -> None: + while not stop_event.is_set(): + await self.master_client.send_heartbeat(Heartbeat(ts=self._now_ms(), trace_id="vi-hb")) + try: + await asyncio.wait_for(stop_event.wait(), timeout=self.config.heartbeat_interval_s) + except TimeoutError: + continue + + +__all__ = [ + "VadEngine", + "VoiceIngestConfig", + "VoiceIngestService", +] + + +# keep imports referenced +_ = (Awaitable, Callable) diff --git a/src/wolfbot/services/voice_playback_service.py b/src/wolfbot/services/voice_playback_service.py new file mode 100644 index 0000000..10080a2 --- /dev/null +++ b/src/wolfbot/services/voice_playback_service.py @@ -0,0 +1,65 @@ +"""Discord voice channel playback Protocol + Fake. + +NPC bots play TTS audio in `MAIN_VOICE_CHANNEL_ID` only after Master sends +`PlaybackAuthorized`. The Discord-side playback API is wrapped behind a +Protocol so unit tests use `FakeVoicePlayback`. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + + +class VoicePlaybackError(RuntimeError): + """Playback failure that should surface via `playback_failed`.""" + + def __init__(self, failure_reason: str) -> None: + super().__init__(failure_reason) + self.failure_reason = failure_reason + + +@runtime_checkable +class VoicePlayback(Protocol): + async def play(self, *, audio: bytes, sample_rate: int) -> tuple[int, int]: + """Play `audio`. Returns (started_at_ms, finished_at_ms).""" + ... + + +@dataclass +class FakeVoicePlayback: + """Captures playback calls and drives configurable timing for tests.""" + + started_at_ms: int = 0 + finished_at_ms: int = 0 + raise_for_audio: Exception | None = None + plays: list[tuple[bytes, int]] = field(default_factory=list) + + async def play(self, *, audio: bytes, sample_rate: int) -> tuple[int, int]: + self.plays.append((audio, sample_rate)) + if self.raise_for_audio is not None: + raise self.raise_for_audio + return (self.started_at_ms, self.finished_at_ms) + + +@dataclass +class DiscordVoicePlayback: + """Production playback wrapper. + + Lifts the actual play-into-VC operation into a user-supplied async + callable so this module remains decoupled from `discord.py`. + """ + + play_fn: Callable[[bytes, int], Awaitable[tuple[int, int]]] + + async def play(self, *, audio: bytes, sample_rate: int) -> tuple[int, int]: + return await self.play_fn(audio, sample_rate) + + +__all__ = [ + "DiscordVoicePlayback", + "FakeVoicePlayback", + "VoicePlayback", + "VoicePlaybackError", +] diff --git a/tests/test_discord_cog_create.py b/tests/test_discord_cog_create.py index cb6ac45..8644242 100644 --- a/tests/test_discord_cog_create.py +++ b/tests/test_discord_cog_create.py @@ -114,9 +114,7 @@ async def test_create_game_db_failure_cleans_up_both_channels(repo) -> None: wolves = FakeChannel(id=12) queue: list[FakeChannel] = [heaven, wolves] - async def fake_create( - guild: Any, name: str, *, safe_to_delete_ids: set[str] - ) -> FakeChannel: + async def fake_create(guild: Any, name: str, *, safe_to_delete_ids: set[str]) -> FakeChannel: return queue.pop(0) cog._create_private_channel = fake_create # type: ignore[method-assign] diff --git a/tests/test_discussion_state.py b/tests/test_discussion_state.py new file mode 100644 index 0000000..35d0e16 --- /dev/null +++ b/tests/test_discussion_state.py @@ -0,0 +1,444 @@ +"""Bundle 1 tests — SpeechEvent persistence, sentinel insertion, post-write hooks, +and PublicDiscussionState rebuild. + +Covers tasks 1.4 and 1.5 of the speech-event-bus-foundation bundle: + * SpeechEvent round-trip through SqliteSpeechEventStore. + * begin_phase() inserts a sentinel and returns the canonical phase_id. + * DiscussionService.record() emits PLAYER_SPEECH LogEntry per source. + * DiscussionService.record() skips channel post for source=text. + * DiscussionService.record() skips both LogEntry and channel post for sentinels. + * rebuild_public_state_from_events() is bitwise reproducible from a fresh fold. + * silent_seats correctly excludes dead seats (via the sentinel baseline). + * co_claims are detected from canonical CO tokens. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest_asyncio + +from wolfbot.domain.discussion import SpeakerKind, SpeechSource, make_phase_id +from wolfbot.domain.enums import Phase +from wolfbot.domain.models import LogEntry +from wolfbot.persistence.schema import migrate +from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + make_human_text_event, + make_npc_generated_event, + make_voice_stt_event, + rebuild_public_state_from_events, +) + + +@pytest_asyncio.fixture +async def store_conn(tmp_path: Path) -> AsyncIterator: + import aiosqlite + + db_path = tmp_path / "speech.db" + await migrate(db_path) + conn = await aiosqlite.connect(str(db_path)) + try: + yield conn + finally: + await conn.close() + + +# ----- in-memory recording fakes for the service hooks ------------------------------ + + +class _RecordingLogSink: + def __init__(self) -> None: + self.entries: list[LogEntry] = [] + + async def insert_log_public(self, entry: LogEntry) -> None: + self.entries.append(entry) + + +class _RecordingPoster: + def __init__(self) -> None: + self.posts: list[tuple[str, str, str]] = [] + + async def post_public(self, game_id: str, text: str, kind: str) -> None: + self.posts.append((game_id, text, kind)) + + +# ----- store round-trip -------------------------------------------------------------- + + +async def test_speech_event_round_trip_persists_all_fields(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + phase_id = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + voice_event = make_voice_stt_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=3, + text="P1のCOちょっと遅くなかった?", + stt_confidence=0.91, + audio_start_ms=72100, + audio_end_ms=74400, + created_at_ms=1_710_000_000_000, + ) + + await store.insert(voice_event) + rows = await store.load_phase("g1", phase_id) + + assert len(rows) == 1 + loaded = rows[0] + assert loaded == voice_event + + +async def test_load_phase_filters_by_phase_id(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + pid_a = make_phase_id("g1", 1, Phase.DAY_DISCUSSION, sequence=1) + pid_b = make_phase_id("g1", 1, Phase.DAY_DISCUSSION, sequence=2) + + e1 = make_human_text_event( + game_id="g1", + phase_id=pid_a, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="まず情報整理しよう", + created_at_ms=1, + ) + e2 = make_npc_generated_event( + game_id="g1", + phase_id=pid_b, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=5, + text="占いCOがいないのは不自然", + created_at_ms=2, + ) + await store.insert(e1) + await store.insert(e2) + + rows_a = await store.load_phase("g1", pid_a) + rows_b = await store.load_phase("g1", pid_b) + + assert [r.event_id for r in rows_a] == [e1.event_id] + assert [r.event_id for r in rows_b] == [e2.event_id] + + +# ----- begin_phase + sentinel -------------------------------------------------------- + + +async def test_begin_phase_inserts_sentinel_and_returns_phase_id(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + log_sink = _RecordingLogSink() + poster = _RecordingPoster() + service = DiscussionService(store, log_sink=log_sink, message_poster=poster) + + phase_id = await service.begin_phase( + game_id="g1", + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3, 4, 5, 6, 7, 8, 9], + ) + + assert phase_id == make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + rows = await store.load_phase("g1", phase_id) + assert len(rows) == 1 + sentinel = rows[0] + assert sentinel.source == SpeechSource.PHASE_BASELINE + assert sentinel.speaker_kind == SpeakerKind.SYSTEM + assert sentinel.speaker_seat is None + # sentinels emit no PLAYER_SPEECH and no main-channel post + assert log_sink.entries == [] + assert poster.posts == [] + + +async def test_sentinel_alive_seat_nos_are_sorted_unique(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + service = DiscussionService(store) + phase_id = await service.begin_phase( + game_id="g1", + day=2, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[3, 1, 1, 5, 2], # duplicated and unsorted + ) + rows = await service.load_phase("g1", phase_id) + assert rows[0].alive_seat_nos_json == "[1, 2, 3, 5]" + + +# ----- record() side-effect dispatch ------------------------------------------------ + + +async def test_record_text_skips_channel_post_emits_log(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + log_sink = _RecordingLogSink() + poster = _RecordingPoster() + service = DiscussionService(store, log_sink=log_sink, message_poster=poster) + + phase_id = await service.begin_phase( + game_id="g1", + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=range(1, 10), + ) + text_event = make_human_text_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="占いCOまだですか?", + ) + + await service.record(text_event) + + assert len(log_sink.entries) == 1 + entry = log_sink.entries[0] + assert entry.kind == DiscussionService.PLAYER_SPEECH_KIND + assert entry.actor_seat == 2 + assert entry.text == "占いCOまだですか?" + assert entry.visibility == "PUBLIC" + # the original Discord message is the channel post — service must not duplicate + assert poster.posts == [] + + +async def test_record_voice_stt_emits_log_and_channel_post(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + log_sink = _RecordingLogSink() + poster = _RecordingPoster() + service = DiscussionService(store, log_sink=log_sink, message_poster=poster) + + phase_id = await service.begin_phase( + game_id="g1", + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=range(1, 10), + ) + voice_event = make_voice_stt_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=4, + text="P1のCOタイミングは怪しい", + stt_confidence=0.82, + audio_start_ms=10, + audio_end_ms=2010, + ) + + await service.record(voice_event) + + assert len(log_sink.entries) == 1 + assert log_sink.entries[0].kind == DiscussionService.PLAYER_SPEECH_KIND + assert poster.posts == [ + ("g1", "P1のCOタイミングは怪しい", DiscussionService.PLAYER_SPEECH_KIND) + ] + + +async def test_record_npc_generated_emits_log_and_channel_post(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + log_sink = _RecordingLogSink() + poster = _RecordingPoster() + service = DiscussionService(store, log_sink=log_sink, message_poster=poster) + + phase_id = await service.begin_phase( + game_id="g1", + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=range(1, 10), + ) + npc_event = make_npc_generated_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=5, + text="今は決め打たない方がよさそう", + ) + + await service.record(npc_event) + + assert len(log_sink.entries) == 1 + assert log_sink.entries[0].actor_seat == 5 + assert poster.posts == [ + ("g1", "今は決め打たない方がよさそう", DiscussionService.PLAYER_SPEECH_KIND) + ] + + +# ----- rebuild ---------------------------------------------------------------------- + + +async def test_rebuild_returns_none_for_phase_without_sentinel(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + pid = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + e = make_human_text_event( + game_id="g1", + phase_id=pid, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="hi", + created_at_ms=1, + ) + await store.insert(e) + rows = await store.load_phase("g1", pid) + assert rebuild_public_state_from_events(rows) is None + + +async def test_rebuild_silent_seats_excludes_dead_seats(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + service = DiscussionService(store) + # day 2: seats 1, 3, 4, 6, 7, 8, 9 alive; seats 2 and 5 already dead + alive = [1, 3, 4, 6, 7, 8, 9] + phase_id = await service.begin_phase( + game_id="g1", + day=2, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=alive, + ) + # seats 1, 3, 4 speak; 6, 7, 8, 9 stay silent + for i, seat in enumerate([1, 3, 4]): + await service.record( + make_human_text_event( + game_id="g1", + phase_id=phase_id, + day=2, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat, + text=f"発言{seat}", + created_at_ms=100 + i, + ) + ) + + rows = await service.load_phase("g1", phase_id) + state = rebuild_public_state_from_events(rows) + assert state is not None + assert state.alive_seat_nos == frozenset(alive) + # silent_seats includes only alive non-speakers — dead seats 2 and 5 must NOT appear + assert state.silent_seats == frozenset({6, 7, 8, 9}) + assert 2 not in state.silent_seats + assert 5 not in state.silent_seats + + +async def test_rebuild_co_claims_capture_canonical_tokens(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + service = DiscussionService(store) + phase_id = await service.begin_phase( + game_id="g1", + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=range(1, 10), + ) + await service.record( + make_human_text_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=4, + text="占いCOします。1白", + created_at_ms=10, + ) + ) + await service.record( + make_npc_generated_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=4, + text="占いCO(再宣言)", + created_at_ms=11, + ) + ) + await service.record( + make_human_text_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=7, + text="霊媒COです", + created_at_ms=12, + ) + ) + + state = rebuild_public_state_from_events(await service.load_phase("g1", phase_id)) + assert state is not None + role_claims = sorted((c.seat, c.role_claim) for c in state.co_claims) + assert role_claims == [(4, "seer"), (7, "medium")] + + +async def test_rebuild_is_deterministic_from_fresh_fold(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + service = DiscussionService(store) + phase_id = await service.begin_phase( + game_id="g1", + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=range(1, 10), + ) + for i, (seat, text) in enumerate( + [(1, "占いCO"), (2, "もう少し聞かせて"), (3, "霊媒COお願いします")], start=10 + ): + await service.record( + make_human_text_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat, + text=text, + created_at_ms=i, + ) + ) + + rows = await service.load_phase("g1", phase_id) + s1 = rebuild_public_state_from_events(rows) + s2 = rebuild_public_state_from_events(rows) + # Pure fold: two rebuilds from the same input must be equal in all observable fields. + assert s1 is not None and s2 is not None + assert s1.alive_seat_nos == s2.alive_seat_nos + assert s1.co_claims == s2.co_claims + assert s1.silent_seats == s2.silent_seats + assert s1.recent_speech_event_ids == s2.recent_speech_event_ids + + +# ----- failure-tolerance: log/poster errors must not break the write ---------------- + + +class _FailingPoster: + async def post_public(self, *_args, **_kwargs) -> None: + raise RuntimeError("discord down") + + +class _FailingLogSink: + async def insert_log_public(self, _entry: LogEntry) -> None: + raise RuntimeError("logs_public down") + + +async def test_record_swallows_post_failure_so_persistence_still_succeeds(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + service = DiscussionService( + store, + log_sink=_FailingLogSink(), + message_poster=_FailingPoster(), + ) + phase_id = await service.begin_phase( + game_id="g1", + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=range(1, 10), + ) + npc_event = make_npc_generated_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=5, + text="hi", + ) + await service.record(npc_event) + rows = await service.load_phase("g1", phase_id) + # sentinel + 1 npc event persisted even though the post-write hooks raised + assert len(rows) == 2 diff --git a/tests/test_game_service_advance.py b/tests/test_game_service_advance.py index 2e72001..2d57192 100644 --- a/tests/test_game_service_advance.py +++ b/tests/test_game_service_advance.py @@ -1870,20 +1870,12 @@ async def test_mixed_human_llm_wolf_split_triggers_early_wake( wakes: list[str] = [] reg.wake = lambda gid: wakes.append(gid) # type: ignore[method-assign] - await service.submit_night_action( - game.id, 2, SubmissionType.SEER_DIVINE, 9, day=1 - ) - await service.submit_night_action( - game.id, 3, SubmissionType.KNIGHT_GUARD, 2, day=1 - ) + await service.submit_night_action(game.id, 2, SubmissionType.SEER_DIVINE, 9, day=1) + await service.submit_night_action(game.id, 3, SubmissionType.KNIGHT_GUARD, 2, day=1) # Wolves split: human picks 4, LLM picks 5. Human-wolf priority resolves # in favor of seat 4, so the night is decided. - await service.submit_night_action( - game.id, 1, SubmissionType.WOLF_ATTACK, 4, day=1 - ) - await service.submit_night_action( - game.id, 9, SubmissionType.WOLF_ATTACK, 5, day=1 - ) + await service.submit_night_action(game.id, 1, SubmissionType.WOLF_ATTACK, 4, day=1) + await service.submit_night_action(game.id, 9, SubmissionType.WOLF_ATTACK, 5, day=1) assert wakes == [game.id] diff --git a/tests/test_llm_prompt_builder.py b/tests/test_llm_prompt_builder.py index ca80dc2..3d0ab6d 100644 --- a/tests/test_llm_prompt_builder.py +++ b/tests/test_llm_prompt_builder.py @@ -961,17 +961,13 @@ def test_werewolf_strategy_disclaims_real_role_inference() -> None: assert "公開情報からの推定" in block -@pytest.mark.parametrize( - "role", [Role.MADMAN, Role.SEER, Role.MEDIUM, Role.KNIGHT, Role.VILLAGER] -) +@pytest.mark.parametrize("role", [Role.MADMAN, Role.SEER, Role.MEDIUM, Role.KNIGHT, Role.VILLAGER]) def test_wolf_attack_only_vocabulary_never_in_non_wolf_strategy(role: Role) -> None: """The new tactical phrases are wolf-private. Any leak (e.g. someone copies the wolf bullet into the knight strategy by mistake) must trip this guard.""" block = _build_strategy_block(role) for phrase in _WOLF_ATTACK_ONLY_PHRASES: - assert phrase not in block, ( - f"wolf-only attack vocab {phrase!r} leaked into {role.name}" - ) + assert phrase not in block, f"wolf-only attack vocab {phrase!r} leaked into {role.name}" # ---------------------------------------------------- night-action task block diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index 17773cc..2913181 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -1228,9 +1228,7 @@ async def test_ask_system_prompt_wolf_attack_task_includes_checklist( the same 4-axis rubric must reach the LLM via the `{task_block}` slot — not only via the strategy block. Exercises the path `_one_night_action` uses.""" task_text = task_night_action(SubmissionType.WOLF_ATTACK, ["席1 A", "席2 B"]) - system_prompt = await _capture_ask_system_prompt( - repo, Role.WEREWOLF, task_text=task_text - ) + system_prompt = await _capture_ask_system_prompt(repo, Role.WEREWOLF, task_text=task_text) assert "襲撃価値" in system_prompt assert "護衛されやすさ" in system_prompt assert "騎士候補度" in system_prompt @@ -1265,9 +1263,7 @@ async def test_ask_system_prompt_wolf_vote_task_includes_partner_checklist( role=Role.WEREWOLF, wolf_partner_tokens=["席3 PartnerName"], ) - system_prompt = await _capture_ask_system_prompt( - repo, Role.WEREWOLF, task_text=task_text - ) + system_prompt = await _capture_ask_system_prompt(repo, Role.WEREWOLF, task_text=task_text) for token in ( "仲間の人狼", "席3 PartnerName", @@ -1290,13 +1286,9 @@ async def test_ask_system_prompt_wolf_runoff_vote_task_includes_runoff_checklist role=Role.WEREWOLF, wolf_partner_tokens=["席3 PartnerName"], ) - system_prompt = await _capture_ask_system_prompt( - repo, Role.WEREWOLF, task_text=task_text - ) + system_prompt = await _capture_ask_system_prompt(repo, Role.WEREWOLF, task_text=task_text) for token in ("決選投票", "透け", "PP/RPP", "仲間の人狼"): - assert token in system_prompt, ( - f"wolf runoff vote system prompt missing {token!r}" - ) + assert token in system_prompt, f"wolf runoff vote system prompt missing {token!r}" async def test_ask_system_prompt_non_wolf_vote_task_excludes_partner_vocabulary( @@ -1307,18 +1299,12 @@ async def test_ask_system_prompt_non_wolf_vote_task_excludes_partner_vocabulary( even though `身内票` / `ライン切り` themselves are shared rules vocab.""" for role in (Role.SEER, Role.MEDIUM, Role.KNIGHT, Role.VILLAGER, Role.MADMAN): task_text = task_vote(["席1 H1"], runoff=False, role=role) - system_prompt = await _capture_ask_system_prompt( - repo, role, task_text=task_text - ) + system_prompt = await _capture_ask_system_prompt(repo, role, task_text=task_text) assert "仲間の人狼" not in system_prompt, ( f"'仲間の人狼' leaked into {role.name} vote prompt" ) - assert "相方を救" not in system_prompt, ( - f"'相方を救' leaked into {role.name} vote prompt" - ) - assert "相方を切" not in system_prompt, ( - f"'相方を切' leaked into {role.name} vote prompt" - ) + assert "相方を救" not in system_prompt, f"'相方を救' leaked into {role.name} vote prompt" + assert "相方を切" not in system_prompt, f"'相方を切' leaked into {role.name} vote prompt" async def test_ask_system_prompt_madman_vote_task_drops_partner_token( @@ -1335,9 +1321,7 @@ async def test_ask_system_prompt_madman_vote_task_drops_partner_token( ) assert "仲間の人狼" not in task_text assert "席3 X" not in task_text - system_prompt = await _capture_ask_system_prompt( - repo, Role.MADMAN, task_text=task_text - ) + system_prompt = await _capture_ask_system_prompt(repo, Role.MADMAN, task_text=task_text) assert "仲間の人狼" not in system_prompt assert "席3 X" not in system_prompt @@ -1622,9 +1606,7 @@ async def test_ask_system_prompt_knight_guard_task_includes_checklist( forbidden literal substrings may appear — that would trip the existing parametrized leak test in the unit-level prompt builder tests.""" task_text = task_night_action(SubmissionType.KNIGHT_GUARD, ["席1 A", "席2 B"]) - system_prompt = await _capture_ask_system_prompt( - repo, Role.KNIGHT, task_text=task_text - ) + system_prompt = await _capture_ask_system_prompt(repo, Role.KNIGHT, task_text=task_text) # Positive — task-level checklist reached the LLM. assert "鉄板護衛" in system_prompt assert "捨て護衛" in system_prompt @@ -1723,9 +1705,7 @@ async def test_ask_system_prompt_seer_divine_task_includes_targeting_checklist( parallel of the wolf-attack and knight-guard task tests above. Wolf-task forbidden substrings must remain absent (existing leak guard reinforced).""" task_text = task_night_action(SubmissionType.SEER_DIVINE, ["席1 A", "席2 B"]) - system_prompt = await _capture_ask_system_prompt( - repo, Role.SEER, task_text=task_text - ) + system_prompt = await _capture_ask_system_prompt(repo, Role.SEER, task_text=task_text) # Positive — task-level targeting checklist reached the LLM. assert "占い価値" in system_prompt assert "灰を狭める" in system_prompt @@ -2126,6 +2106,7 @@ async def test_ask_user_context_no_wolf_partner_block_for_villager(repo: SqliteR # test_llm_prompt_builder.py. Verify the new content actually reaches the LLM # via the full system-prompt assembly path. + @pytest.mark.parametrize("role", list(Role)) async def test_ask_system_prompt_pair_inference_reaches_any_role( repo: SqliteRepo, role: Role @@ -2134,12 +2115,8 @@ async def test_ask_system_prompt_pair_inference_reaches_any_role( via the rules block. `相方候補` (inference noun) and `2 人狼` (the canonical pair label) are the two shared anchors.""" system_prompt = await _capture_ask_system_prompt(repo, role) - assert "相方候補" in system_prompt, ( - f"{role.name} missing pair-inference anchor '相方候補'" - ) - assert "2 人狼" in system_prompt, ( - f"{role.name} missing pair-inference anchor '2 人狼'" - ) + assert "相方候補" in system_prompt, f"{role.name} missing pair-inference anchor '相方候補'" + assert "2 人狼" in system_prompt, f"{role.name} missing pair-inference anchor '2 人狼'" async def test_ask_system_prompt_wolf_seat_includes_two_wolf_set_framing( @@ -2183,9 +2160,7 @@ async def test_ask_system_prompt_wolf_vote_task_extends_with_two_wolf_set( role=Role.WEREWOLF, wolf_partner_tokens=["席3 PartnerName"], ) - system_prompt = await _capture_ask_system_prompt( - repo, Role.WEREWOLF, task_text=task_text - ) + system_prompt = await _capture_ask_system_prompt(repo, Role.WEREWOLF, task_text=task_text) assert "2 人狼セット" in system_prompt # Existing wolf-only vote anchors preserved. assert "相方" in system_prompt diff --git a/tests/test_master_ws_server.py b/tests/test_master_ws_server.py new file mode 100644 index 0000000..814cd35 --- /dev/null +++ b/tests/test_master_ws_server.py @@ -0,0 +1,544 @@ +"""Bundle 4: master speech control surface. + +Exercises: +- WebSocket envelope dispatch with the canonical `type` field. +- NPC `npc_register` → `npc_registered` handshake updates the registry and + replies on the back-channel. +- Heartbeats refresh `last_heartbeat_ms` and revive an offline NPC. +- Voice-ingest connections receive a `registry_snapshot` on attach and a + `registry_update` on subsequent NPC registration deltas. +- `MasterIngestService` discards STT payloads whose Discord user id matches + a registered NPC bot. +- The new audit tables (`npc_speak_requests` / `npc_speak_results` / + `npc_playback_events`) round-trip through the repo helpers. + +No real WebSocket server is started — `HandlerRegistry.dispatch` is invoked +directly with synthetic JSON envelopes. +""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable + +import pytest + +from wolfbot.domain.discussion import SpeechSource, make_phase_id +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Game, Seat +from wolfbot.domain.ws_messages import ( + Heartbeat, + NpcRegister, + NpcRegistered, + RegistrySnapshot, + RegistryUpdate, + SpeakResult, + SpeechEventPayload, +) +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, +) +from wolfbot.services.master_ingest_service import ( + MasterIngestService, + PhaseLookup, +) +from wolfbot.services.master_ws_server import ( + ConnectionContext, + HandlerRegistry, + MasterHandlers, +) +from wolfbot.services.npc_registry import InMemoryNpcRegistry + + +def _make_send_capture() -> tuple[list[str], Callable[[str], Awaitable[None]]]: + """Returns (captured, send) — `send(msg)` appends to `captured`.""" + captured: list[str] = [] + + async def send(msg: str) -> None: + captured.append(msg) + + return captured, send + + +def _make_npc_ctx(*, tag: str = "npc-temp") -> tuple[ConnectionContext, list[str]]: + captured, send = _make_send_capture() + return ConnectionContext(role="npc", tag=tag, send=send), captured + + +def _make_voice_ingest_ctx() -> tuple[ConnectionContext, list[str]]: + captured, send = _make_send_capture() + return ( + ConnectionContext(role="voice-ingest", tag="voice-ingest", send=send), + captured, + ) + + +# ---------------------------------------------------------------- registration + + +async def test_npc_register_responds_with_npc_registered_and_inserts_registry() -> None: + registry = InMemoryNpcRegistry() + handlers = MasterHandlers(registry=registry, now_ms=lambda: 1000) + dispatcher = HandlerRegistry() + handlers.install(dispatcher) + ctx, captured = _make_npc_ctx() + msg = NpcRegister( + ts=999, + trace_id="t-1", + npc_id="npc_p5", + discord_bot_user_id="bot-uid-5", + supported_voices=("ja-JP-A",), + version="1.0.0", + ) + await dispatcher.dispatch(msg.model_dump_json(), ctx) + + assert ctx.tag == "npc_p5" + entry = registry.get("npc_p5") + assert entry is not None + assert entry.discord_bot_user_id == "bot-uid-5" + assert entry.last_heartbeat_ms == 1000 + assert entry.is_online is True + assert len(captured) == 1 + reply = NpcRegistered.model_validate_json(captured[0]) + assert reply.npc_id == "npc_p5" + assert reply.assigned_seat is None # not assigned yet + + +async def test_heartbeat_refreshes_last_heartbeat_and_revives_offline_npc() -> None: + registry = InMemoryNpcRegistry() + times = iter([1000, 2000, 9000]) + handlers = MasterHandlers(registry=registry, now_ms=lambda: next(times)) + dispatcher = HandlerRegistry() + handlers.install(dispatcher) + ctx, _ = _make_npc_ctx() + + await dispatcher.dispatch( + NpcRegister( + ts=900, trace_id="t", npc_id="npc_p5", discord_bot_user_id="b5" + ).model_dump_json(), + ctx, + ) + # Force offline. + registry.prune_offline(now_ms=10_000, timeout_ms=1) + entry = registry.get("npc_p5") + assert entry is not None and entry.is_online is False + + # Heartbeat with a fresh ts re-flips online. + await dispatcher.dispatch( + Heartbeat(ts=8000, trace_id="t", npc_id="npc_p5").model_dump_json(), + ctx, + ) + entry = registry.get("npc_p5") + assert entry is not None and entry.is_online is True + assert entry.last_heartbeat_ms == 9000 + + +async def test_unknown_message_type_is_logged_not_raised() -> None: + registry = InMemoryNpcRegistry() + handlers = MasterHandlers(registry=registry, now_ms=lambda: 1) + dispatcher = HandlerRegistry() + handlers.install(dispatcher) + ctx, _ = _make_npc_ctx() + # Should not raise. + await dispatcher.dispatch( + json.dumps({"type": "definitely_not_a_real_type", "ts": 1, "trace_id": "x"}), + ctx, + ) + + +async def test_invalid_json_is_logged_not_raised() -> None: + registry = InMemoryNpcRegistry() + handlers = MasterHandlers(registry=registry, now_ms=lambda: 1) + dispatcher = HandlerRegistry() + handlers.install(dispatcher) + ctx, _ = _make_npc_ctx() + await dispatcher.dispatch("{not-json", ctx) + + +async def test_speak_result_dispatches_to_handler() -> None: + registry = InMemoryNpcRegistry() + received: list[SpeakResult] = [] + + async def on_result(msg: SpeakResult, _ctx: ConnectionContext) -> None: + received.append(msg) + + handlers = MasterHandlers(registry=registry, on_speak_result=on_result, now_ms=lambda: 1) + dispatcher = HandlerRegistry() + handlers.install(dispatcher) + ctx, _ = _make_npc_ctx(tag="npc_p1") + msg = SpeakResult( + ts=1, + trace_id="t", + request_id="r1", + npc_id="npc_p1", + phase_id="p", + status="accepted", + text="やあ", + ) + await dispatcher.dispatch(msg.model_dump_json(), ctx) + assert len(received) == 1 + assert received[0].request_id == "r1" + + +# ---------------------------------------------------------------- registry listener + + +async def test_registry_listener_emits_added_when_new_npc_registers() -> None: + registry = InMemoryNpcRegistry() + deltas: list[tuple[set[str], set[str]]] = [] + + async def listener(added: set[str], removed: set[str]) -> None: + deltas.append((added, removed)) + + registry.add_listener(listener) + handlers = MasterHandlers(registry=registry, now_ms=lambda: 1) + dispatcher = HandlerRegistry() + handlers.install(dispatcher) + ctx, _ = _make_npc_ctx() + await dispatcher.dispatch( + NpcRegister( + ts=1, trace_id="t", npc_id="npc_a", discord_bot_user_id="botA" + ).model_dump_json(), + ctx, + ) + # Allow the scheduled listener task to run. + import asyncio + + await asyncio.sleep(0) + assert deltas == [({"botA"}, set())] + + +async def test_registry_unregister_emits_removed() -> None: + registry = InMemoryNpcRegistry() + deltas: list[tuple[set[str], set[str]]] = [] + + async def listener(added: set[str], removed: set[str]) -> None: + deltas.append((added, removed)) + + registry.register( + npc_id="npc_b", + discord_bot_user_id="botB", + supported_voices=(), + version="0.0.1", + send=None, + now_ms=1, + ) + registry.add_listener(listener) + registry.unregister("npc_b", reason="ws_closed") + import asyncio + + await asyncio.sleep(0) + assert deltas == [(set(), {"botB"})] + + +async def test_registry_snapshot_message_serializable_round_trip() -> None: + msg = RegistrySnapshot(ts=1, trace_id="t", npc_user_ids=("a", "b")) + out = RegistrySnapshot.model_validate_json(msg.model_dump_json()) + assert out.npc_user_ids == ("a", "b") + + +async def test_registry_update_message_serializable_round_trip() -> None: + msg = RegistryUpdate(ts=1, trace_id="t", added=("x",), removed=("y",)) + out = RegistryUpdate.model_validate_json(msg.model_dump_json()) + assert out.added == ("x",) and out.removed == ("y",) + + +# ---------------------------------------------------------------- ingest service + + +class _StubPhaseLookup: + def __init__( + self, + mapping: dict[str, tuple[Phase, int]], + alive_seats: dict[str, list[int]] | None = None, + ) -> None: + self._mapping = mapping + self._alive = alive_seats or {} + + async def get_phase(self, game_id: str) -> tuple[Phase, int] | None: + return self._mapping.get(game_id) + + async def get_alive_seat_nos(self, game_id: str) -> list[int]: + return self._alive.get(game_id, []) + + +async def test_master_ingest_discards_npc_speaker(repo: SqliteRepo) -> None: + registry = InMemoryNpcRegistry() + registry.register( + npc_id="npc1", + discord_bot_user_id="bot1", + supported_voices=(), + version="1", + send=None, + now_ms=1, + ) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + lookup = _StubPhaseLookup({"g1": (Phase.DAY_DISCUSSION, 1)}) + svc = MasterIngestService(registry=registry, discussion=discussion, phase_lookup=lookup) + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id="g1", + phase_id="p", + seat_no=4, + speaker_discord_user_id="bot1", + segment_id="s1", + text="勝手にtts", + confidence=0.9, + duration_ms=500, + audio_start_ms=0, + audio_end_ms=500, + ) + event, reason = await svc.ingest_voice(payload) + assert event is None + assert reason == "npc_stt_discarded" + rows = await store.load_for_game("g1") + assert rows == [] + + +async def test_master_ingest_accepts_human_speaker(repo: SqliteRepo) -> None: + # Seed a real game so the foreign key on speech_events.game_id holds. + g = Game( + id="g1", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + await repo.insert_seat( + "g1", + Seat(seat_no=4, display_name="Hu", discord_user_id="u4", is_llm=False, persona_key=None), + ) + await repo.set_player_role("g1", 4, Role.VILLAGER) + registry = InMemoryNpcRegistry() + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + lookup = _StubPhaseLookup( + {"g1": (Phase.DAY_DISCUSSION, 1)}, + alive_seats={"g1": [4]}, + ) + svc = MasterIngestService(registry=registry, discussion=discussion, phase_lookup=lookup) + canonical_phase_id = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id="g1", + phase_id=canonical_phase_id, + seat_no=4, + speaker_discord_user_id="u4", + segment_id="s1", + text="人間のはずだよ", + confidence=0.9, + duration_ms=500, + audio_start_ms=0, + audio_end_ms=500, + ) + event, reason = await svc.ingest_voice(payload) + assert reason is None + assert event is not None + assert event.source == SpeechSource.VOICE_STT + # The event must use the canonical phase_id computed on Master. + assert event.phase_id == canonical_phase_id + rows = await store.load_phase("g1", canonical_phase_id) + assert any(r.text == "人間のはずだよ" for r in rows) + # A phase_baseline sentinel must have been seeded. + baselines = [r for r in rows if r.source == SpeechSource.PHASE_BASELINE] + assert len(baselines) == 1 + + +async def test_master_ingest_ignores_caller_phase_id(repo: SqliteRepo) -> None: + """MasterIngestService must compute the canonical phase_id on Master, + not trust the caller-supplied value from the voice-ingest worker.""" + g = Game( + id="gX", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=2, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + await repo.insert_seat( + "gX", + Seat(seat_no=3, display_name="Hu", discord_user_id="u3", is_llm=False, persona_key=None), + ) + await repo.set_player_role("gX", 3, Role.VILLAGER) + registry = InMemoryNpcRegistry() + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + lookup = _StubPhaseLookup( + {"gX": (Phase.DAY_DISCUSSION, 2)}, + alive_seats={"gX": [3]}, + ) + svc = MasterIngestService(registry=registry, discussion=discussion, phase_lookup=lookup) + + # Caller supplies a stale phase_id from day 1 — Master must override. + stale_phase_id = make_phase_id("gX", 1, Phase.DAY_DISCUSSION) + canonical_phase_id = make_phase_id("gX", 2, Phase.DAY_DISCUSSION) + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id="gX", + phase_id=stale_phase_id, + seat_no=3, + speaker_discord_user_id="u3", + segment_id="s2", + text="テスト", + confidence=0.9, + duration_ms=300, + audio_start_ms=0, + audio_end_ms=300, + ) + event, reason = await svc.ingest_voice(payload) + assert reason is None + assert event is not None + assert event.phase_id == canonical_phase_id + assert event.phase_id != stale_phase_id + + +# ---------------------------------------------------------------- audit tables + + +async def test_audit_tables_round_trip(repo: SqliteRepo) -> None: + g = Game( + id="ga", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + + await repo.insert_npc_speak_request( + request_id="r1", + game_id="ga", + phase_id="ph", + npc_id="np", + seat_no=2, + logic_packet_id="lp", + suggested_intent="counter", + max_chars=80, + max_duration_ms=8000, + priority=1, + expires_at_ms=10_000, + created_at_ms=1000, + ) + open_reqs = await repo.load_open_npc_speak_requests("ga") + assert len(open_reqs) == 1 and open_reqs[0]["request_id"] == "r1" + + await repo.insert_npc_speak_result( + request_id="r1", + game_id="ga", + phase_id="ph", + npc_id="np", + status="accepted", + text="それは違うよ", + used_logic_ids=["c1", "c2"], + intent="counter", + estimated_duration_ms=2500, + failure_reason=None, + received_at_ms=1100, + ) + open_reqs = await repo.load_open_npc_speak_requests("ga") + assert open_reqs == [] # closed by result + + await repo.open_npc_playback( + request_id="r1", + game_id="ga", + phase_id="ph", + npc_id="np", + speech_event_id="ev1", + authorized_at_ms=1200, + playback_deadline_ms=11_000, + ) + open_play = await repo.load_open_npc_playback("ga") + assert len(open_play) == 1 + + await repo.update_npc_playback_tts( + "r1", outcome="success", duration_ms=500, failure_reason=None + ) + await repo.close_npc_playback( + "r1", finished_at_ms=2000, outcome="succeeded", failure_reason=None + ) + open_play = await repo.load_open_npc_playback("ga") + assert open_play == [] + + +async def test_close_npc_playback_only_affects_open_rows(repo: SqliteRepo) -> None: + """A second close_npc_playback after the row is already closed must not + overwrite the original outcome.""" + g = Game( + id="gb", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + await repo.open_npc_playback( + request_id="r2", + game_id="gb", + phase_id="ph", + npc_id="np", + speech_event_id="ev1", + authorized_at_ms=1, + playback_deadline_ms=2, + ) + await repo.close_npc_playback("r2", finished_at_ms=3, outcome="succeeded", failure_reason=None) + # Second close is a no-op. + await repo.close_npc_playback("r2", finished_at_ms=99, outcome="failed", failure_reason="oops") + open_play = await repo.load_open_npc_playback("gb") + assert open_play == [] + + +# ---------------------------------------------------------------- protocol smoke + + +def test_phase_lookup_protocol_runtime_check() -> None: + """A class with `get_phase` and `get_alive_seat_nos` satisfies PhaseLookup.""" + + class Impl: + async def get_phase(self, game_id: str) -> tuple[Phase, int] | None: + return None + + async def get_alive_seat_nos(self, game_id: str) -> list[int]: + return [] + + obj = Impl() + # Use the runtime-checkable protocol; this asserts the structural match. + assert isinstance(obj, PhaseLookup) + + +@pytest.mark.parametrize("psk_match", [True, False]) +def test_websockets_master_ws_server_constructs_without_starting( + psk_match: bool, +) -> None: + """Constructor wiring smoke — no actual socket bind.""" + from wolfbot.services.master_ws_server import WebsocketsMasterWsServer + + registry = InMemoryNpcRegistry() + handlers = MasterHandlers(registry=registry, now_ms=lambda: 0) + server = WebsocketsMasterWsServer( + host="127.0.0.1", + port=8888, + psk="secret" if psk_match else "other", + registry=registry, + handlers=handlers, + ) + assert server.psk == ("secret" if psk_match else "other") + assert server.host == "127.0.0.1" diff --git a/tests/test_npc_voice_worker.py b/tests/test_npc_voice_worker.py new file mode 100644 index 0000000..d6c482a --- /dev/null +++ b/tests/test_npc_voice_worker.py @@ -0,0 +1,409 @@ +"""Bundle 7: NPC voice worker — integration coverage. + +Drives the `NpcClient` directly with synthetic JSON envelopes. Substitutes +`FakeNpcGenerator`, `FakeTtsService`, and `FakeVoicePlayback`. Verifies: + +- Successful end-to-end: SpeakRequest → SpeakResult → PlaybackAuthorized → + TTS synthesis → playback → tts_finished + playback_finished. +- Decline path: empty generator output → SpeakResult(declined). +- Pre-authorization invariant: no audio is played before + PlaybackAuthorized. +- Unauthorized: PlaybackRejected drops the queued utterance silently. +- TTS error: tts_failed message; no playback. +- Playback error: playback_failed message after tts_finished. +- TtsCache: identical text re-uses synthesized audio. +- Logic packet routing: SpeakRequest with unknown packet still produces a + SpeakResult (best-effort). +""" + +from __future__ import annotations + +import json + +from wolfbot.domain.ws_messages import ( + LogicPacket, + NpcRegistered, + PlaybackAuthorized, + PlaybackFailed, + PlaybackFinished, + PlaybackRejected, + SpeakRequest, + SpeakResult, + TtsFinished, +) +from wolfbot.services.npc_client import NpcClient, NpcClientConfig +from wolfbot.services.npc_speech_service import ( + FakeNpcGenerator, + NpcGeneratedSpeech, + NpcSpeechService, +) +from wolfbot.services.tts_service import ( + FakeTtsService, + InMemoryTtsCache, + TtsProviderError, + TtsResult, +) +from wolfbot.services.voice_playback_service import ( + FakeVoicePlayback, + VoicePlaybackError, +) + + +def _make_client( + *, + generator: FakeNpcGenerator, + tts: FakeTtsService, + playback: FakeVoicePlayback, + captured: list[str] | None = None, + cache: InMemoryTtsCache | None = None, +) -> tuple[NpcClient, list[str]]: + out = captured if captured is not None else [] + + async def send(msg: str) -> None: + out.append(msg) + + speech = NpcSpeechService(generator) + client = NpcClient( + config=NpcClientConfig( + npc_id="npc_p2", + discord_bot_user_id="bot2", + voice_id="ja-Standard-A", + ), + speech=speech, + tts=tts, + playback=playback, + send=send, + now_ms=lambda: 1000, + cache=cache or InMemoryTtsCache(max_entries=4), + ) + return client, out + + +def _make_logic(packet_id: str = "lp1", phase_id: str = "ph") -> LogicPacket: + return LogicPacket( + ts=1, + trace_id="t", + packet_id=packet_id, + phase_id=phase_id, + recipient_npc_id="npc_p2", + public_state_summary="silent_seats=[]", + logic_candidates=(), + pressure={}, + expires_at_ms=2000, + ) + + +def _make_request( + *, request_id: str = "sr1", logic_packet_id: str = "lp1", phase_id: str = "ph" +) -> SpeakRequest: + return SpeakRequest( + ts=1, + trace_id="t", + request_id=request_id, + phase_id=phase_id, + npc_id="npc_p2", + seat_no=2, + logic_packet_id=logic_packet_id, + suggested_intent="speak", + max_chars=80, + max_duration_ms=8000, + priority=0, + expires_at_ms=2000, + ) + + +async def test_end_to_end_authorized_playback_finishes() -> None: + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="まあそれは違うよ", + intent="counter", + used_logic_ids=("c1",), + estimated_duration_ms=2000, + ) + ] + ) + tts = FakeTtsService(scripted=[TtsResult(audio=b"audio", duration_ms=900)]) + playback = FakeVoicePlayback(started_at_ms=2000, finished_at_ms=2900) + client, captured = _make_client(generator=gen, tts=tts, playback=playback) + + # Logic packet → speak_request → speak_result. + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + result = SpeakResult.model_validate_json(captured[-1]) + assert result.status == "accepted" and result.text == "まあそれは違うよ" + + # No playback yet. + assert playback.plays == [] + + # Authorize. + auth = PlaybackAuthorized( + ts=2, + trace_id="t", + request_id="sr1", + npc_id="npc_p2", + speech_event_id="ev1", + playback_deadline_ms=10_000, + ) + await client.process_message(auth.model_dump_json()) + # tts_finished + playback_finished sent in order. + assert any(json.loads(m).get("type") == "tts_finished" for m in captured) + assert any(json.loads(m).get("type") == "playback_finished" for m in captured) + finished = next( + PlaybackFinished.model_validate_json(m) + for m in captured + if json.loads(m).get("type") == "playback_finished" + ) + assert finished.started_at_ms == 2000 + assert finished.finished_at_ms == 2900 + assert playback.plays == [(b"audio", 48_000)] + + +async def test_decline_when_generator_returns_none() -> None: + gen = FakeNpcGenerator(scripted=[None]) + tts = FakeTtsService() + playback = FakeVoicePlayback() + client, captured = _make_client(generator=gen, tts=tts, playback=playback) + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + result = SpeakResult.model_validate_json(captured[-1]) + assert result.status == "declined" + + +async def test_no_playback_before_authorization() -> None: + """Without a PlaybackAuthorized, no playback / TTS event is emitted — + matching the discord-integration spec.""" + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="こんにちは", intent="greeting", used_logic_ids=(), estimated_duration_ms=500 + ) + ] + ) + tts = FakeTtsService(scripted=[TtsResult(audio=b"abc", duration_ms=500)]) + playback = FakeVoicePlayback() + client, captured = _make_client(generator=gen, tts=tts, playback=playback) + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + # No tts_finished / playback_finished yet. + assert playback.plays == [] + types = [json.loads(m).get("type") for m in captured] + assert "tts_finished" not in types + assert "playback_finished" not in types + + +async def test_playback_rejected_drops_pending_silently() -> None: + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="占いCO", intent="co", used_logic_ids=(), estimated_duration_ms=800 + ) + ] + ) + tts = FakeTtsService(scripted=[TtsResult(audio=b"abc", duration_ms=500)]) + playback = FakeVoicePlayback() + client, _ = _make_client(generator=gen, tts=tts, playback=playback) + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + rej = PlaybackRejected( + ts=2, + trace_id="t", + request_id="sr1", + npc_id="npc_p2", + failure_reason="utterance_too_long", + ) + await client.process_message(rej.model_dump_json()) + # Subsequent authorization MUST NOT replay (because the result was rejected). + auth = PlaybackAuthorized( + ts=3, + trace_id="t", + request_id="sr1", + npc_id="npc_p2", + speech_event_id="ev1", + playback_deadline_ms=10_000, + ) + await client.process_message(auth.model_dump_json()) + assert playback.plays == [] + + +async def test_tts_failure_emits_tts_failed_and_no_playback() -> None: + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="やあ", intent="greeting", used_logic_ids=(), estimated_duration_ms=500 + ) + ] + ) + tts = FakeTtsService(scripted=[TtsProviderError("tts_provider_error")]) + playback = FakeVoicePlayback() + client, captured = _make_client(generator=gen, tts=tts, playback=playback) + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + auth = PlaybackAuthorized( + ts=2, + trace_id="t", + request_id="sr1", + npc_id="npc_p2", + speech_event_id="ev1", + playback_deadline_ms=10_000, + ) + await client.process_message(auth.model_dump_json()) + types = [json.loads(m).get("type") for m in captured] + assert "tts_failed" in types + assert "tts_finished" not in types + assert playback.plays == [] + + +async def test_playback_error_emits_playback_failed() -> None: + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="やあ", intent="greeting", used_logic_ids=(), estimated_duration_ms=500 + ) + ] + ) + tts = FakeTtsService(scripted=[TtsResult(audio=b"abc", duration_ms=500)]) + playback = FakeVoicePlayback( + raise_for_audio=VoicePlaybackError("discord_playback_error"), + ) + client, captured = _make_client(generator=gen, tts=tts, playback=playback) + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + auth = PlaybackAuthorized( + ts=2, + trace_id="t", + request_id="sr1", + npc_id="npc_p2", + speech_event_id="ev1", + playback_deadline_ms=10_000, + ) + await client.process_message(auth.model_dump_json()) + failed = next( + PlaybackFailed.model_validate_json(m) + for m in captured + if json.loads(m).get("type") == "playback_failed" + ) + assert failed.failure_reason == "discord_playback_error" + + +async def test_tts_cache_avoids_resynth() -> None: + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="同じ", intent="speak", used_logic_ids=(), estimated_duration_ms=400 + ), + NpcGeneratedSpeech( + text="同じ", intent="speak", used_logic_ids=(), estimated_duration_ms=400 + ), + ] + ) + tts = FakeTtsService(default=TtsResult(audio=b"a", duration_ms=400)) + playback = FakeVoicePlayback(started_at_ms=1, finished_at_ms=2) + cache = InMemoryTtsCache(max_entries=4) + client, _captured = _make_client(generator=gen, tts=tts, playback=playback, cache=cache) + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request(request_id="r1").model_dump_json()) + await client.process_message( + PlaybackAuthorized( + ts=1, + trace_id="t", + request_id="r1", + npc_id="npc_p2", + speech_event_id="ev1", + playback_deadline_ms=10_000, + ).model_dump_json() + ) + await client.process_message(_make_request(request_id="r2").model_dump_json()) + await client.process_message( + PlaybackAuthorized( + ts=1, + trace_id="t", + request_id="r2", + npc_id="npc_p2", + speech_event_id="ev2", + playback_deadline_ms=10_000, + ).model_dump_json() + ) + # First synth uncached; second synth must hit the cache. + assert cache.hits == 1 + assert cache.misses == 1 + + +async def test_npc_registered_marks_client_registered() -> None: + gen = FakeNpcGenerator() + tts = FakeTtsService() + playback = FakeVoicePlayback() + client, _ = _make_client(generator=gen, tts=tts, playback=playback) + assert client.registered is False + msg = NpcRegistered( + ts=1, trace_id="t", npc_id="npc_p2", assigned_seat=2, game_id="g", phase_id="ph" + ) + await client.process_message(msg.model_dump_json()) + assert client.registered is True + + +async def test_unknown_logic_packet_uses_empty_logic_in_speak_result() -> None: + """If a SpeakRequest references an unseen `logic_packet_id`, the NPC + still produces a SpeakResult — degraded, but not silent.""" + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="ええと", intent="speak", used_logic_ids=(), estimated_duration_ms=300 + ) + ] + ) + tts = FakeTtsService() + playback = FakeVoicePlayback() + client, captured = _make_client(generator=gen, tts=tts, playback=playback) + await client.process_message(_make_request(logic_packet_id="never-seen").model_dump_json()) + result = SpeakResult.model_validate_json(captured[-1]) + assert result.status == "accepted" + + +async def test_invalid_inbound_json_is_logged_not_raised() -> None: + gen = FakeNpcGenerator() + tts = FakeTtsService() + playback = FakeVoicePlayback() + client, _ = _make_client(generator=gen, tts=tts, playback=playback) + await client.process_message("{not-json") + await client.process_message(json.dumps({"no_type_here": True})) + # No exception — handler must be tolerant. + + +def test_npc_bot_main_module_loads() -> None: + """Smoke-load the entrypoint module to catch import-time regressions.""" + import importlib + + mod = importlib.import_module("wolfbot.npc_bot_main") + assert hasattr(mod, "main") + + +async def test_tts_finished_includes_correct_duration() -> None: + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="ね", intent="speak", used_logic_ids=(), estimated_duration_ms=10 + ) + ] + ) + tts = FakeTtsService(scripted=[TtsResult(audio=b"a", duration_ms=750)]) + playback = FakeVoicePlayback(started_at_ms=1, finished_at_ms=2) + client, captured = _make_client(generator=gen, tts=tts, playback=playback) + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + await client.process_message( + PlaybackAuthorized( + ts=2, + trace_id="t", + request_id="sr1", + npc_id="npc_p2", + speech_event_id="ev1", + playback_deadline_ms=10_000, + ).model_dump_json() + ) + finished = next( + TtsFinished.model_validate_json(m) + for m in captured + if json.loads(m).get("type") == "tts_finished" + ) + assert finished.tts_duration_ms == 750 diff --git a/tests/test_observability.py b/tests/test_observability.py new file mode 100644 index 0000000..d52fd85 --- /dev/null +++ b/tests/test_observability.py @@ -0,0 +1,319 @@ +"""Bundle 9: cross-component observability. + +Verifies: +- `emit_event` produces a structured payload with the canonical envelope. +- `CapturingHandler` records and filters events by name + fields. +- `emit_phase_summary` sums human / NPC speech events from `speech_events` + and reactive_voice telemetry from `npc_speak_*` / `npc_playback_events`. +- The summary mode field reflects the game's discussion_mode (rounds vs + reactive_voice). +""" + +from __future__ import annotations + +import logging + +from wolfbot.domain.discussion import ( + SpeechSource, + make_phase_id, +) +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Game, Seat +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discussion_phase_summary import emit_phase_summary +from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + make_human_text_event, + make_npc_generated_event, + make_phase_baseline, +) +from wolfbot.services.structured_logging import ( + COMPONENT_MASTER, + CapturingHandler, + build_discussion_phase_summary, + emit_event, +) + + +async def _seed_phase_with_speech( + repo: SqliteRepo, *, mode: str = "rounds" +) -> tuple[Game, str, DiscussionService]: + g = Game( + id="ob1", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode=mode, + ) + await repo.create_game(g) + await repo.insert_seat( + g.id, + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + ) + await repo.insert_seat( + g.id, + Seat( + seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + ) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2], + created_at_ms=1, + ) + ) + await store.insert( + make_human_text_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="人間です", + created_at_ms=2, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="占いCO", + created_at_ms=3, + ) + ) + return g, phase_id, discussion + + +def test_emit_event_records_envelope_fields() -> None: + handler = CapturingHandler() + test_logger = logging.getLogger("wolfbot.observability.unit-test") + test_logger.setLevel(logging.DEBUG) + test_logger.addHandler(handler) + try: + emit_event( + test_logger, + component="master", + event="speak_request_suppressed", + game_id="g1", + phase_id="ph", + failure_reason="human_currently_speaking", + ) + events = list(handler.events_with(name="speak_request_suppressed", game_id="g1")) + assert len(events) == 1 + ev = events[0] + assert ev.payload["component"] == "master" + assert ev.payload["failure_reason"] == "human_currently_speaking" + assert "ts" in ev.payload + finally: + test_logger.removeHandler(handler) + + +def test_capturing_handler_filters_by_name_and_fields() -> None: + handler = CapturingHandler() + test_logger = logging.getLogger("wolfbot.observability.filter-test") + test_logger.setLevel(logging.DEBUG) + test_logger.addHandler(handler) + try: + emit_event(test_logger, component="master", event="a", game_id="g1") + emit_event(test_logger, component="master", event="a", game_id="g2") + emit_event(test_logger, component="master", event="b", game_id="g1") + a_g1 = list(handler.events_with(name="a", game_id="g1")) + assert len(a_g1) == 1 + all_a = list(handler.events_with(name="a")) + assert len(all_a) == 2 + finally: + test_logger.removeHandler(handler) + + +def test_build_discussion_phase_summary_includes_required_fields() -> None: + payload = build_discussion_phase_summary( + game_id="g1", + phase_id="ph", + mode="rounds", + speech_events_total=4, + human_speech_events=1, + npc_speech_events=3, + ) + for key in ( + "game_id", + "phase_id", + "mode", + "speech_events_total", + "human_speech_events", + "npc_speech_events", + "stt_success", + "stt_failed", + "logic_packets_built", + "speak_requests_sent", + "speak_results_accepted", + "speak_results_rejected", + "playback_authorized", + "tts_success", + "tts_failed", + "playback_success", + "playback_failed", + "stale_dropped", + ): + assert key in payload + + +async def test_emit_phase_summary_counts_speech_events_in_rounds_mode( + repo: SqliteRepo, +) -> None: + handler = CapturingHandler() + wolfbot_logger = logging.getLogger("wolfbot") + prior_level = wolfbot_logger.level + wolfbot_logger.setLevel(logging.DEBUG) + wolfbot_logger.addHandler(handler) + try: + g, phase_id, discussion = await _seed_phase_with_speech(repo, mode="rounds") + counts = await emit_phase_summary( + repo=repo, + discussion=discussion, + game_id=g.id, + phase_id=phase_id, + mode="rounds", + ) + assert counts["speech_events_total"] == 2 + assert counts["human_speech_events"] == 1 + assert counts["npc_speech_events"] == 1 + # The reactive_voice telemetry is zero in rounds mode (no audit rows). + assert counts["speak_requests_sent"] == 0 + assert counts["playback_authorized"] == 0 + events = list(handler.events_with(name="discussion_phase_summary", game_id=g.id)) + assert len(events) == 1 + assert events[0].payload["mode"] == "rounds" + assert events[0].payload["component"] == COMPONENT_MASTER + finally: + wolfbot_logger.removeHandler(handler) + wolfbot_logger.setLevel(prior_level) + + +async def test_emit_phase_summary_includes_audit_rows_in_reactive_voice_mode( + repo: SqliteRepo, +) -> None: + handler = CapturingHandler() + wolfbot_logger = logging.getLogger("wolfbot") + prior_level = wolfbot_logger.level + wolfbot_logger.setLevel(logging.DEBUG) + wolfbot_logger.addHandler(handler) + try: + g, phase_id, discussion = await _seed_phase_with_speech(repo, mode="reactive_voice") + # Seed two requests: one accepted+played, one rejected. + await repo.insert_npc_speak_request( + request_id="r1", + game_id=g.id, + phase_id=phase_id, + npc_id="n2", + seat_no=2, + logic_packet_id="lp1", + suggested_intent="speak", + max_chars=80, + max_duration_ms=8000, + priority=0, + expires_at_ms=10_000, + created_at_ms=1, + ) + await repo.insert_npc_speak_result( + request_id="r1", + game_id=g.id, + phase_id=phase_id, + npc_id="n2", + status="accepted", + text="占いCO", + used_logic_ids=["c1"], + intent="co", + estimated_duration_ms=1500, + failure_reason=None, + received_at_ms=2, + ) + await repo.open_npc_playback( + request_id="r1", + game_id=g.id, + phase_id=phase_id, + npc_id="n2", + speech_event_id="ev1", + authorized_at_ms=2, + playback_deadline_ms=10_000, + ) + await repo.update_npc_playback_tts( + "r1", outcome="success", duration_ms=400, failure_reason=None + ) + await repo.close_npc_playback( + "r1", finished_at_ms=3, outcome="succeeded", failure_reason=None + ) + await repo.insert_npc_speak_request( + request_id="r2", + game_id=g.id, + phase_id=phase_id, + npc_id="n2", + seat_no=2, + logic_packet_id="lp2", + suggested_intent="speak", + max_chars=80, + max_duration_ms=8000, + priority=0, + expires_at_ms=10_000, + created_at_ms=4, + ) + await repo.insert_npc_speak_result( + request_id="r2", + game_id=g.id, + phase_id=phase_id, + npc_id="n2", + status="rejected", + text=None, + used_logic_ids=None, + intent=None, + estimated_duration_ms=None, + failure_reason="stale_phase", + received_at_ms=5, + ) + + counts = await emit_phase_summary( + repo=repo, + discussion=discussion, + game_id=g.id, + phase_id=phase_id, + mode="reactive_voice", + ) + assert counts["speak_requests_sent"] == 2 + assert counts["speak_results_accepted"] == 1 + assert counts["speak_results_rejected"] == 1 + assert counts["tts_success"] == 1 + assert counts["playback_success"] == 1 + assert counts["stale_dropped"] == 1 + events = list( + handler.events_with( + name="discussion_phase_summary", game_id=g.id, mode="reactive_voice" + ) + ) + assert len(events) == 1 + finally: + wolfbot_logger.removeHandler(handler) + wolfbot_logger.setLevel(prior_level) + + +def test_speech_source_filter_excludes_phase_baseline() -> None: + """The summary's `speech_events_total` must NOT count phase_baseline.""" + # Sanity check via SpeechSource. + assert SpeechSource.PHASE_BASELINE != SpeechSource.TEXT + assert SpeechSource.PHASE_BASELINE != SpeechSource.VOICE_STT + assert SpeechSource.PHASE_BASELINE != SpeechSource.NPC_GENERATED diff --git a/tests/test_public_discussion_state.py b/tests/test_public_discussion_state.py new file mode 100644 index 0000000..d6c69da --- /dev/null +++ b/tests/test_public_discussion_state.py @@ -0,0 +1,170 @@ +"""Bundle 2 tests — PublicDiscussionState fold semantics. + +These tests treat `apply_speech_event` and `rebuild_public_state_from_events` +as a pure pair: a `reduce` over the event sequence must produce the same +state as a single `rebuild` call. They also verify the deterministic rules +called out in the spec delta: + + * `alive_seat_nos` comes from the sentinel — dead seats are excluded by + construction (they were never in the baseline). + * `silent_seats` = `alive_seat_nos` minus seats with ≥1 non-sentinel event. + * `co_claims` are recorded in arrival order, deduped per (seat, role). + * `recent_speech_event_ids` keeps at most 10 ids in arrival order. +""" + +from __future__ import annotations + +from functools import reduce + +from wolfbot.domain.discussion import SpeechSource +from wolfbot.domain.enums import Phase +from wolfbot.services.discussion_service import ( + apply_speech_event, + make_human_text_event, + make_npc_generated_event, + make_phase_baseline, + make_phase_id, + rebuild_public_state_from_events, +) + + +def _seed(alive: list[int], events_payload: list[tuple[int, str]], game_id: str = "g1") -> list: + phase_id = make_phase_id(game_id, 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id=game_id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=alive, + created_at_ms=0, + ) + seq = [sentinel] + for i, (seat, text) in enumerate(events_payload, start=1): + seq.append( + make_human_text_event( + game_id=game_id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat, + text=text, + created_at_ms=i, + ) + ) + return seq + + +def test_apply_then_rebuild_agree() -> None: + events = _seed( + alive=[1, 2, 3, 4, 5, 6, 7, 8, 9], + events_payload=[ + (1, "占いCOします"), + (3, "情報整理しましょう"), + (3, "P1のCOは普通"), + (7, "霊媒COです"), + ], + ) + + folded = reduce(apply_speech_event, events, None) + rebuilt = rebuild_public_state_from_events(events) + + assert folded is not None and rebuilt is not None + assert folded.alive_seat_nos == rebuilt.alive_seat_nos + assert folded.co_claims == rebuilt.co_claims + assert folded.silent_seats == rebuilt.silent_seats + assert folded.recent_speech_event_ids == rebuilt.recent_speech_event_ids + + +def test_silent_seats_excludes_dead_seats() -> None: + # alive baseline omits seat 5 (dead); so even if "no event for seat 5", it is NOT silent + events = _seed( + alive=[1, 2, 3, 4, 6, 7, 8, 9], + events_payload=[(1, "おはよう"), (2, "はい"), (4, "黙ってる人気になる")], + ) + state = rebuild_public_state_from_events(events) + assert state is not None + assert 5 not in state.alive_seat_nos + assert 5 not in state.silent_seats + assert state.silent_seats == frozenset({3, 6, 7, 8, 9}) + + +def test_co_claims_dedup_and_preserve_order() -> None: + events = _seed( + alive=list(range(1, 10)), + events_payload=[ + (4, "占いCO 結果は1白"), + (4, "占いCO(再)"), + (7, "霊媒CO"), + (7, "霊媒CO 続報"), + (1, "占いCO 対抗"), + ], + ) + state = rebuild_public_state_from_events(events) + assert state is not None + seq = [(c.seat, c.role_claim) for c in state.co_claims] + assert seq == [(4, "seer"), (7, "medium"), (1, "seer")] + + +def test_recent_speech_event_ids_caps_at_10() -> None: + events = _seed( + alive=list(range(1, 10)), + events_payload=[(((i % 9) + 1), f"発言{i}") for i in range(1, 16)], + ) + state = rebuild_public_state_from_events(events) + assert state is not None + assert len(state.recent_speech_event_ids) == 10 + # the cap keeps the LAST 10 by arrival order + expected_last_10 = [e.event_id for e in events if e.source != SpeechSource.PHASE_BASELINE][-10:] + assert list(state.recent_speech_event_ids) == expected_last_10 + + +def test_apply_with_only_npc_events_still_silences_alive_baseline() -> None: + # Even without any human events, an alive seat with zero non-sentinel events stays silent. + pid = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id="g1", + phase_id=pid, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=0, + ) + npc = make_npc_generated_event( + game_id="g1", + phase_id=pid, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="hi", + created_at_ms=1, + ) + + state = rebuild_public_state_from_events([sentinel, npc]) + assert state is not None + assert state.alive_seat_nos == frozenset({1, 2, 3}) + assert state.silent_seats == frozenset({2, 3}) + + +def test_apply_speech_event_returns_none_without_prior_baseline() -> None: + # apply_speech_event(None, non_sentinel) must return None — caller hasn't seeded a state yet + pid = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + e = make_human_text_event( + game_id="g1", + phase_id=pid, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="hi", + created_at_ms=1, + ) + assert apply_speech_event(None, e) is None + + +def test_rebuild_is_independent_of_caller_supplied_seed() -> None: + # The rebuild path is documented as needing only `events`; calling it twice + # on the same input should yield identical states (no shared mutable seed). + events = _seed(alive=[1, 2, 3], events_payload=[(1, "占いCO"), (2, "霊媒CO")]) + s1 = rebuild_public_state_from_events(events) + s2 = rebuild_public_state_from_events(events) + assert s1 is not None and s2 is not None + assert s1 == s2 diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py new file mode 100644 index 0000000..2c06917 --- /dev/null +++ b/tests/test_reactive_voice_master.py @@ -0,0 +1,477 @@ +"""Bundle 5: master arbitration + recovery — protocol-level coverage. + +Verifies the SpeakArbiter logic without standing up a real Master. Each +test exercises one branch of the arbitration flow: + +- Successful dispatch + accepted SpeakResult → SpeechEvent + PlaybackAuthorized. +- Stale phase / expired request / over-length text → rejection paths. +- Serial-speech gate blocks while a playback is open and while a human is + speaking. +- Offline NPC is skipped without sending a SpeakRequest. +- Recovery sweep closes in-flight rows with master_restart. +- Master restart rebuilds PublicDiscussionState from speech_events. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from wolfbot.domain.discussion import ( + PublicDiscussionState, + SpeechSource, + make_phase_id, +) +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Game, Seat +from wolfbot.domain.ws_messages import ( + PlaybackAuthorized, + PlaybackFinished, + PlaybackRejected, + SpeakResult, +) +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + make_phase_baseline, +) +from wolfbot.services.master_logic_service import build_logic_packet +from wolfbot.services.npc_registry import InMemoryNpcRegistry +from wolfbot.services.speak_arbiter import SpeakArbiter, SpeakArbiterConfig + + +def _captured_send(buf: list[str]) -> Callable[[str], Awaitable[None]]: + async def send(msg: str) -> None: + buf.append(msg) + + return send + + +async def _seed_game(repo: SqliteRepo) -> tuple[Game, list[Seat]]: + g = Game( + id="rv1", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + Seat( + seat_no=2, + display_name="セツ", + discord_user_id=None, + is_llm=True, + persona_key="setsu", + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + return g, seats + + +def _seed_state(game_id: str, day: int = 1) -> PublicDiscussionState: + phase_id = make_phase_id(game_id, day, Phase.DAY_DISCUSSION) + return PublicDiscussionState( + game_id=game_id, + phase_id=phase_id, + day=day, + alive_seat_nos=frozenset({1, 2}), + silent_seats=frozenset({1, 2}), + ) + + +async def test_successful_dispatch_emits_logic_packet_and_speak_request( + repo: SqliteRepo, +) -> None: + game, _seats = await _seed_game(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + ) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + state = _seed_state(game.id) + + request, reason = await arb.dispatch_request( + state=state, + candidate_npc_id="npc_p2", + seat_no=2, + game_id=game.id, + ) + assert reason is None and request is not None + # Two messages on the back-channel: LogicPacket then SpeakRequest. + assert len(npc_buf) == 2 + assert '"type":"logic_packet"' in npc_buf[0] + assert '"type":"speak_request"' in npc_buf[1] + + +async def test_dispatch_records_request_in_audit_table(repo: SqliteRepo) -> None: + game, _ = await _seed_game(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500) + state = _seed_state(game.id) + request, _ = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert request is not None + rows = await repo.load_open_npc_speak_requests(game.id) + assert any(r["request_id"] == request.request_id for r in rows) + + +async def test_offline_candidate_is_skipped(repo: SqliteRepo) -> None: + game, _ = await _seed_game(repo) + registry = InMemoryNpcRegistry() + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1) + state = _seed_state(game.id) + req, reason = await arb.dispatch_request( + state=state, candidate_npc_id="never_registered", seat_no=2, game_id=game.id + ) + assert req is None and reason == "npc_offline" + + +async def test_human_speaking_blocks_dispatch(repo: SqliteRepo) -> None: + game, _ = await _seed_game(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500) + arb.mark_human_speaking("seg-1") + req, reason = await arb.dispatch_request( + state=_seed_state(game.id), candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert req is None and reason == "human_currently_speaking" + arb.clear_human_speaking("seg-1") + req, reason = await arb.dispatch_request( + state=_seed_state(game.id), candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert req is not None and reason is None + + +async def test_speak_result_accepted_emits_authorized_and_writes_speech_event( + repo: SqliteRepo, +) -> None: + game, _ = await _seed_game(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + ) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500) + state = _seed_state(game.id) + req, _ = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert req is not None + npc_buf.clear() + + result = SpeakResult( + ts=1600, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + phase_id=req.phase_id, + status="accepted", + text="うーん怪しいかも", + ) + ok, reason = await arb.handle_speak_result( + result, current_phase_id=req.phase_id, day=1, phase=Phase.DAY_DISCUSSION + ) + assert ok and reason is None + assert any('"type":"playback_authorized"' in m for m in npc_buf) + auth = next( + PlaybackAuthorized.model_validate_json(m) for m in npc_buf if '"playback_authorized"' in m + ) + assert auth.npc_id == "npc_p2" + rows = await store.load_phase(game.id, req.phase_id) + assert any( + r.source == SpeechSource.NPC_GENERATED and r.text == "うーん怪しいかも" for r in rows + ) + + +async def test_speak_result_over_length_rejected(repo: SqliteRepo) -> None: + game, _ = await _seed_game(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + config=SpeakArbiterConfig(max_chars_reactive=10), + now_ms=lambda: 1500, + ) + state = _seed_state(game.id) + req, _ = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert req is not None + npc_buf.clear() + result = SpeakResult( + ts=1600, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + phase_id=req.phase_id, + status="accepted", + text="this exceeds the cap definitely", + ) + ok, reason = await arb.handle_speak_result( + result, current_phase_id=req.phase_id, day=1, phase=Phase.DAY_DISCUSSION + ) + assert not ok and reason == "utterance_too_long" + rejection = next( + PlaybackRejected.model_validate_json(m) for m in npc_buf if '"playback_rejected"' in m + ) + assert rejection.failure_reason == "utterance_too_long" + + +async def test_speak_result_stale_phase_rejected(repo: SqliteRepo) -> None: + game, _ = await _seed_game(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500) + state = _seed_state(game.id) + req, _ = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert req is not None + npc_buf.clear() + result = SpeakResult( + ts=1600, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + phase_id=req.phase_id, # stays the same on the result + status="accepted", + text="ok", + ) + ok, reason = await arb.handle_speak_result( + result, + current_phase_id="some-other-phase", # arbiter sees a different current phase + day=1, + phase=Phase.DAY_DISCUSSION, + ) + assert not ok and reason == "stale_phase" + + +async def test_serial_speech_blocks_after_authorize_until_finished( + repo: SqliteRepo, +) -> None: + game, _ = await _seed_game(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500) + state = _seed_state(game.id) + req, _ = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert req is not None + await arb.handle_speak_result( + SpeakResult( + ts=1600, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + phase_id=req.phase_id, + status="accepted", + text="まあね", + ), + current_phase_id=req.phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + ) + # Now block: next dispatch should fail with queue_busy. + req2, reason = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert req2 is None and reason == "queue_busy" + # Close the playback — the gate should release. + await arb.handle_playback_finished( + PlaybackFinished( + ts=1700, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + started_at_ms=1600, + finished_at_ms=1700, + ) + ) + req3, reason = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert req3 is not None and reason is None + + +async def test_recovery_sweep_marks_in_flight_rows_master_restart( + repo: SqliteRepo, +) -> None: + game, _ = await _seed_game(repo) + registry = InMemoryNpcRegistry() + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 9999) + # Manually seed one open request and one open playback (without going + # through dispatch — Master crashed mid-flight). + await repo.insert_npc_speak_request( + request_id="open-req-1", + game_id=game.id, + phase_id="ph", + npc_id="npc_p2", + seat_no=2, + logic_packet_id="lp", + suggested_intent="x", + max_chars=80, + max_duration_ms=1000, + priority=0, + expires_at_ms=10000, + created_at_ms=8000, + ) + await repo.open_npc_playback( + request_id="open-pb-1", + game_id=game.id, + phase_id="ph", + npc_id="npc_p2", + speech_event_id="ev1", + authorized_at_ms=8000, + playback_deadline_ms=12000, + ) + await arb.reactive_voice_recovery_sweep(game.id) + open_reqs = await repo.load_open_npc_speak_requests(game.id) + open_play = await repo.load_open_npc_playback(game.id) + assert open_reqs == [] + assert open_play == [] + + +async def test_rebuild_public_state_from_master_restart(repo: SqliteRepo) -> None: + game, _ = await _seed_game(repo) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id=game.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2], + created_at_ms=1, + ) + await store.insert(sentinel) + # Simulate a recovered SpeakArbiter on a fresh process. + arb = SpeakArbiter( + repo=repo, + registry=InMemoryNpcRegistry(), + discussion=discussion, + now_ms=lambda: 5, + ) + state = await arb.rebuild_public_state(game_id=game.id, day=1, phase=Phase.DAY_DISCUSSION) + assert state is not None + assert state.alive_seat_nos == frozenset({1, 2}) + + +def test_logic_packet_builder_includes_co_claims_in_summary() -> None: + state = PublicDiscussionState( + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + alive_seat_nos=frozenset({1, 2, 3}), + ) + # CoClaim is normally derived; build one manually for the unit test. + from wolfbot.domain.discussion import CoClaim + + state.co_claims = (CoClaim(seat=2, role_claim="seer", declared_at_event_id="e1"),) + state.silent_seats = frozenset({3}) + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_p3", + expires_at_ms=2000, + now_ms=1500, + ) + assert packet.recipient_npc_id == "npc_p3" + assert packet.expires_at_ms == 2000 + assert any(c.id == "co-2-seer" for c in packet.logic_candidates) + assert "席2=seer" in packet.public_state_summary + assert "silent_seats=[3]" in packet.public_state_summary diff --git a/tests/test_reactive_voice_mode.py b/tests/test_reactive_voice_mode.py new file mode 100644 index 0000000..651b010 --- /dev/null +++ b/tests/test_reactive_voice_mode.py @@ -0,0 +1,1108 @@ +"""Bundle 8: reactive_voice mode plumbing. + +Verifies the mode-fixed-per-game contract: + +- A game created with `discussion_mode="reactive_voice"` keeps that mode + across reload — env changes do not retro-rewrite the column. +- Default mode is `rounds`. +- The schema migration is idempotent on existing DBs. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any +from unittest.mock import MagicMock + +from wolfbot.domain.enums import Phase +from wolfbot.domain.models import Game +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discord_service import WolfCog +from wolfbot.services.game_service import new_game_id + + +async def test_default_mode_is_rounds(repo: SqliteRepo) -> None: + g = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + reloaded = await repo.load_game(g.id) + assert reloaded is not None + assert reloaded.discussion_mode == "rounds" + + +async def test_reactive_voice_mode_persisted_across_reload(repo: SqliteRepo) -> None: + g = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + reloaded = await repo.load_game(g.id) + assert reloaded is not None + assert reloaded.discussion_mode == "reactive_voice" + + +async def test_phase_advance_under_reactive_voice_skips_round_gate() -> None: + """In reactive_voice mode, _plan_next at DAY_DISCUSSION advances when the + deadline passes regardless of llm_speech_counts.""" + from wolfbot.domain.enums import Phase as PhaseEnum + from wolfbot.domain.state_machine import plan_day_discussion_to_vote + + game = Game( + id="rg", + guild_id="gu", + host_user_id="h", + phase=PhaseEnum.DAY_DISCUSSION, + day_number=1, + deadline_epoch=100, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + transition = plan_day_discussion_to_vote(game, 200) + assert transition.next_phase is PhaseEnum.DAY_VOTE + + +async def test_settings_loads_default_discussion_mode(monkeypatch) -> None: # type: ignore[no-untyped-def] + """The Settings object exposes LLM_DISCUSSION_MODE with rounds default.""" + monkeypatch.setenv("DISCORD_TOKEN", "dummy") + monkeypatch.setenv("XAI_API_KEY", "dummy") + monkeypatch.setenv("DISCORD_GUILD_ID", "1") + monkeypatch.setenv("MAIN_TEXT_CHANNEL_ID", "1") + monkeypatch.setenv("MAIN_VOICE_CHANNEL_ID", "1") + monkeypatch.delenv("LLM_DISCUSSION_MODE", raising=False) + from wolfbot.config import Settings + + s = Settings() # type: ignore[call-arg] + assert s.LLM_DISCUSSION_MODE == "rounds" + + +def test_phase_module_imports_reactive_voice_keep_existing_phases() -> None: + """Sanity: adding discussion_mode does not change the Phase enum.""" + assert Phase.DAY_DISCUSSION.value == "DAY_DISCUSSION" + + +# --------------------------------------------------------------------------- +# Integration tests: /wolf create captures discussion_mode, on_message +# produces speech_events, and the boot path wires DiscussionService. +# --------------------------------------------------------------------------- + + +@dataclass +class _FakeChannel: + id: int + deleted: bool = False + + async def delete(self, reason: str = "") -> None: + self.deleted = True + + +@dataclass +class _FakeResponse: + deferred: bool = False + ephemerals: list[str] = field(default_factory=list) + + async def send_message(self, content: str, ephemeral: bool = False) -> None: + if ephemeral: + self.ephemerals.append(content) + + async def defer(self, thinking: bool = False) -> None: + self.deferred = True + + +@dataclass +class _FakeFollowup: + messages: list[str] = field(default_factory=list) + + async def send(self, content: str) -> None: + self.messages.append(content) + + +class _FakeGuild: + def __init__(self, guild_id: int) -> None: + self.id = guild_id + + +class _FakeUser: + def __init__(self, user_id: int = 777) -> None: + self.id = user_id + + +class _FakeInteraction: + def __init__(self, guild_id: int, user_id: int = 777) -> None: + self.guild: Any = _FakeGuild(guild_id) + self.guild_id = guild_id + self.user = _FakeUser(user_id) + self.response = _FakeResponse() + self.followup = _FakeFollowup() + + +def _build_cog_with_settings(repo: Any, *, discussion_mode: str = "rounds") -> WolfCog: + settings = MagicMock() + settings.MAIN_TEXT_CHANNEL_ID = 100 + settings.MAIN_VOICE_CHANNEL_ID = 200 + settings.LLM_DISCUSSION_MODE = discussion_mode + return WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=settings, + ) + + +async def test_create_game_captures_rounds_mode(repo: SqliteRepo) -> None: + """/wolf create persists discussion_mode='rounds' from Settings.""" + cog = _build_cog_with_settings(repo, discussion_mode="rounds") + + async def fake_create(guild: Any, name: str, *, safe_to_delete_ids: set[str]) -> _FakeChannel: + return _FakeChannel(id=hash(name) % 10000) + + cog._create_private_channel = fake_create # type: ignore[method-assign] + interaction = _FakeInteraction(guild_id=99) + await WolfCog.create.callback(cog, interaction) # type: ignore[arg-type] + + game = await repo.load_active_game_for_guild("99") + assert game is not None + assert game.discussion_mode == "rounds" + + +async def test_create_game_captures_reactive_voice_mode(repo: SqliteRepo) -> None: + """/wolf create persists discussion_mode='reactive_voice' from Settings.""" + cog = _build_cog_with_settings(repo, discussion_mode="reactive_voice") + + async def fake_create(guild: Any, name: str, *, safe_to_delete_ids: set[str]) -> _FakeChannel: + return _FakeChannel(id=hash(name) % 10000) + + cog._create_private_channel = fake_create # type: ignore[method-assign] + interaction = _FakeInteraction(guild_id=98) + await WolfCog.create.callback(cog, interaction) # type: ignore[arg-type] + + game = await repo.load_active_game_for_guild("98") + assert game is not None + assert game.discussion_mode == "reactive_voice" + + +async def test_create_game_falls_back_on_invalid_mode(repo: SqliteRepo) -> None: + """/wolf create falls back to 'rounds' when given an invalid mode.""" + cog = _build_cog_with_settings(repo, discussion_mode="invalid_mode") + + async def fake_create(guild: Any, name: str, *, safe_to_delete_ids: set[str]) -> _FakeChannel: + return _FakeChannel(id=hash(name) % 10000) + + cog._create_private_channel = fake_create # type: ignore[method-assign] + interaction = _FakeInteraction(guild_id=97) + await WolfCog.create.callback(cog, interaction) # type: ignore[arg-type] + + game = await repo.load_active_game_for_guild("97") + assert game is not None + assert game.discussion_mode == "rounds" + + +async def _make_discussion_game( + repo: SqliteRepo, + *, + guild_id: str | None = None, + discussion_mode: str = "rounds", + human_seats: list[int] | None = None, + llm_seats: list[int] | None = None, +) -> tuple[Game, Any, Any]: + """Helper: create a DAY_DISCUSSION game with seats and a wired DiscussionService.""" + from wolfbot.domain.models import Seat + from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + ) + + gid = guild_id or f"g-{new_game_id()}" + game = Game( + id=new_game_id(), + guild_id=gid, + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=9999999999, + main_text_channel_id="100", + main_vc_channel_id="200", + created_at=0, + discussion_mode=discussion_mode, + ) + await repo.create_game(game) + for sno in human_seats or [1]: + await repo.insert_seat( + game.id, + Seat( + seat_no=sno, + display_name=f"H{sno}", + discord_user_id=f"user{sno}", + is_llm=False, + persona_key=None, + ), + ) + for sno in llm_seats or []: + await repo.insert_seat( + game.id, + Seat( + seat_no=sno, + display_name=f"NPC{sno}", + discord_user_id=None, + is_llm=True, + persona_key=f"persona{sno}", + ), + ) + + store = SqliteSpeechEventStore(repo._db) + ds = DiscussionService(store=store, log_sink=repo) + return game, store, ds + + +async def test_on_message_writes_speech_event_for_human_text(repo: SqliteRepo) -> None: + """Main-channel text during DAY_DISCUSSION writes a SpeechEvent row.""" + from wolfbot.domain.discussion import SpeechSource, make_phase_id + + game, store, ds = await _make_discussion_game(repo, human_seats=[1]) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=ds, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "user1" + msg.guild.id = game.guild_id + msg.channel.id = 100 + msg.content = "占いCO!私は占い師です" + + await WolfCog.on_message(cog, msg) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + # Expect baseline sentinel + the text event. + non_baseline = [e for e in events if e.source != SpeechSource.PHASE_BASELINE] + assert len(non_baseline) == 1 + assert non_baseline[0].source == SpeechSource.TEXT + assert non_baseline[0].speaker_seat == 1 + assert non_baseline[0].text == "占いCO!私は占い師です" + + +async def test_on_message_seeds_phase_baseline_before_text_event(repo: SqliteRepo) -> None: + """on_message must seed the phase_baseline sentinel so PublicDiscussionState + rebuild works even in an all-human game with no LLM dispatch.""" + from wolfbot.domain.discussion import SpeechSource, make_phase_id + + game, store, ds = await _make_discussion_game(repo, human_seats=[1, 2]) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=ds, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "user1" + msg.guild.id = game.guild_id + msg.channel.id = 100 + msg.content = "こんにちは" + + await WolfCog.on_message(cog, msg) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + baselines = [e for e in events if e.source == SpeechSource.PHASE_BASELINE] + assert len(baselines) == 1, "on_message should seed exactly one phase_baseline" + + # Second message from a different user should NOT duplicate the baseline. + msg2 = MagicMock() + msg2.author.bot = False + msg2.author.id = "user2" + msg2.guild.id = game.guild_id + msg2.channel.id = 100 + msg2.content = "了解" + + await WolfCog.on_message(cog, msg2) + + events = await store.load_phase(game.id, phase_id) + baselines = [e for e in events if e.source == SpeechSource.PHASE_BASELINE] + assert len(baselines) == 1, "baseline must be idempotent across multiple messages" + non_baseline = [e for e in events if e.source != SpeechSource.PHASE_BASELINE] + assert len(non_baseline) == 2 + + +async def test_llm_adapter_seeds_baseline_for_all_human_game(repo: SqliteRepo) -> None: + """submit_llm_discussion_rounds must seed the phase baseline even when there + are zero LLM seats so an all-human game gets a sentinel row.""" + from wolfbot.domain.discussion import SpeechSource, make_phase_id + from wolfbot.services.llm_service import LLMAdapter + + game, store, ds = await _make_discussion_game( + repo, + human_seats=[1, 2, 3], + llm_seats=[], + ) + players = await repo.load_players(game.id) + seats = await repo.load_seats(game.id) + + adapter = LLMAdapter( + repo=repo, + decider=MagicMock(), + message_poster=MagicMock(), + discussion_service=ds, + ) + + await adapter.submit_llm_discussion_rounds(game, players, seats) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + baselines = [e for e in events if e.source == SpeechSource.PHASE_BASELINE] + assert len(baselines) == 1, "baseline must be seeded even with no LLM seats" + + +async def test_main_py_wires_discussion_service() -> None: + """Verify the main module imports and instantiates DiscussionService correctly. + + We do not start the full bot — just verify the import chain doesn't break + and the LLMAdapter constructor accepts discussion_service. + """ + import inspect + + from wolfbot.services.discussion_service import DiscussionService, SqliteSpeechEventStore + from wolfbot.services.llm_service import LLMAdapter + + # Verify LLMAdapter accepts discussion_service kwarg. + + sig = inspect.signature(LLMAdapter.__init__) + assert "discussion_service" in sig.parameters + + # Verify WolfCog accepts discussion_service kwarg. + sig = inspect.signature(WolfCog.__init__) + assert "discussion_service" in sig.parameters + + # Verify SqliteSpeechEventStore exists and is importable. + assert callable(SqliteSpeechEventStore) + assert callable(DiscussionService) + + +async def test_on_message_record_emits_player_speech_log(repo: SqliteRepo) -> None: + """When discussion_service is wired, on_message should emit the PLAYER_SPEECH + LogEntry via record() — not via the legacy direct insert_log_public path. + This ensures the SpeechEvent and the LogEntry are produced atomically.""" + game, _store, ds = await _make_discussion_game(repo, human_seats=[1]) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=ds, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "user1" + msg.guild.id = game.guild_id + msg.channel.id = 100 + msg.content = "テスト発言" + + await WolfCog.on_message(cog, msg) + + # Verify PLAYER_SPEECH log was written (via record()'s log_sink hook). + logs = await repo.load_public_logs(game.id) + player_speeches = [lg for lg in logs if lg["kind"] == "PLAYER_SPEECH"] + assert len(player_speeches) == 1 + assert player_speeches[0]["text"] == "テスト発言" + assert player_speeches[0]["actor_seat"] == 1 + + +async def test_on_message_during_runoff_speech_writes_speech_event(repo: SqliteRepo) -> None: + """Main-channel text during DAY_RUNOFF_SPEECH also writes a SpeechEvent.""" + from wolfbot.domain.discussion import SpeechSource, make_phase_id + from wolfbot.domain.models import Seat + from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + ) + + game = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + phase=Phase.DAY_RUNOFF_SPEECH, + day_number=1, + deadline_epoch=9999999999, + main_text_channel_id="100", + main_vc_channel_id="200", + created_at=0, + ) + await repo.create_game(game) + await repo.insert_seat( + game.id, + Seat(seat_no=1, display_name="H1", discord_user_id="user1", is_llm=False, persona_key=None), + ) + store = SqliteSpeechEventStore(repo._db) + ds = DiscussionService(store=store, log_sink=repo) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=ds, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "user1" + msg.guild.id = game.guild_id + msg.channel.id = 100 + msg.content = "決選弁論です" + + await WolfCog.on_message(cog, msg) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_RUNOFF_SPEECH) + events = await store.load_phase(game.id, phase_id) + non_baseline = [e for e in events if e.source != SpeechSource.PHASE_BASELINE] + assert len(non_baseline) == 1 + assert non_baseline[0].source == SpeechSource.TEXT + assert non_baseline[0].text == "決選弁論です" + + +async def test_recovery_skips_rounds_resume_for_reactive_voice(repo: SqliteRepo) -> None: + """resume_llm_speech_progress must no-op for reactive_voice games so the + legacy two-round batch is never spawned after a restart.""" + from wolfbot.domain.models import Seat + from wolfbot.services.game_service import GameService + + game = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=9999999999, + main_text_channel_id="100", + main_vc_channel_id="200", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(game) + await repo.insert_seat( + game.id, + Seat(seat_no=1, display_name="NPC1", discord_user_id=None, is_llm=True, persona_key="p1"), + ) + + mock_discord = MagicMock() + mock_llm = MagicMock() + mock_llm.submit_llm_discussion_rounds = MagicMock() + mock_wake = MagicMock() + + service = GameService(repo=repo, discord=mock_discord, llm=mock_llm, wake=mock_wake) + + await service.resume_llm_speech_progress(game.id) + + # The LLM adapter's submit_llm_discussion_rounds must NOT have been called. + mock_llm.submit_llm_discussion_rounds.assert_not_called() + + +async def test_main_py_wires_reactive_voice_pipeline_services() -> None: + """Verify the reactive_voice pipeline modules are importable and the + WebsocketsMasterWsServer / SpeakArbiter / NpcRegistry / MasterIngestService + constructors accept the expected parameters.""" + import inspect + + from wolfbot.services.master_ingest_service import MasterIngestService + from wolfbot.services.master_ws_server import ( + MasterHandlers, + WebsocketsMasterWsServer, + ) + from wolfbot.services.npc_registry import InMemoryNpcRegistry + from wolfbot.services.speak_arbiter import SpeakArbiter + + sig = inspect.signature(WebsocketsMasterWsServer.__init__) + assert "host" in sig.parameters + assert "psk" in sig.parameters + assert "registry" in sig.parameters + assert "handlers" in sig.parameters + + sig = inspect.signature(SpeakArbiter.__init__) + assert "repo" in sig.parameters + assert "registry" in sig.parameters + assert "discussion" in sig.parameters + + sig = inspect.signature(MasterIngestService.__init__) + assert "registry" in sig.parameters + assert "discussion" in sig.parameters + assert "phase_lookup" in sig.parameters + + assert callable(InMemoryNpcRegistry) + assert callable(MasterHandlers) + + +async def test_discussion_service_record_posts_voice_stt_to_channel(repo: SqliteRepo) -> None: + """DiscussionService.record() must invoke message_poster.post_public for + voice_stt events so the utterance is visible to text-only observers.""" + from wolfbot.services.discussion_service import DiscussionService + + game, store, _ = await _make_discussion_game(repo, human_seats=[1]) + + posted: list[tuple[str, str, str]] = [] + + class _FakePoster: + async def post_public(self, game_id: str, text: str, kind: str) -> None: + posted.append((game_id, text, kind)) + + ds = DiscussionService(store=store, log_sink=repo, message_poster=_FakePoster()) + + from wolfbot.services.discussion_service import make_voice_stt_event + + event = make_voice_stt_event( + game_id=game.id, + phase_id="test-phase", + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="声の発言テスト", + stt_confidence=0.9, + audio_start_ms=0, + audio_end_ms=500, + ) + await ds.record(event) + + assert len(posted) == 1 + assert posted[0][0] == game.id + assert posted[0][1] == "声の発言テスト" + assert posted[0][2] == "PLAYER_SPEECH" + + +async def test_discussion_service_record_skips_channel_post_for_text(repo: SqliteRepo) -> None: + """DiscussionService.record() must NOT post to channel for source=text + since the original Discord message is already visible.""" + from wolfbot.services.discussion_service import ( + DiscussionService, + make_human_text_event, + ) + + game, store, _ = await _make_discussion_game(repo, human_seats=[1]) + + posted: list[tuple[str, str, str]] = [] + + class _FakePoster: + async def post_public(self, game_id: str, text: str, kind: str) -> None: + posted.append((game_id, text, kind)) + + ds = DiscussionService(store=store, log_sink=repo, message_poster=_FakePoster()) + + event = make_human_text_event( + game_id=game.id, + phase_id="test-phase", + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="テキスト発言", + ) + await ds.record(event) + + assert posted == [], "source=text events must not duplicate the channel post" + + +async def test_arbiter_try_dispatch_next_triggers_on_reactive_voice(repo: SqliteRepo) -> None: + """SpeakArbiter.try_dispatch_next dispatches a SpeakRequest when a + reactive_voice game has an online NPC in a discussion phase.""" + from wolfbot.domain.models import Seat + from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + ) + from wolfbot.services.npc_registry import InMemoryNpcRegistry + from wolfbot.services.speak_arbiter import SpeakArbiter + + game = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=9999999999, + main_text_channel_id="100", + main_vc_channel_id="200", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(game) + await repo.insert_seat( + game.id, + Seat(seat_no=1, display_name="H1", discord_user_id="user1", is_llm=False, persona_key=None), + ) + await repo.insert_seat( + game.id, + Seat(seat_no=2, display_name="NPC2", discord_user_id=None, is_llm=True, persona_key="p2"), + ) + + store = SqliteSpeechEventStore(repo._db) + ds = DiscussionService(store=store, log_sink=repo) + registry = InMemoryNpcRegistry() + + sent_messages: list[str] = [] + + async def _fake_send(msg: str) -> None: + sent_messages.append(msg) + + registry.register( + npc_id="npc2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_fake_send, + now_ms=1000, + ) + registry.assign("npc2", seat=2, game_id=game.id, phase_id="test") + + arbiter = SpeakArbiter( + repo=repo, + registry=registry, + discussion=ds, + now_ms=lambda: 2000, + ) + + # Seed the phase baseline so rebuild_public_state works. + await ds.begin_phase( + game_id=game.id, day=1, phase=Phase.DAY_DISCUSSION, alive_seat_nos=[1, 2] + ) + + await arbiter.try_dispatch_next(game.id) + + # The arbiter should have sent a LogicPacket + SpeakRequest to the NPC. + assert len(sent_messages) >= 2, f"Expected LogicPacket + SpeakRequest, got {len(sent_messages)}" + import json as _json + + types = [_json.loads(m).get("type") for m in sent_messages] + assert "logic_packet" in types + assert "speak_request" in types + + +async def test_arbiter_try_dispatch_next_noop_for_rounds(repo: SqliteRepo) -> None: + """try_dispatch_next must no-op for rounds-mode games.""" + from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + ) + from wolfbot.services.npc_registry import InMemoryNpcRegistry + from wolfbot.services.speak_arbiter import SpeakArbiter + + game = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=9999999999, + main_text_channel_id="100", + main_vc_channel_id="200", + created_at=0, + discussion_mode="rounds", + ) + await repo.create_game(game) + + store = SqliteSpeechEventStore(repo._db) + ds = DiscussionService(store=store, log_sink=repo) + registry = InMemoryNpcRegistry() + arbiter = SpeakArbiter(repo=repo, registry=registry, discussion=ds) + + # Should be a no-op — no errors, no dispatch. + await arbiter.try_dispatch_next(game.id) + + +async def test_recovery_sweep_closes_open_speak_requests(repo: SqliteRepo) -> None: + """reactive_voice_recovery_sweep must close open npc_speak_requests and + npc_playback_events with failure_reason=master_restart.""" + from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + ) + from wolfbot.services.npc_registry import InMemoryNpcRegistry + from wolfbot.services.speak_arbiter import SpeakArbiter + + game = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=9999999999, + main_text_channel_id="100", + main_vc_channel_id="200", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(game) + + # Insert an open speak request (no matching result). + await repo.insert_npc_speak_request( + request_id="sr_test1", + game_id=game.id, + phase_id="phase1", + npc_id="npc1", + seat_no=2, + logic_packet_id="lp1", + suggested_intent="speak", + max_chars=80, + max_duration_ms=12000, + priority=0, + expires_at_ms=99999, + created_at_ms=1000, + ) + # Insert an open playback event. + await repo.open_npc_playback( + request_id="sr_test2", + game_id=game.id, + phase_id="phase1", + npc_id="npc1", + speech_event_id="se1", + authorized_at_ms=1000, + playback_deadline_ms=13000, + ) + + store = SqliteSpeechEventStore(repo._db) + ds = DiscussionService(store=store, log_sink=repo) + registry = InMemoryNpcRegistry() + arbiter = SpeakArbiter(repo=repo, registry=registry, discussion=ds, now_ms=lambda: 5000) + + await arbiter.reactive_voice_recovery_sweep(game.id) + + # Verify open speak request was closed. + open_reqs = await repo.load_open_npc_speak_requests(game.id) + assert open_reqs == [], "All open speak requests should be closed" + + # Verify open playback was closed. + open_play = await repo.load_open_npc_playback(game.id) + assert open_play == [], "All open playback events should be closed" + + +async def test_recovery_service_calls_sweep_for_reactive_voice(repo: SqliteRepo) -> None: + """RecoveryService._recover_one must call the reactive_voice sweep for + reactive_voice games when the sweep callback is wired.""" + from wolfbot.domain.models import Seat + from wolfbot.services.recovery_service import RecoveryService + from wolfbot.services.timer_service import EngineRegistry + + game = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=9999999999, + main_text_channel_id="100", + main_vc_channel_id="200", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(game) + await repo.insert_seat( + game.id, + Seat(seat_no=1, display_name="H1", discord_user_id="u1", is_llm=False, persona_key=None), + ) + + swept_games: list[str] = [] + + async def _sweep(game_id: str) -> None: + swept_games.append(game_id) + + mock_discord = MagicMock() + mock_discord.reconcile = MagicMock(return_value=None) + mock_discord.announce_recovery = MagicMock(return_value=None) + + async def noop(*_a: Any, **_k: Any) -> None: + pass + + mock_discord.reconcile = noop + mock_discord.announce_recovery = noop + + from unittest.mock import AsyncMock + + mock_gs = MagicMock() + mock_gs.advance = AsyncMock() + mock_gs.resend_pending_dms = AsyncMock() + mock_gs.resume_llm_speech_progress = AsyncMock() + + registry = EngineRegistry() + svc = RecoveryService( + repo=repo, + game_service=mock_gs, + registry=registry, + discord=mock_discord, + reactive_voice_sweep=_sweep, + ) + + recovered = await svc.recover_all() + assert game.id in recovered + assert game.id in swept_games, "Sweep must be called for reactive_voice games" + + +async def test_game_service_emits_phase_summary_on_discussion_exit(repo: SqliteRepo) -> None: + """GameService.advance must emit discussion_phase_summary when leaving + DAY_DISCUSSION phase.""" + from wolfbot.domain.models import Seat + from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + ) + from wolfbot.services.game_service import GameService + + game = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=100, + main_text_channel_id="100", + main_vc_channel_id="200", + created_at=0, + discussion_mode="rounds", + ) + await repo.create_game(game) + from wolfbot.domain.enums import Role + + for sno in range(1, 10): + await repo.insert_seat( + game.id, + Seat( + seat_no=sno, + display_name=f"P{sno}", + discord_user_id=f"u{sno}", + is_llm=False, + persona_key=None, + ), + ) + await repo.set_player_role(game.id, sno, Role.VILLAGER) + + store = SqliteSpeechEventStore(repo._db) + ds = DiscussionService(store=store, log_sink=repo) + + mock_discord = MagicMock() + + async def noop_apply(*_a: Any, **_k: Any) -> None: + pass + + mock_discord.apply_permissions = noop_apply + mock_discord.kill_permissions = noop_apply + mock_discord.post_public = noop_apply + mock_discord.post_morning = noop_apply + mock_discord.send_private = noop_apply + mock_discord.send_vote_dms = noop_apply + mock_discord.send_night_action_dms = noop_apply + mock_discord.announce_waiting = noop_apply + mock_discord.on_game_end = noop_apply + mock_discord.reconcile = noop_apply + mock_discord.post_wolves_chat = noop_apply + + mock_llm = MagicMock() + mock_llm.submit_llm_votes = noop_apply + mock_llm.submit_llm_discussion_rounds = noop_apply + mock_llm.submit_llm_runoff_candidate_speeches = noop_apply + mock_llm.submit_llm_night_actions = noop_apply + mock_llm.discussion_service = ds + + mock_wake = MagicMock() + mock_wake.wake = MagicMock() + + service = GameService( + repo=repo, discord=mock_discord, llm=mock_llm, wake=mock_wake, clock=lambda: 200 + ) + + # The advance should transition from DAY_DISCUSSION to DAY_VOTE and + # emit the phase summary. We just need it to not crash. + await service.advance(game.id) + + # Verify the game moved to DAY_VOTE. + updated = await repo.load_game(game.id) + assert updated is not None + assert updated.phase == Phase.DAY_VOTE + + +async def test_ws_authenticate_reads_request_path() -> None: + """WebsocketsMasterWsServer._authenticate must read ws.request.path + (websockets 16.0) rather than the legacy ws.path.""" + from wolfbot.services.master_ws_server import MasterHandlers, WebsocketsMasterWsServer + from wolfbot.services.npc_registry import InMemoryNpcRegistry + + registry = InMemoryNpcRegistry() + handlers = MasterHandlers(registry=registry, now_ms=lambda: 0) + server = WebsocketsMasterWsServer( + host="127.0.0.1", + port=8899, + psk="testpsk", + registry=registry, + handlers=handlers, + ) + + # Simulate websockets 16.0 connection object with ws.request.path + class _FakeRequest: + path = "/?role=npc&psk=testpsk" + + class _FakeWs: + request = _FakeRequest() + # No .path attribute — websockets 16.0 style + + async def send(self, data: str) -> None: + pass + + async def close(self, code: int = 1000, reason: str = "") -> None: + pass + + ctx = await server._authenticate(_FakeWs()) + assert ctx is not None + assert ctx.role == "npc" + + +async def test_ws_authenticate_rejects_bad_psk() -> None: + """Auth must reject when psk doesn't match.""" + from wolfbot.services.master_ws_server import MasterHandlers, WebsocketsMasterWsServer + from wolfbot.services.npc_registry import InMemoryNpcRegistry + + registry = InMemoryNpcRegistry() + handlers = MasterHandlers(registry=registry, now_ms=lambda: 0) + server = WebsocketsMasterWsServer( + host="127.0.0.1", + port=8899, + psk="testpsk", + registry=registry, + handlers=handlers, + ) + + class _FakeRequest: + path = "/?role=npc&psk=wrong" + + closed_with: list[int] = [] + + class _FakeWs: + request = _FakeRequest() + + async def send(self, data: str) -> None: + pass + + async def close(self, code: int = 1000, reason: str = "") -> None: + closed_with.append(code) + + ctx = await server._authenticate(_FakeWs()) + assert ctx is None + assert closed_with == [4401] + + +# --------------------------------------------------------------------------- +# R1-F04 / R4-F10: on_message text path triggers arbiter dispatch callback +# --------------------------------------------------------------------------- + + +async def test_on_message_text_triggers_speech_recorded_callback(repo: SqliteRepo) -> None: + """After recording a text SpeechEvent, on_message must call the + on_speech_recorded callback so the arbiter can dispatch an NPC reply.""" + game, _store, ds = await _make_discussion_game( + repo, human_seats=[1], discussion_mode="reactive_voice" + ) + + dispatched_game_ids: list[str] = [] + + async def fake_on_speech_recorded(game_id: str) -> None: + dispatched_game_ids.append(game_id) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=ds, + on_speech_recorded=fake_on_speech_recorded, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "user1" + msg.guild.id = game.guild_id + msg.channel.id = 100 + msg.content = "テスト発言" + + await WolfCog.on_message(cog, msg) + + assert dispatched_game_ids == [game.id], ( + "on_speech_recorded must be called with the game_id after text event" + ) + + +async def test_on_message_text_no_callback_when_none(repo: SqliteRepo) -> None: + """When on_speech_recorded is None (no arbiter wired), on_message still + records the SpeechEvent without error.""" + from wolfbot.domain.discussion import SpeechSource, make_phase_id + + game, store, ds = await _make_discussion_game(repo, human_seats=[1]) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=ds, + on_speech_recorded=None, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "user1" + msg.guild.id = game.guild_id + msg.channel.id = 100 + msg.content = "発言テスト" + + await WolfCog.on_message(cog, msg) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + non_baseline = [e for e in events if e.source != SpeechSource.PHASE_BASELINE] + assert len(non_baseline) == 1 + + +async def test_main_wires_on_speech_recorded_to_cog() -> None: + """Verify the main module wiring passes on_speech_recorded to WolfCog.""" + import inspect + + sig = inspect.signature(WolfCog.__init__) + assert "on_speech_recorded" in sig.parameters, ( + "WolfCog must accept on_speech_recorded callback" + ) diff --git a/tests/test_rounds_speech_event_backfill.py b/tests/test_rounds_speech_event_backfill.py new file mode 100644 index 0000000..7028553 --- /dev/null +++ b/tests/test_rounds_speech_event_backfill.py @@ -0,0 +1,224 @@ +"""Bundle 3: rounds-mode SpeechEvent backfill — regression coverage. + +Asserts that wiring a `DiscussionService` into `LLMAdapter` does NOT change +existing rounds-mode observable behavior (post counts, kind, log entries, +discussion_rounds_done progress) AND that one SpeechEvent(source=npc_generated) +is written per accepted utterance, plus exactly one phase_baseline sentinel +per phase entry. The pre-bundle behavior (no DiscussionService wired) must +remain bitwise unchanged. +""" + +from __future__ import annotations + +import asyncio +import random +from dataclasses import dataclass, field +from typing import Any + +from wolfbot.domain.discussion import SpeechSource, make_phase_id +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Game, Seat +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, +) +from wolfbot.services.llm_service import ( + FakeLLMActionDecider, + LLMAction, + LLMAdapter, +) + + +@dataclass +class FakePoster: + messages: list[tuple[str, str]] = field(default_factory=list) + + async def post_public(self, game: Any, text: str, kind: str) -> None: + self.messages.append((text, kind)) + + async def post_wolves_chat(self, game: Any, text: str, kind: str) -> None: + pass + + +@dataclass +class WakeRecorder: + waked: list[str] = field(default_factory=list) + + def wake(self, game_id: str) -> None: + self.waked.append(game_id) + + +class FakeGS: + def __init__(self) -> None: + self.wake = WakeRecorder() + + +async def _seed(repo: SqliteRepo) -> tuple[Game, list[Seat]]: + game = Game( + id="g-be", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + heaven_channel_id="h1", + wolves_channel_id="w1", + created_at=0, + ) + await repo.create_game(game) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + Seat( + seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat(seat_no=3, display_name="ジナ", discord_user_id=None, is_llm=True, persona_key="gina"), + ] + for s in seats: + await repo.insert_seat(game.id, s) + await repo.set_player_role(game.id, 1, Role.WEREWOLF) + await repo.set_player_role(game.id, 2, Role.SEER) + await repo.set_player_role(game.id, 3, Role.VILLAGER) + return game, seats + + +async def _no_sleep_block(coro_factory: Any) -> Any: + import wolfbot.services.llm_service as svc + + original_sleep = asyncio.sleep + + async def no_sleep(_secs: float) -> None: + await original_sleep(0) + + svc.asyncio.sleep = no_sleep # type: ignore[attr-defined] + try: + return await coro_factory() + finally: + svc.asyncio.sleep = original_sleep # type: ignore[attr-defined] + + +async def _drain(adapter: LLMAdapter) -> None: + await asyncio.sleep(0.05) + for t in list(adapter._background_tasks): + await t + + +async def test_rounds_mode_emits_speech_events_when_discussion_service_wired( + repo: SqliteRepo, +) -> None: + game, seats = await _seed(repo) + poster = FakePoster() + decider = FakeLLMActionDecider( + default=LLMAction(intent="speak", public_message="占いCO", reason_summary="r"), + ) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + adapter = LLMAdapter( + repo=repo, + decider=decider, + message_poster=poster, + rng=random.Random(0), + clock=lambda: 1000, + discussion_service=discussion, + ) + adapter.set_game_service(FakeGS()) # type: ignore[arg-type] + players = await repo.load_players(game.id) + + async def run() -> None: + await adapter.submit_llm_discussion_rounds(game, players, seats) + await _drain(adapter) + + await _no_sleep_block(run) + + # Existing rounds behavior preserved. + assert len(poster.messages) == 4 + assert all(kind == "LLM_SPEAK" for _, kind in poster.messages) + for seat_no in (2, 3): + progress = await repo.load_llm_speech_progress(game.id, day=1, seat_no=seat_no) + assert progress[3] == 2 + + # Speech events: one phase_baseline sentinel + 4 npc_generated rows. + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + sources = [e.source for e in events] + assert sources.count(SpeechSource.PHASE_BASELINE) == 1 + assert sources.count(SpeechSource.NPC_GENERATED) == 4 + npc_events = [e for e in events if e.source == SpeechSource.NPC_GENERATED] + assert sorted(e.speaker_seat for e in npc_events) == [2, 2, 3, 3] + assert all(e.text == "占いCO" for e in npc_events) + + +async def test_rounds_mode_without_discussion_service_unchanged( + repo: SqliteRepo, +) -> None: + """Smoke: when no DiscussionService is wired, no speech_events rows are + written and the existing test_llm_trigger guarantees still hold.""" + game, seats = await _seed(repo) + poster = FakePoster() + decider = FakeLLMActionDecider( + default=LLMAction(intent="speak", public_message="発言", reason_summary="r"), + ) + adapter = LLMAdapter( + repo=repo, + decider=decider, + message_poster=poster, + rng=random.Random(0), + clock=lambda: 1000, + discussion_service=None, + ) + adapter.set_game_service(FakeGS()) # type: ignore[arg-type] + players = await repo.load_players(game.id) + + async def run() -> None: + await adapter.submit_llm_discussion_rounds(game, players, seats) + await _drain(adapter) + + await _no_sleep_block(run) + + assert len(poster.messages) == 4 + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + assert events == [] + + +async def test_rounds_mode_co_claims_visible_via_rebuild(repo: SqliteRepo) -> None: + """End-to-end: backfilled SpeechEvents fold into a PublicDiscussionState + whose `co_claims` extracts the 占いCO marker from the LLM utterance.""" + from wolfbot.services.discussion_service import rebuild_public_state_from_events + + game, seats = await _seed(repo) + poster = FakePoster() + decider = FakeLLMActionDecider( + default=LLMAction(intent="speak", public_message="占いCO", reason_summary="r"), + ) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + adapter = LLMAdapter( + repo=repo, + decider=decider, + message_poster=poster, + rng=random.Random(0), + clock=lambda: 1000, + discussion_service=discussion, + ) + adapter.set_game_service(FakeGS()) # type: ignore[arg-type] + players = await repo.load_players(game.id) + + async def run() -> None: + await adapter.submit_llm_discussion_rounds(game, players, seats) + await _drain(adapter) + + await _no_sleep_block(run) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.alive_seat_nos == frozenset({1, 2, 3}) + # Both LLM seats CO'd; the silent set should be just the human seat. + assert state.silent_seats == frozenset({1}) + seat_set = {c.seat for c in state.co_claims} + assert seat_set == {2, 3} + assert all(c.role_claim == "seer" for c in state.co_claims) diff --git a/tests/test_voice_ingest_service.py b/tests/test_voice_ingest_service.py new file mode 100644 index 0000000..e03aca9 --- /dev/null +++ b/tests/test_voice_ingest_service.py @@ -0,0 +1,189 @@ +"""Bundle 6: voice-ingest worker — Protocol-level coverage. + +Verifies the boundary semantics that matter most for the speech-event-bus: + +* NPC packet → discarded at receive boundary (no VAD lifecycle). +* Human packet through full pipeline → exactly one `speech_event_payload`. +* Below-threshold STT result → no `speech_event_payload`, one `stt_failed`. +* SttProviderError → no `speech_event_payload`, one `stt_failed`. +* `apply_snapshot` / `apply_update` from Master correctly maintain the + voice-ingest registry view. +* Restart abandons open VAD windows. +""" + +from __future__ import annotations + +from wolfbot.services.stt_service import ( + FakeSttService, + SttProviderError, + SttResult, +) +from wolfbot.services.voice_ingest_client import ( + FakeMasterIngestionClient, + InMemoryNpcRegistryView, + make_default_listeners, +) +from wolfbot.services.voice_ingest_service import ( + VoiceIngestConfig, + VoiceIngestService, +) + + +def _phase_lookup_active() -> tuple[str, str] | None: + return ("g1", "g1::day1::DAY_DISCUSSION::1") + + +def _seat_lookup(uid: str) -> int | None: + table = {"u3": 3, "u4": 4} + return table.get(uid) + + +async def test_npc_packet_dropped_at_receive_boundary() -> None: + view = InMemoryNpcRegistryView() + view.apply_snapshot(("npc-bot",)) + client = FakeMasterIngestionClient() + stt = FakeSttService() + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + now_ms=lambda: 1000, + ) + forwarded = await svc.handle_voice_packet(speaker_user_id="npc-bot", pcm=b"x" * 32) + assert forwarded is False + assert svc.dropped_npc_packets == 1 + seg_id = await svc.begin_segment(speaker_user_id="npc-bot") + assert seg_id is None + assert client.vad_started == [] + + +async def test_human_segment_full_pipeline_emits_speech_event_payload() -> None: + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(scripted=[SttResult(text="こんにちは", confidence=0.85, duration_ms=600)]) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + now_ms=lambda: 1000, + ) + seg_id = await svc.begin_segment(speaker_user_id="u3") + assert seg_id is not None + await svc.handle_voice_packet(speaker_user_id="u3", pcm=b"audio") + await svc.end_segment(speaker_user_id="u3") + assert len(client.vad_started) == 1 + assert len(client.vad_ended) == 1 + assert len(client.speech_payloads) == 1 + payload = client.speech_payloads[0] + assert payload.text == "こんにちは" + assert payload.seat_no == 3 + assert payload.segment_id == seg_id + assert client.stt_failures == [] + + +async def test_low_confidence_drop_does_not_emit_speech_event_payload() -> None: + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(scripted=[SttResult(text="あ", confidence=0.3, duration_ms=200)]) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + config=VoiceIngestConfig(confidence_threshold=0.6), + now_ms=lambda: 1, + ) + await svc.begin_segment(speaker_user_id="u3") + await svc.end_segment(speaker_user_id="u3") + assert client.speech_payloads == [] + assert len(client.stt_failures) == 1 + assert client.stt_failures[0].failure_reason == "stt_low_confidence" + assert svc.stt_low_confidence_count == 1 + + +async def test_stt_provider_error_drops_segment() -> None: + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(scripted=[SttProviderError("stt_timeout")]) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + now_ms=lambda: 1, + ) + await svc.begin_segment(speaker_user_id="u3") + await svc.end_segment(speaker_user_id="u3") + assert client.speech_payloads == [] + assert len(client.stt_failures) == 1 + assert client.stt_failures[0].failure_reason == "stt_timeout" + + +async def test_unknown_speaker_seat_skipped() -> None: + """A speaker that is not in any seat (orphan voice in VC) cannot start a segment.""" + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(default=SttResult(text="hi", confidence=0.9, duration_ms=1)) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=lambda _uid: None, + phase_lookup=_phase_lookup_active, + now_ms=lambda: 1, + ) + seg = await svc.begin_segment(speaker_user_id="ghost") + assert seg is None + assert client.vad_started == [] + + +async def test_inactive_phase_skips_segment() -> None: + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(default=SttResult(text="hi", confidence=0.9, duration_ms=1)) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=lambda: None, + now_ms=lambda: 1, + ) + seg = await svc.begin_segment(speaker_user_id="u3") + assert seg is None + assert client.vad_started == [] + + +async def test_registry_view_listeners_apply_snapshot_and_update() -> None: + view = InMemoryNpcRegistryView() + on_snap, on_update = make_default_listeners(view) + on_snap(("a", "b")) + assert view.npc_user_ids() == {"a", "b"} + on_update(("c",), ("a",)) + assert view.npc_user_ids() == {"b", "c"} + assert view.is_npc("c") is True + assert view.is_npc("a") is False + + +async def test_restart_abandons_open_segments() -> None: + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(default=SttResult(text="x", confidence=0.9, duration_ms=1)) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + now_ms=lambda: 1, + ) + await svc.begin_segment(speaker_user_id="u3") + await svc.begin_segment(speaker_user_id="u4") + abandoned = await svc.abandon_open_segments() + assert abandoned == 2 diff --git a/uv.lock b/uv.lock index 82a4d48..699733a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,14 +1,14 @@ version = 1 -revision = 3 +revision = 1 requires-python = "==3.11.*" [[package]] name = "aiohappyeyeballs" version = "2.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 }, ] [[package]] @@ -24,25 +24,25 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, - { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, - { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, - { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, - { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, - { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, - { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, - { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, - { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, - { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513 }, + { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748 }, + { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673 }, + { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757 }, + { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152 }, + { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010 }, + { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251 }, + { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969 }, + { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871 }, + { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844 }, + { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969 }, + { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193 }, + { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477 }, + { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198 }, + { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321 }, + { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069 }, + { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859 }, ] [[package]] @@ -53,27 +53,27 @@ dependencies = [ { name = "frozenlist" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 }, ] [[package]] name = "aiosqlite" version = "0.22.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821 } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405 }, ] [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, ] [[package]] @@ -84,36 +84,36 @@ dependencies = [ { name = "idna" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, ] [[package]] name = "attrs" version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, ] [[package]] name = "certifi" version = "2026.4.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077 } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707 }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] [[package]] @@ -123,52 +123,52 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/57/9a2d9abdabdc9db8ef28ce0cf4129669e1c8717ba28d607b5ba357c4de3b/discord_py-2.7.1.tar.gz", hash = "sha256:24d5e6a45535152e4b98148a9dd6b550d25dc2c9fb41b6d670319411641249da", size = 1106326, upload-time = "2026-03-03T18:40:46.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/57/9a2d9abdabdc9db8ef28ce0cf4129669e1c8717ba28d607b5ba357c4de3b/discord_py-2.7.1.tar.gz", hash = "sha256:24d5e6a45535152e4b98148a9dd6b550d25dc2c9fb41b6d670319411641249da", size = 1106326 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/a7/17208c3b3f92319e7fad259f1c6d5a5baf8fd0654c54846ced329f83c3eb/discord_py-2.7.1-py3-none-any.whl", hash = "sha256:849dca2c63b171146f3a7f3f8acc04248098e9e6203412ce3cf2745f284f7439", size = 1227550, upload-time = "2026-03-03T18:40:44.492Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a7/17208c3b3f92319e7fad259f1c6d5a5baf8fd0654c54846ced329f83c3eb/discord_py-2.7.1-py3-none-any.whl", hash = "sha256:849dca2c63b171146f3a7f3f8acc04248098e9e6203412ce3cf2745f284f7439", size = 1227550 }, ] [[package]] name = "distro" version = "1.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, ] [[package]] name = "frozenlist" version = "1.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875 } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, - { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, - { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, - { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, - { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, - { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, - { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, - { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, - { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, - { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912 }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046 }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119 }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067 }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160 }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544 }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797 }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923 }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886 }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731 }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544 }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806 }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382 }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647 }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064 }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937 }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, ] [[package]] @@ -179,9 +179,9 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, ] [[package]] @@ -194,101 +194,97 @@ dependencies = [ { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] [[package]] name = "idna" version = "3.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629 }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, ] [[package]] name = "jiter" version = "0.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896, upload-time = "2026-04-10T14:26:01.986Z" }, - { url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085, upload-time = "2026-04-10T14:26:03.364Z" }, - { url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393, upload-time = "2026-04-10T14:26:05.314Z" }, - { url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937, upload-time = "2026-04-10T14:26:06.884Z" }, - { url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646, upload-time = "2026-04-10T14:26:08.345Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225, upload-time = "2026-04-10T14:26:10.161Z" }, - { url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682, upload-time = "2026-04-10T14:26:11.574Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973, upload-time = "2026-04-10T14:26:13.316Z" }, - { url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568, upload-time = "2026-04-10T14:26:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535, upload-time = "2026-04-10T14:26:16.956Z" }, - { url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709, upload-time = "2026-04-10T14:26:18.5Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660, upload-time = "2026-04-10T14:26:20.511Z" }, - { url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659, upload-time = "2026-04-10T14:26:22.152Z" }, - { url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772, upload-time = "2026-04-10T14:26:23.458Z" }, - { url = "https://files.pythonhosted.org/packages/32/a1/ef34ca2cab2962598591636a1804b93645821201cc0095d4a93a9a329c9d/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211", size = 311366, upload-time = "2026-04-10T14:28:27.943Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/520576a532a6b8a6f42747afed289c8448c879a34d7802fe2c832d4fd38f/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b", size = 309873, upload-time = "2026-04-10T14:28:29.688Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7c/c16db114ea1f2f532f198aa8dc39585026af45af362c69a0492f31bc4821/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea", size = 344816, upload-time = "2026-04-10T14:28:31.348Z" }, - { url = "https://files.pythonhosted.org/packages/99/8f/15e7741ff19e9bcd4d753f7ff22f988fd54592f134ca13701c13ea8c20e0/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577", size = 351445, upload-time = "2026-04-10T14:28:33.093Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896 }, + { url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085 }, + { url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393 }, + { url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937 }, + { url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646 }, + { url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225 }, + { url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682 }, + { url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973 }, + { url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568 }, + { url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535 }, + { url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709 }, + { url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660 }, + { url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659 }, + { url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772 }, ] [[package]] name = "librt" version = "0.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/1e/2ec7afcebcf3efea593d13aee18bbcfdd3a243043d848ebf385055e9f636/librt-0.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90904fac73c478f4b83f4ed96c99c8208b75e6f9a8a1910548f69a00f1eaa671", size = 67155, upload-time = "2026-04-09T16:04:42.933Z" }, - { url = "https://files.pythonhosted.org/packages/18/77/72b85afd4435268338ad4ec6231b3da8c77363f212a0227c1ff3b45e4d35/librt-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:789fff71757facc0738e8d89e3b84e4f0251c1c975e85e81b152cdaca927cc2d", size = 69916, upload-time = "2026-04-09T16:04:44.042Z" }, - { url = "https://files.pythonhosted.org/packages/27/fb/948ea0204fbe2e78add6d46b48330e58d39897e425560674aee302dca81c/librt-0.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1bf465d1e5b0a27713862441f6467b5ab76385f4ecf8f1f3a44f8aa3c695b4b6", size = 199635, upload-time = "2026-04-09T16:04:45.5Z" }, - { url = "https://files.pythonhosted.org/packages/ac/cd/894a29e251b296a27957856804cfd21e93c194aa131de8bb8032021be07e/librt-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f819e0c6413e259a17a7c0d49f97f405abadd3c2a316a3b46c6440b7dbbedbb1", size = 211051, upload-time = "2026-04-09T16:04:47.016Z" }, - { url = "https://files.pythonhosted.org/packages/18/8f/dcaed0bc084a35f3721ff2d081158db569d2c57ea07d35623ddaca5cfc8e/librt-0.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0785c2fb4a81e1aece366aa3e2e039f4a4d7d21aaaded5227d7f3c703427882", size = 224031, upload-time = "2026-04-09T16:04:48.207Z" }, - { url = "https://files.pythonhosted.org/packages/03/44/88f6c1ed1132cd418601cc041fbd92fed28b3a09f39de81978e0822d13ff/librt-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80b25c7b570a86c03b5da69e665809deb39265476e8e21d96a9328f9762f9990", size = 218069, upload-time = "2026-04-09T16:04:50.025Z" }, - { url = "https://files.pythonhosted.org/packages/a3/90/7d02e981c2db12188d82b4410ff3e35bfdb844b26aecd02233626f46af2b/librt-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4d16b608a1c43d7e33142099a75cd93af482dadce0bf82421e91cad077157f4", size = 224857, upload-time = "2026-04-09T16:04:51.684Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c3/c77e706b7215ca32e928d47535cf13dbc3d25f096f84ddf8fbc06693e229/librt-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:194fc1a32e1e21fe809d38b5faea66cc65eaa00217c8901fbdb99866938adbdb", size = 219865, upload-time = "2026-04-09T16:04:52.949Z" }, - { url = "https://files.pythonhosted.org/packages/52/d1/32b0c1a0eb8461c70c11656c46a29f760b7c7edf3c36d6f102470c17170f/librt-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8c6bc1384d9738781cfd41d09ad7f6e8af13cfea2c75ece6bd6d2566cdea2076", size = 218451, upload-time = "2026-04-09T16:04:54.174Z" }, - { url = "https://files.pythonhosted.org/packages/74/d1/adfd0f9c44761b1d49b1bec66173389834c33ee2bd3c7fd2e2367f1942d4/librt-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cb151e52a044f06e54ac7f7b47adbfc89b5c8e2b63e1175a9d587c43e8942a", size = 241300, upload-time = "2026-04-09T16:04:55.452Z" }, - { url = "https://files.pythonhosted.org/packages/09/b0/9074b64407712f0003c27f5b1d7655d1438979155f049720e8a1abd9b1a1/librt-0.9.0-cp311-cp311-win32.whl", hash = "sha256:f100bfe2acf8a3689af9d0cc660d89f17286c9c795f9f18f7b62dd1a6b247ae6", size = 55668, upload-time = "2026-04-09T16:04:56.689Z" }, - { url = "https://files.pythonhosted.org/packages/24/19/40b77b77ce80b9389fb03971431b09b6b913911c38d412059e0b3e2a9ef2/librt-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b73e4266307e51c95e09c0750b7ec383c561d2e97d58e473f6f6a209952fbb8", size = 62976, upload-time = "2026-04-09T16:04:57.733Z" }, - { url = "https://files.pythonhosted.org/packages/70/9d/9fa7a64041e29035cb8c575af5f0e3840be1b97b4c4d9061e0713f171849/librt-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc5518873822d2faa8ebdd2c1a4d7c8ef47b01a058495ab7924cb65bdbf5fc9a", size = 53502, upload-time = "2026-04-09T16:04:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1e/2ec7afcebcf3efea593d13aee18bbcfdd3a243043d848ebf385055e9f636/librt-0.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90904fac73c478f4b83f4ed96c99c8208b75e6f9a8a1910548f69a00f1eaa671", size = 67155 }, + { url = "https://files.pythonhosted.org/packages/18/77/72b85afd4435268338ad4ec6231b3da8c77363f212a0227c1ff3b45e4d35/librt-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:789fff71757facc0738e8d89e3b84e4f0251c1c975e85e81b152cdaca927cc2d", size = 69916 }, + { url = "https://files.pythonhosted.org/packages/27/fb/948ea0204fbe2e78add6d46b48330e58d39897e425560674aee302dca81c/librt-0.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1bf465d1e5b0a27713862441f6467b5ab76385f4ecf8f1f3a44f8aa3c695b4b6", size = 199635 }, + { url = "https://files.pythonhosted.org/packages/ac/cd/894a29e251b296a27957856804cfd21e93c194aa131de8bb8032021be07e/librt-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f819e0c6413e259a17a7c0d49f97f405abadd3c2a316a3b46c6440b7dbbedbb1", size = 211051 }, + { url = "https://files.pythonhosted.org/packages/18/8f/dcaed0bc084a35f3721ff2d081158db569d2c57ea07d35623ddaca5cfc8e/librt-0.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0785c2fb4a81e1aece366aa3e2e039f4a4d7d21aaaded5227d7f3c703427882", size = 224031 }, + { url = "https://files.pythonhosted.org/packages/03/44/88f6c1ed1132cd418601cc041fbd92fed28b3a09f39de81978e0822d13ff/librt-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80b25c7b570a86c03b5da69e665809deb39265476e8e21d96a9328f9762f9990", size = 218069 }, + { url = "https://files.pythonhosted.org/packages/a3/90/7d02e981c2db12188d82b4410ff3e35bfdb844b26aecd02233626f46af2b/librt-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4d16b608a1c43d7e33142099a75cd93af482dadce0bf82421e91cad077157f4", size = 224857 }, + { url = "https://files.pythonhosted.org/packages/ef/c3/c77e706b7215ca32e928d47535cf13dbc3d25f096f84ddf8fbc06693e229/librt-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:194fc1a32e1e21fe809d38b5faea66cc65eaa00217c8901fbdb99866938adbdb", size = 219865 }, + { url = "https://files.pythonhosted.org/packages/52/d1/32b0c1a0eb8461c70c11656c46a29f760b7c7edf3c36d6f102470c17170f/librt-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8c6bc1384d9738781cfd41d09ad7f6e8af13cfea2c75ece6bd6d2566cdea2076", size = 218451 }, + { url = "https://files.pythonhosted.org/packages/74/d1/adfd0f9c44761b1d49b1bec66173389834c33ee2bd3c7fd2e2367f1942d4/librt-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cb151e52a044f06e54ac7f7b47adbfc89b5c8e2b63e1175a9d587c43e8942a", size = 241300 }, + { url = "https://files.pythonhosted.org/packages/09/b0/9074b64407712f0003c27f5b1d7655d1438979155f049720e8a1abd9b1a1/librt-0.9.0-cp311-cp311-win32.whl", hash = "sha256:f100bfe2acf8a3689af9d0cc660d89f17286c9c795f9f18f7b62dd1a6b247ae6", size = 55668 }, + { url = "https://files.pythonhosted.org/packages/24/19/40b77b77ce80b9389fb03971431b09b6b913911c38d412059e0b3e2a9ef2/librt-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b73e4266307e51c95e09c0750b7ec383c561d2e97d58e473f6f6a209952fbb8", size = 62976 }, + { url = "https://files.pythonhosted.org/packages/70/9d/9fa7a64041e29035cb8c575af5f0e3840be1b97b4c4d9061e0713f171849/librt-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc5518873822d2faa8ebdd2c1a4d7c8ef47b01a058495ab7924cb65bdbf5fc9a", size = 53502 }, ] [[package]] name = "multidict" version = "6.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, - { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, - { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, - { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, - { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, - { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, - { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626 }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706 }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356 }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355 }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433 }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376 }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365 }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747 }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293 }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962 }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360 }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940 }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502 }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065 }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870 }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302 }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981 }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159 }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319 }, ] [[package]] @@ -301,25 +297,25 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, - { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, - { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, - { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, - { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, - { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, - { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307 }, + { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917 }, + { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516 }, + { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889 }, + { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844 }, + { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300 }, + { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498 }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314 }, ] [[package]] name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, ] [[package]] @@ -336,60 +332,60 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/59/bdcc6b759b8c42dd73afaf5bf8f902c04b37987a5514dbc1c64dba390fef/openai-2.32.0.tar.gz", hash = "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0", size = 693286, upload-time = "2026-04-15T22:28:19.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/59/bdcc6b759b8c42dd73afaf5bf8f902c04b37987a5514dbc1c64dba390fef/openai-2.32.0.tar.gz", hash = "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0", size = 693286 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570, upload-time = "2026-04-15T22:28:17.714Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570 }, ] [[package]] name = "packaging" version = "26.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831 }, ] [[package]] name = "pathspec" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2e/17/9c3094b822982b9f1ea666d8580ce59000f61f87c1663556fb72031ad9ec/pathspec-1.1.0.tar.gz", hash = "sha256:f5d7c555da02fd8dde3e4a2354b6aba817a89112fa8f333f7917a2a4834dd080", size = 133918, upload-time = "2026-04-23T01:46:22.298Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/17/9c3094b822982b9f1ea666d8580ce59000f61f87c1663556fb72031ad9ec/pathspec-1.1.0.tar.gz", hash = "sha256:f5d7c555da02fd8dde3e4a2354b6aba817a89112fa8f333f7917a2a4834dd080", size = 133918 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/c9/8eed0486f074e9f1ca7f8ce5ad663e65f12fdab344028d658fa1b03d35e0/pathspec-1.1.0-py3-none-any.whl", hash = "sha256:574b128f7456bd899045ccd142dd446af7e6cfd0072d63ad73fbc55fbb4aaa42", size = 56264, upload-time = "2026-04-23T01:46:20.606Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/8eed0486f074e9f1ca7f8ce5ad663e65f12fdab344028d658fa1b03d35e0/pathspec-1.1.0-py3-none-any.whl", hash = "sha256:574b128f7456bd899045ccd142dd446af7e6cfd0072d63ad73fbc55fbb4aaa42", size = 56264 }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] [[package]] name = "propcache" version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442 } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, - { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, - { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, - { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, - { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, - { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, - { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, - { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, - { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, - { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, - { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208 }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777 }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647 }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929 }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778 }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144 }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030 }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252 }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064 }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429 }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727 }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097 }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084 }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637 }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064 }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305 }, ] [[package]] @@ -402,9 +398,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981 }, ] [[package]] @@ -414,35 +410,31 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740, upload-time = "2026-04-20T14:41:20.932Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293, upload-time = "2026-04-20T14:43:42.115Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222, upload-time = "2026-04-20T14:41:57.841Z" }, - { url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852, upload-time = "2026-04-20T14:40:43.077Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134, upload-time = "2026-04-20T14:43:03.349Z" }, - { url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785, upload-time = "2026-04-20T14:41:19.285Z" }, - { url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404, upload-time = "2026-04-20T14:43:10.108Z" }, - { url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898, upload-time = "2026-04-20T14:44:51.475Z" }, - { url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856, upload-time = "2026-04-20T14:43:46.64Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168, upload-time = "2026-04-20T14:42:00.302Z" }, - { url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885, upload-time = "2026-04-20T14:41:05.253Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328, upload-time = "2026-04-20T14:41:43.991Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464, upload-time = "2026-04-20T14:43:12.215Z" }, - { url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837, upload-time = "2026-04-20T14:41:47.707Z" }, - { url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647, upload-time = "2026-04-20T14:42:27.535Z" }, - { url = "https://files.pythonhosted.org/packages/66/7f/03dbad45cd3aa9083fbc93c210ae8b005af67e4136a14186950a747c6874/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46", size = 2105683, upload-time = "2026-04-20T14:42:19.779Z" }, - { url = "https://files.pythonhosted.org/packages/26/22/4dc186ac8ea6b257e9855031f51b62a9637beac4d68ac06bee02f046f836/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874", size = 1940052, upload-time = "2026-04-20T14:43:59.274Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ca/d376391a5aff1f2e8188960d7873543608130a870961c2b6b5236627c116/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76", size = 1988172, upload-time = "2026-04-20T14:41:17.469Z" }, - { url = "https://files.pythonhosted.org/packages/0e/6b/523b9f85c23788755d6ab949329de692a2e3a584bc6beb67fef5e035aa9d/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531", size = 2128596, upload-time = "2026-04-20T14:40:41.707Z" }, - { url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973, upload-time = "2026-04-20T14:42:06.175Z" }, - { url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191, upload-time = "2026-04-20T14:43:14.319Z" }, - { url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791, upload-time = "2026-04-20T14:43:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197, upload-time = "2026-04-20T14:44:27.932Z" }, - { url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073, upload-time = "2026-04-20T14:43:20.729Z" }, - { url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886, upload-time = "2026-04-20T14:44:04.826Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528, upload-time = "2026-04-20T14:40:47.431Z" }, - { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740 }, + { url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293 }, + { url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222 }, + { url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852 }, + { url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134 }, + { url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785 }, + { url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404 }, + { url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898 }, + { url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856 }, + { url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168 }, + { url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885 }, + { url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328 }, + { url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464 }, + { url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837 }, + { url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647 }, + { url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973 }, + { url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191 }, + { url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791 }, + { url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197 }, + { url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073 }, + { url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886 }, + { url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528 }, + { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144 }, ] [[package]] @@ -454,18 +446,18 @@ dependencies = [ { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709 } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940 }, ] [[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, ] [[package]] @@ -479,9 +471,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 }, ] [[package]] @@ -492,61 +484,61 @@ dependencies = [ { name = "pytest" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 }, ] [[package]] name = "python-dotenv" version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, ] [[package]] name = "ruff" version = "0.15.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264 } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, - { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, - { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, - { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, - { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, - { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, - { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, - { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, - { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, - { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, - { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943 }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592 }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501 }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693 }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177 }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886 }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183 }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575 }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537 }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813 }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136 }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701 }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887 }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316 }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535 }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692 }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614 }, ] [[package]] name = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] [[package]] name = "tenacity" version = "9.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926 }, ] [[package]] @@ -556,27 +548,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598 } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374 }, ] [[package]] name = "types-aiofiles" version = "25.1.0.20260409" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/66/9e62a2692792bc96c0f423f478149f4a7b84720704c546c8960b0a047c89/types_aiofiles-25.1.0.20260409.tar.gz", hash = "sha256:49e67d72bdcf9fe406f5815758a78dc34a1249bb5aa2adba78a80aec0a775435", size = 14812, upload-time = "2026-04-09T04:22:35.308Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/66/9e62a2692792bc96c0f423f478149f4a7b84720704c546c8960b0a047c89/types_aiofiles-25.1.0.20260409.tar.gz", hash = "sha256:49e67d72bdcf9fe406f5815758a78dc34a1249bb5aa2adba78a80aec0a775435", size = 14812 } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/d0/28236f869ba4dfb223ecdbc267eb2bdb634b81a561dd992230a4f9ec48fa/types_aiofiles-25.1.0.20260409-py3-none-any.whl", hash = "sha256:923fedb532c772cc0f62e0ce4282725afa82ca5b41cabd9857f06b55e5eee8de", size = 14372, upload-time = "2026-04-09T04:22:34.328Z" }, + { url = "https://files.pythonhosted.org/packages/27/d0/28236f869ba4dfb223ecdbc267eb2bdb634b81a561dd992230a4f9ec48fa/types_aiofiles-25.1.0.20260409-py3-none-any.whl", hash = "sha256:923fedb532c772cc0f62e0ce4282725afa82ca5b41cabd9857f06b55e5eee8de", size = 14372 }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, ] [[package]] @@ -586,9 +578,32 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346 } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340 }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022 }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319 }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631 }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870 }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361 }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615 }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246 }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684 }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947 }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260 }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071 }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968 }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735 }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598 }, ] [[package]] @@ -604,6 +619,7 @@ dependencies = [ { name = "pydantic-settings" }, { name = "python-dotenv" }, { name = "tenacity" }, + { name = "websockets" }, ] [package.dev-dependencies] @@ -625,6 +641,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.2" }, { name = "python-dotenv", specifier = ">=1.0" }, { name = "tenacity", specifier = ">=8.2" }, + { name = "websockets", specifier = ">=12" }, ] [package.metadata.requires-dev] @@ -645,25 +662,25 @@ dependencies = [ { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" }, - { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" }, - { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" }, - { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" }, - { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" }, - { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" }, - { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" }, - { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" }, - { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" }, - { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" }, - { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641 }, + { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248 }, + { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988 }, + { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566 }, + { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079 }, + { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741 }, + { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099 }, + { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678 }, + { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803 }, + { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163 }, + { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859 }, + { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202 }, + { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866 }, + { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852 }, + { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919 }, + { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602 }, + { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461 }, + { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336 }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288 }, ] From e53bf431b557abf87c1dd18b1278648431e75811 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 11:47:59 +0900 Subject: [PATCH 002/133] feat: wire NPC bot production pipeline and Master voice-ingest integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 53 ++++ .gitignore | 9 +- CLAUDE.md | 15 + README.md | 7 +- pyproject.toml | 26 +- src/wolfbot/config.py | 6 +- src/wolfbot/domain/discussion.py | 1 + src/wolfbot/domain/ws_messages.py | 1 + src/wolfbot/main.py | 177 ++++++++++- src/wolfbot/npc_bot_main.py | 217 ++++++++++++-- src/wolfbot/persistence/schema.py | 5 + src/wolfbot/services/audio_sink.py | 79 +++++ src/wolfbot/services/discussion_service.py | 27 +- src/wolfbot/services/master_ingest_service.py | 1 + src/wolfbot/services/npc_generator_grok.py | 190 ++++++++++++ src/wolfbot/services/recovery_service.py | 33 +- src/wolfbot/services/speak_arbiter.py | 71 ++++- src/wolfbot/services/stt_service.py | 204 ++++++++++++- src/wolfbot/services/tts_service.py | 92 +++++- src/wolfbot/services/voice_ingest_client.py | 50 +++- src/wolfbot/services/voice_ingest_service.py | 1 + tests/test_npc_seat_assignment.py | 283 ++++++++++++++++++ uv.lock | 104 +++++++ 23 files changed, 1572 insertions(+), 80 deletions(-) create mode 100644 src/wolfbot/services/audio_sink.py create mode 100644 src/wolfbot/services/npc_generator_grok.py create mode 100644 tests/test_npc_seat_assignment.py diff --git a/.env.example b/.env.example index d0219fa..f4eaaa6 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,61 @@ +# ── Master bot (必須) ────────────────────────────────── +# Discord Developer Portal で発行した bot トークン DISCORD_TOKEN= +# xAI API キー — LLM プレイヤー(NPC)の思考・発言生成に使用 XAI_API_KEY= +# xAI のモデル名 — NPC の投票判断・夜行動・議論発言の生成に使用 XAI_MODEL=grok-4-1-fast +# bot を動かす Discord サーバー(guild)の数値 ID DISCORD_GUILD_ID= +# 議論用メイン text チャンネルの数値 ID MAIN_TEXT_CHANNEL_ID= +# プレイヤーが会話するメイン VC の数値 ID MAIN_VOICE_CHANNEL_ID= +# SQLite データベースの保存先パス WOLFBOT_DB_PATH=./wolfbot.db +# ログ出力レベル (DEBUG / INFO / WARNING / ERROR) LOG_LEVEL=INFO + +# ── リアルタイム音声モード (reactive_voice 使用時のみ) ── +# LLM 議論モード: rounds=ターン制 / reactive_voice=リアルタイム音声 +LLM_DISCUSSION_MODE=reactive_voice +# ── NPC bot / voice-ingest 間 WebSocket ── +# Master ↔ NPC bot 間 WebSocket の listen アドレス +MASTER_WS_LISTEN=127.0.0.1:8800 +# NPC bot の WebSocket 認証用 Pre-Shared Key (任意) +MASTER_NPC_PSK= + +# ── 音声認識 + 発話解析 (Master 統合) ────────────────── +# Master bot が VC に参加し、人間の音声を直接受信・解析する。 +# Gemini API キー — 音声を書き起こし+構造化解析するために使用 +GEMINI_API_KEY= +# Gemini のモデル名 — 音声→書き起こし+要約+CO検出+投票先抽出を1回のAPIコールで実行 +GEMINI_MODEL=gemini-2.0-flash-lite + +# ── NPC bot (NPC 1体ごとに別プロセス・別 .env で起動) ── +# Discord は 1 bot あたり 1 VC 接続/guild なので、NPC の数だけ +# 別の bot アカウントが必要。以下の変数を NPC ごとに変えて起動する。 +# 起動コマンド: NPC_ID=npc_1 NPC_DISCORD_TOKEN=... uv run wolfbot-npc +# +# NPC の一意 ID (Master WS 上の識別子。npc_1, npc_2, ... など) +# NPC_ID=npc_1 +# NPC 用 Discord bot トークン (NPC ごとに別の bot アプリケーションが必要) +# NPC_DISCORD_TOKEN= +# Master WS への接続先 URL +# MASTER_WS_URL=ws://127.0.0.1:8800 +# Master WS 認証用 PSK (Master 側の MASTER_NPC_PSK と同じ値) +# MASTER_NPC_PSK= +# xAI API キー (NPC の発言生成に使用。Master と同じキーでよい) +# XAI_API_KEY= +# xAI モデル名 +# XAI_MODEL=grok-4-1-fast +# VC 参加先 (Master 側と同じ値) +# MAIN_VOICE_CHANNEL_ID= +# Discord サーバー ID (Master 側と同じ値) +# DISCORD_GUILD_ID= +# VOICEVOX のスピーカー ID (NPC ごとに声を変える) +# TTS_VOICE_ID=3 +# VOICEVOX エンジンの URL +# VOICEVOX_URL=http://localhost:50021 +# ハートビート送信間隔(秒) +# HEARTBEAT_INTERVAL_S=5 diff --git a/.gitignore b/.gitignore index 8a50b42..b45b87b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,11 @@ build/ .DS_Store # for git push -.claude/ \ No newline at end of file +.claude/ + +# Specflow local env +.specflow/config.env +.specflow/runs/ +.specflow/worktrees/ +.codex +openspec \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index d8ee4fa..37f00d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ Contributor-facing conventions (commit style, test naming, PR expectations) live ```bash uv sync # install deps (main + dev) uv run wolfbot # run the bot (requires .env) +uv run wolfbot-npc # run one NPC bot (requires NPC env vars) uv run pytest tests # full test suite uv run pytest tests/test_rules_votes.py # single file @@ -40,6 +41,11 @@ From `.env.example`. All must be set for the bot to start: - `DISCORD_GUILD_ID`, `MAIN_TEXT_CHANNEL_ID`, `MAIN_VOICE_CHANNEL_ID` — ints - `WOLFBOT_DB_PATH` — SQLite path (default `./wolfbot.db`) - `LOG_LEVEL` — default `INFO` +- `LLM_DISCUSSION_MODE` — `rounds` (default) or `reactive_voice` +- `MASTER_WS_LISTEN` — Master WS bind address (default `127.0.0.1:8800`) +- `MASTER_NPC_PSK` — optional PSK for NPC/voice-ingest WS auth (SecretStr) +- `GEMINI_API_KEY` — optional Gemini API key for voice-ingest audio analysis (SecretStr) +- `GEMINI_MODEL` — Gemini model name (default `gemini-2.0-flash-lite`) Loaded at boot by `src/wolfbot/config.py::Settings` (pydantic-settings, reads `.env`, instantiated once in `main.py`). Adding a new env var = adding a typed field to `Settings` — do not parse `os.environ` directly from code paths. @@ -136,6 +142,15 @@ During wolf-attack splits, the main channel announces only `未確定: N件` (hi `src/wolfbot/services/llm_service.py` uses the `openai` client pointed at `https://api.x.ai/v1/chat/completions`. `response_format` enforces the `LLMAction` JSON schema strictly, and `tenacity` retries on transient errors. +### Reactive voice pipeline (realtime chat) + +When `LLM_DISCUSSION_MODE=reactive_voice` and `MASTER_NPC_PSK` is set, `main.py` wires a realtime voice pipeline: + +- **Master side** (`main.py`): `InMemoryNpcRegistry` + `SpeakArbiter` + `MasterIngestService` + `WebsocketsMasterWsServer`. The Master joins VC via `voice_recv.VoiceRecvClient` + `WolfbotAudioSink` for STT ingest, receives human speech via `GeminiAudioAnalyzer`, and dispatches NPC turns via the arbiter. On phase enter, `_on_reactive_phase_enter` assigns online NPCs to LLM seats. +- **NPC side** (`npc_bot_main.py`, one process per NPC bot): `discord.Client` joins VC → WebSocket to Master (PSK auth) → `NpcClient` handles `SpeakRequest` → `GrokNpcGenerator` (xAI Grok, structured JSON) → `NpcSpeechService` → `VoicevoxTtsService` (local VOICEVOX, 24kHz WAV) → `DiscordVoicePlayback` (`discord.FFmpegPCMAudio`). +- **Seat assignment**: `_on_reactive_phase_enter` in `main.py` pairs online NPC bots with unassigned LLM seats via `npc_registry.assign()`. The arbiter only dispatches to NPCs with an assigned seat. +- **Entry point**: `uv run wolfbot-npc` (each NPC needs its own Discord bot token, `NPC_ID`, and env vars — see `.env.example`). + The system prompt is **composed per actor** by `src/wolfbot/llm/prompt_builder.py`, not loaded verbatim. Three programmatically-generated blocks are layered onto `src/wolfbot/prompts/llm_system_prompt.md`: `_build_game_rules_block()` (9-player ruleset derived from `ROLE_DISTRIBUTION` + `VILLAGE_SIZE` so the canonical numbers aren't duplicated, plus the shared reasoning heuristics every seat sees — currently CO evaluation: a **never-countered** single role-CO is presumed near-real, but a **sole survivor** of a contested CO history (same role had ≥2 COs, others died) is **not** auto-trusted; topical mention of a CO ("the seer CO 〜について") is distinguished from self-declaration; counter-COs and divination/attack alignment feed the judgment), `_ROLE_STRATEGIES[role]` (role-scoped tactical hints — wolf/madman carry day-phased fake-CO playbooks that deliberately mirror each other, knight carries peaceful-morning guard-CO guidance, seer/medium/villager carry judgment-integrity rules (villager strategy explicitly forbids 「村人CO」/「素村CO」 and equivalents); cross-leak tests assert one role never sees another's strategy), and `_build_speech_profile_block(persona)` (the persona's 話法 section). Routing when editing: shared heuristics every seat should see → `_build_game_rules_block`; role-specific strategy → `_ROLE_STRATEGIES`; base framing / output format / hard invariants → the markdown template. The markdown is a template, not the whole prompt. The legacy `context_analysis` CO parser was removed (commit `b29c4f7`); LLM seats now read raw public-log `PLAYER_SPEECH` entries and apply the CO-detection rules from the system prompt themselves. Don't reintroduce a pre-digested CO summary. diff --git a/README.md b/README.md index 923cb3a..568fa83 100644 --- a/README.md +++ b/README.md @@ -106,12 +106,17 @@ uv run wolfbot | --- | --- | --- | | `DISCORD_TOKEN` | 必須 | Discord bot のトークン | | `XAI_API_KEY` | 必須 | xAI API キー | -| `XAI_MODEL` | 既定値: `grok-4-1-fast-reasoning` | 使用する xAI モデル名 | +| `XAI_MODEL` | 既定値: `grok-4-1-fast` | 使用する xAI モデル名 | | `DISCORD_GUILD_ID` | 必須 | `/wolf` コマンドを同期する guild の ID | | `MAIN_TEXT_CHANNEL_ID` | 必須 | 議論用に使う既存のメイン text チャンネル ID | | `MAIN_VOICE_CHANNEL_ID` | 必須 | 参加者が会話する既存のメイン VC の ID | | `WOLFBOT_DB_PATH` | 既定値: `./wolfbot.db` | SQLite データベースの保存先 | | `LOG_LEVEL` | 既定値: `INFO` | ログ出力レベル | +| `LLM_DISCUSSION_MODE` | 既定値: `rounds` | LLM 議論モード (`rounds` / `reactive_voice`) | +| `MASTER_WS_LISTEN` | 既定値: `127.0.0.1:8800` | Master ↔ NPC/voice-ingest WS の listen アドレス | +| `MASTER_NPC_PSK` | 任意 | NPC bot / voice-ingest の WS 認証用 Pre-Shared Key | +| `GEMINI_API_KEY` | 任意 | Gemini API キー (voice-ingest の音声解析用) | +| `GEMINI_MODEL` | 既定値: `gemini-2.0-flash-lite` | voice-ingest で使用する Gemini モデル名 | ## Discord 側の準備 diff --git a/pyproject.toml b/pyproject.toml index d1a4347..00dce30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,10 +13,12 @@ dependencies = [ "httpx>=0.27", "tenacity>=8.2", "websockets>=12", + "discord-ext-voice-recv>=0.5.2a179", ] [project.scripts] wolfbot = "wolfbot.main:cli" +wolfbot-npc = "wolfbot.npc_bot_main:main" [build-system] requires = ["hatchling"] @@ -38,9 +40,7 @@ dev = [ asyncio_mode = "auto" testpaths = ["tests"] pythonpath = ["src"] -filterwarnings = [ - "ignore::DeprecationWarning:audioop", -] +filterwarnings = ["ignore::DeprecationWarning:audioop"] [tool.ruff] line-length = 100 @@ -49,16 +49,20 @@ src = ["src", "tests"] [tool.ruff.lint] select = [ - "E", "F", "W", # pycodestyle / pyflakes - "I", # isort - "B", # bugbear - "UP", # pyupgrade - "SIM", # simplify - "RUF", # ruff-specific - "TID", # tidy imports + "E", + "F", + "W", # pycodestyle / pyflakes + "I", # isort + "B", # bugbear + "UP", # pyupgrade + "SIM", # simplify + "RUF", # ruff-specific + "TID", # tidy imports + ] ignore = [ - "E501", # line length handled by formatter + "E501", # line length handled by formatter + ] [tool.ruff.lint.per-file-ignores] diff --git a/src/wolfbot/config.py b/src/wolfbot/config.py index ae5f227..4d0bf90 100644 --- a/src/wolfbot/config.py +++ b/src/wolfbot/config.py @@ -10,7 +10,8 @@ class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + model_config = SettingsConfigDict( + env_file=".env", env_file_encoding="utf-8", extra="ignore") DISCORD_TOKEN: SecretStr XAI_API_KEY: SecretStr @@ -26,3 +27,6 @@ class Settings(BaseSettings): # Master ↔ NPC bot / voice-ingest WebSocket transport. MASTER_WS_LISTEN: str = "127.0.0.1:8800" MASTER_NPC_PSK: SecretStr | None = None + # Gemini API for voice-ingest audio analysis (STT + structured inference). + GEMINI_API_KEY: SecretStr | None = None + GEMINI_MODEL: str = "gemini-2.0-flash-lite" diff --git a/src/wolfbot/domain/discussion.py b/src/wolfbot/domain/discussion.py index 49aff0c..68c16e6 100644 --- a/src/wolfbot/domain/discussion.py +++ b/src/wolfbot/domain/discussion.py @@ -76,6 +76,7 @@ class SpeechEvent(BaseModel): stt_confidence: float | None = None audio_start_ms: int | None = None audio_end_ms: int | None = None + summary: str | None = None alive_seat_nos_json: str | None = None created_at_ms: int diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index 5edac89..7c6bfb0 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -183,6 +183,7 @@ class SpeechEventPayload(BaseEnvelope): duration_ms: int audio_start_ms: int audio_end_ms: int + summary: str | None = None class SttFailed(BaseEnvelope): diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 524d0b6..43ea4dd 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -40,6 +40,7 @@ async def _run() -> None: intents = discord.Intents.default() intents.message_content = True intents.members = True + intents.voice_states = True bot = commands.Bot(command_prefix="!", intents=intents) registry = EngineRegistry() @@ -78,8 +79,50 @@ async def post_public(self, game_id: str, text: str, kind: str) -> None: ) # The reactive_phase_enter callback is set below once the arbiter exists. _reactive_phase_cb: list[Any] = [] + # Optional NPC registry reference for seat assignment on phase enter. + _npc_registry_ref: list[Any] = [] + # voice-ingest seat/phase caches (populated when integrated ingest is active) + _vc_seat_map: dict[str, int] = {} + _vc_phase_cache: list[tuple[str, str] | None] = [None] + + async def _refresh_voice_ingest_cache(game_id: str) -> None: + """Update seat map and phase cache for the integrated voice-ingest.""" + game = await repo.load_game(game_id) + if game is None or game.ended_at is not None: + _vc_phase_cache[0] = None + return + from wolfbot.domain.discussion import make_phase_id as _mkpi + phase_id = _mkpi(game.id, game.day_number, game.phase) + _vc_phase_cache[0] = (game.id, phase_id) + seats = await repo.load_seats(game_id) + players = await repo.load_players(game_id) + _vc_seat_map.clear() + for s in seats: + if s.discord_user_id and any(p.seat_no == s.seat_no and p.alive for p in players): + _vc_seat_map[s.discord_user_id] = s.seat_no async def _on_reactive_phase_enter(game_id: str) -> None: + await _refresh_voice_ingest_cache(game_id) + # Assign online NPC bots to their game seats so the arbiter can pick them. + if _npc_registry_ref and _vc_phase_cache[0] is not None: + _game_id, phase_id = _vc_phase_cache[0] + npc_reg = _npc_registry_ref[0] + seats = await repo.load_seats(game_id) + llm_seats = [s for s in seats if s.is_llm] + online = npc_reg.all_online() + # Pair each online NPC bot with an unassigned LLM seat (round-robin). + assigned_npc_ids = { + e.npc_id for e in online if e.assigned_seat is not None and e.game_id == game_id} + unassigned_npcs = [ + e for e in online if e.npc_id not in assigned_npc_ids] + unassigned_seats = [s for s in llm_seats if not any( + e.assigned_seat == s.seat_no and e.game_id == game_id for e in online + )] + for npc_entry, seat in zip(unassigned_npcs, unassigned_seats, strict=False): + npc_reg.assign(npc_entry.npc_id, seat=seat.seat_no, + game_id=game_id, phase_id=phase_id) + log.info("npc_seat_assigned npc=%s seat=%d game=%s", + npc_entry.npc_id, seat.seat_no, game_id) if _reactive_phase_cb: await _reactive_phase_cb[0].try_dispatch_next(game_id) @@ -110,7 +153,8 @@ async def _on_speech_recorded(game_id: str) -> None: on_speech_recorded=_on_speech_recorded, ) await bot.add_cog(cog) - bot.tree.add_command(cog.wolf, guild=discord.Object(id=settings.DISCORD_GUILD_ID)) + bot.tree.add_command(cog.wolf, guild=discord.Object( + id=settings.DISCORD_GUILD_ID)) recovery = RecoveryService( repo=repo, @@ -125,6 +169,7 @@ async def _on_speech_recorded(game_id: str) -> None: # wants the WS transport running. Without a PSK the pipeline stays off and # reactive_voice games simply rely on the deadline gate (no NPC speech). ws_server: Any = None + voice_ingest: Any = None if settings.MASTER_NPC_PSK is not None: from wolfbot.services.master_ingest_service import MasterIngestService from wolfbot.services.master_ws_server import ( @@ -135,6 +180,7 @@ async def _on_speech_recorded(game_id: str) -> None: from wolfbot.services.speak_arbiter import SpeakArbiter npc_registry = InMemoryNpcRegistry() + _npc_registry_ref.append(npc_registry) arbiter = SpeakArbiter( repo=repo, @@ -143,6 +189,7 @@ async def _on_speech_recorded(game_id: str) -> None: ) _reactive_phase_cb.append(arbiter) recovery._reactive_voice_sweep = arbiter.reactive_voice_recovery_sweep + recovery._reactive_voice_reenter = arbiter.try_dispatch_next class _RepoPhase: """Adapts SqliteRepo to MasterIngestService.PhaseLookup.""" @@ -181,7 +228,8 @@ async def _on_speak_result(msg: Any, _ctx: Any) -> None: phase=g.phase, ) if accepted: - log.info("speak_result_accepted npc=%s game=%s", msg.npc_id, g.id) + log.info("speak_result_accepted npc=%s game=%s", + msg.npc_id, g.id) async def _on_tts_finished(msg: Any, _ctx: Any) -> None: await arbiter.handle_tts_finished(msg) @@ -211,22 +259,32 @@ async def _on_playback_failed(msg: Any, _ctx: Any) -> None: async def _on_speech_payload(msg: Any, _ctx: Any) -> None: await ingest_service.ingest_voice(msg) + # Finalize the STT gate for this segment before dispatching. + segment_id = getattr(msg, "segment_id", None) + if segment_id: + arbiter.finalize_stt(segment_id) # After a new human speech event lands, try dispatching an NPC reply. if hasattr(msg, "game_id") and msg.game_id: await arbiter.try_dispatch_next(msg.game_id) + async def _on_stt_failed(msg: Any, _ctx: Any) -> None: + segment_id = getattr(msg, "segment_id", None) + if segment_id: + arbiter.finalize_stt(segment_id) + # Gate cleared — try dispatching even though the STT failed. + game_id = getattr(msg, "game_id", None) + if game_id: + await arbiter.try_dispatch_next(game_id) + async def _on_vad_started(msg: Any, _ctx: Any) -> None: segment_id = getattr(msg, "segment_id", None) or "unknown" arbiter.mark_human_speaking(segment_id) async def _on_vad_ended(msg: Any, _ctx: Any) -> None: segment_id = getattr(msg, "segment_id", None) or "unknown" - arbiter.clear_human_speaking(segment_id) - # Human stopped speaking — after STT completes, the speech_event - # callback will trigger dispatch. But also try now in case no STT - # event follows (e.g. low-confidence drop). - if hasattr(msg, "game_id") and msg.game_id: - await arbiter.try_dispatch_next(msg.game_id) + # Keep the human-speaking gate held until STT completes. + # mark_pending_stt records the segment for finalization timeout. + arbiter.mark_pending_stt(segment_id) master_handlers = MasterHandlers( registry=npc_registry, @@ -238,6 +296,7 @@ async def _on_vad_ended(msg: Any, _ctx: Any) -> None: on_speech_event_payload=_on_speech_payload, on_vad_started=_on_vad_started, on_vad_ended=_on_vad_ended, + on_stt_failed=_on_stt_failed, ) host, port_str = settings.MASTER_WS_LISTEN.rsplit(":", 1) @@ -248,13 +307,86 @@ async def _on_vad_ended(msg: Any, _ctx: Any) -> None: registry=npc_registry, handlers=master_handlers, ) - log.info("reactive_voice pipeline wired, WS will listen on %s", settings.MASTER_WS_LISTEN) + log.info("reactive_voice pipeline wired, WS will listen on %s", + settings.MASTER_WS_LISTEN) + + # ---- integrated voice-ingest (STT runs in Master process) ---- + # Instead of a separate voice-ingest process, Master joins VC itself + # and pipes audio through VoiceIngestService → DirectMasterIngestionClient + # → arbiter/ingest_service, all in-process. + if settings.GEMINI_API_KEY is not None: + from wolfbot.services.stt_service import GeminiAudioAnalyzer + from wolfbot.services.voice_ingest_client import DirectMasterIngestionClient + from wolfbot.services.voice_ingest_service import VoiceIngestService + + # Direct callbacks (no WS ctx needed) + async def _direct_vad_started(msg: Any) -> None: + segment_id = getattr(msg, "segment_id", None) or "unknown" + arbiter.mark_human_speaking(segment_id) + + async def _direct_vad_ended(msg: Any) -> None: + segment_id = getattr(msg, "segment_id", None) or "unknown" + arbiter.mark_pending_stt(segment_id) + + async def _direct_speech_payload(msg: Any) -> None: + await ingest_service.ingest_voice(msg) + segment_id = getattr(msg, "segment_id", None) + if segment_id: + arbiter.finalize_stt(segment_id) + if hasattr(msg, "game_id") and msg.game_id: + await arbiter.try_dispatch_next(msg.game_id) + + async def _direct_stt_failed(msg: Any) -> None: + segment_id = getattr(msg, "segment_id", None) + if segment_id: + arbiter.finalize_stt(segment_id) + game_id = getattr(msg, "game_id", None) + if game_id: + await arbiter.try_dispatch_next(game_id) + + direct_client = DirectMasterIngestionClient( + on_vad_started=_direct_vad_started, + on_vad_ended=_direct_vad_ended, + on_speech_event_payload=_direct_speech_payload, + on_stt_failed=_direct_stt_failed, + ) + + gemini_stt = GeminiAudioAnalyzer( + api_key=settings.GEMINI_API_KEY.get_secret_value(), + model=settings.GEMINI_MODEL, + ) + + # NpcRegistryView adapter: InMemoryNpcRegistry → NpcRegistryView + class _RegistryViewAdapter: + def is_npc(self, discord_user_id: str) -> bool: + return discord_user_id in npc_registry.discord_bot_user_ids() + + def npc_user_ids(self) -> set[str]: + return npc_registry.discord_bot_user_ids() + + def _seat_lookup(discord_user_id: str) -> int | None: + """Resolve a Discord user ID to their seat number.""" + return _vc_seat_map.get(discord_user_id) + + def _phase_lookup() -> tuple[str, str] | None: + return _vc_phase_cache[0] + + voice_ingest = VoiceIngestService( + registry_view=_RegistryViewAdapter(), + master_client=direct_client, + stt=gemini_stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup, + ) + log.info("integrated voice-ingest wired (Gemini model=%s)", + settings.GEMINI_MODEL) @bot.event async def on_ready() -> None: guild = discord.Object(id=settings.DISCORD_GUILD_ID) await bot.tree.sync(guild=guild) - log.info("synced slash commands to guild %s", settings.DISCORD_GUILD_ID) + log.info("synced slash commands to guild %s", + settings.DISCORD_GUILD_ID) # on_ready re-fires on reconnect. Engines started on the first ready keep # ticking locally across reconnects, so re-running recovery would only # duplicate them. Run it once per process. @@ -263,7 +395,30 @@ async def on_ready() -> None: recovery_done.set() if ws_server is not None: await ws_server.start() - log.info("master WS server started on %s", settings.MASTER_WS_LISTEN) + log.info("master WS server started on %s", + settings.MASTER_WS_LISTEN) + # Join VC and start listening via discord-ext-voice-recv AudioSink. + if voice_ingest is not None: + from discord.ext import voice_recv + + from wolfbot.services.audio_sink import WolfbotAudioSink + + vc_channel = bot.get_channel(settings.MAIN_VOICE_CHANNEL_ID) + if vc_channel is not None and isinstance(vc_channel, discord.VoiceChannel): + try: + vc_client = await vc_channel.connect(cls=voice_recv.VoiceRecvClient) + sink = WolfbotAudioSink( + voice_ingest, loop=asyncio.get_running_loop()) + vc_client.listen(sink) + log.info("master_vc_joined channel=%s, audio_sink active", + settings.MAIN_VOICE_CHANNEL_ID) + except Exception: + log.warning("master_vc_join_failed channel=%s", + settings.MAIN_VOICE_CHANNEL_ID, exc_info=True) + else: + log.warning("voice_channel_not_found id=%s", + settings.MAIN_VOICE_CHANNEL_ID) + recovered = await recovery.recover_all() log.info("recovered %d game(s)", len(recovered)) diff --git a/src/wolfbot/npc_bot_main.py b/src/wolfbot/npc_bot_main.py index e153ae3..45c8e78 100644 --- a/src/wolfbot/npc_bot_main.py +++ b/src/wolfbot/npc_bot_main.py @@ -1,26 +1,29 @@ """NPC bot worker entrypoint. -Reads `NPC_*` env vars, builds an `NpcClient`, connects to Master, and -parks on the asyncio loop. The actual Discord-side bot connection is -deferred to a real implementation; this entrypoint focuses on the -configurable wiring and is exercised by integration tests via the -`NpcClient` API directly. +Reads ``NPC_*`` env vars, connects to Discord VC, opens a WS connection +to Master, registers, and runs the heartbeat + message loop. On +``speak_request`` the NPC generates text via Grok, synthesizes via +VOICEVOX, and plays the audio into the voice channel. -Run with: +Run with:: - uv run python -m wolfbot.npc_bot_main + uv run wolfbot-npc -(after exporting the NPC env vars described in the proposal — `NPC_ID`, -`NPC_DISCORD_TOKEN`, `MASTER_WS_URL`, `MASTER_NPC_PSK`, `TTS_VOICE_ID`, -`TTS_PROVIDER`, etc.) +(after exporting the env vars below — ``NPC_ID``, ``NPC_DISCORD_TOKEN``, +``MASTER_WS_URL``, ``MASTER_NPC_PSK``, ``XAI_API_KEY``, ``TTS_VOICE_ID``, +``VOICEVOX_URL``, ``MAIN_VOICE_CHANNEL_ID``.) """ from __future__ import annotations import asyncio import contextlib +import io import logging import os +import time + +import discord log = logging.getLogger(__name__) @@ -32,28 +35,192 @@ def _read_env(name: str, *, required: bool = True, default: str | None = None) - return val or "" +def _now_ms() -> int: + return int(time.time() * 1000) + + async def _main() -> None: - """Wire the worker. Implementation deliberately minimal — production code - will plug in the real Discord client + websockets transport. This - function is exercised in tests via component-level setups.""" npc_id = _read_env("NPC_ID") discord_token = _read_env("NPC_DISCORD_TOKEN") master_ws_url = _read_env("MASTER_WS_URL") - psk_set = bool(_read_env("MASTER_NPC_PSK")) - voice_id = _read_env("TTS_VOICE_ID", required=False, default="ja-JP-Standard-A") + psk = _read_env("MASTER_NPC_PSK") + xai_api_key = _read_env("XAI_API_KEY") + xai_model = _read_env("XAI_MODEL", required=False, default="grok-4-1-fast") + voice_id = _read_env("TTS_VOICE_ID", required=False, default="3") + voicevox_url = _read_env( + "VOICEVOX_URL", required=False, default="http://localhost:50021") + vc_channel_id = int(_read_env("MAIN_VOICE_CHANNEL_ID")) + guild_id = int(_read_env("DISCORD_GUILD_ID")) + heartbeat_interval = float( + _read_env("HEARTBEAT_INTERVAL_S", required=False, default="5")) + log_level = _read_env("LOG_LEVEL", required=False, default="INFO") + + logging.basicConfig( + level=getattr(logging, log_level.upper(), logging.INFO), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) log.info( - "npc_bot_starting npc_id=%s ws_url=%s discord_token_set=%s psk_set=%s voice=%s", - npc_id, - master_ws_url, - bool(discord_token), - psk_set, - voice_id, + "npc_bot_starting npc_id=%s ws=%s voice_id=%s voicevox=%s", + npc_id, master_ws_url, voice_id, voicevox_url, + ) + + # ---- Discord client (voice only, no message_content) ---- + intents = discord.Intents.default() + intents.voice_states = True + bot = discord.Client(intents=intents) + + vc_client_ref: list[discord.VoiceClient | None] = [None] + ready_event = asyncio.Event() + + @bot.event + async def on_ready() -> None: + log.info("npc_discord_ready user=%s", bot.user) + guild = bot.get_guild(guild_id) + if guild is None: + log.error("npc_guild_not_found id=%s", guild_id) + return + vc_channel = guild.get_channel(vc_channel_id) + if vc_channel is None or not isinstance(vc_channel, discord.VoiceChannel): + log.error("npc_vc_channel_not_found id=%s", vc_channel_id) + return + try: + vc_client_ref[0] = await vc_channel.connect() + log.info("npc_vc_joined channel=%s", vc_channel_id) + except Exception: + log.exception("npc_vc_join_failed channel=%s", vc_channel_id) + ready_event.set() + + # Start Discord in background + discord_task = asyncio.create_task(bot.start(discord_token)) + + # Wait for VC connection + try: + await asyncio.wait_for(ready_event.wait(), timeout=30.0) + except TimeoutError: + log.error("npc_discord_ready_timeout") + raise SystemExit(1) from None + + discord_user_id = str(bot.user.id) if bot.user else "unknown" + + # ---- Build NPC pipeline ---- + from wolfbot.services.npc_client import NpcClient, NpcClientConfig + from wolfbot.services.npc_generator_grok import GrokNpcGenerator, GrokNpcGeneratorConfig + from wolfbot.services.npc_speech_service import NpcSpeechService + from wolfbot.services.tts_service import VoicevoxTtsService + from wolfbot.services.voice_playback_service import DiscordVoicePlayback + + generator = GrokNpcGenerator( + api_key=xai_api_key, + config=GrokNpcGeneratorConfig(model=xai_model), + ) + speech_service = NpcSpeechService(generator=generator) + tts = VoicevoxTtsService(base_url=voicevox_url, + default_speaker=int(voice_id)) + + # ---- Playback function: WAV → discord.VoiceClient.play ---- + async def _play_audio(audio: bytes, sample_rate: int) -> tuple[int, int]: + vc = vc_client_ref[0] + if vc is None or not vc.is_connected(): + from wolfbot.services.voice_playback_service import VoicePlaybackError + raise VoicePlaybackError("vc_not_connected") + + # Convert raw WAV (possibly 24kHz) to PCM source + started = _now_ms() + done_event = asyncio.Event() + play_error: list[Exception | None] = [None] + + def _after(error: Exception | None) -> None: + play_error[0] = error + # Schedule set on the event loop since this callback is from a thread + bot.loop.call_soon_threadsafe(done_event.set) + + source = discord.FFmpegPCMAudio(io.BytesIO(audio), pipe=True) + vc.play(source, after=_after) + await done_event.wait() + + finished = _now_ms() + if play_error[0] is not None: + from wolfbot.services.voice_playback_service import VoicePlaybackError + raise VoicePlaybackError(f"playback_error: {play_error[0]}") + return (started, finished) + + playback = DiscordVoicePlayback(play_fn=_play_audio) + + # ---- WS connection to Master ---- + import websockets + + sep = "?" if "?" not in master_ws_url else "&" + ws_url = f"{master_ws_url}{sep}role=npc&psk={psk}" + ws = await websockets.connect(ws_url) + + async def _ws_send(msg: str) -> None: + await ws.send(msg) + + client = NpcClient( + config=NpcClientConfig( + npc_id=npc_id, + discord_bot_user_id=discord_user_id, + voice_id=voice_id, + ), + speech=speech_service, + tts=tts, + playback=playback, + send=_ws_send, + now_ms=_now_ms, + ) + + # Register with Master + await client.register() + log.info("npc_registered npc_id=%s user_id=%s", npc_id, discord_user_id) + + # ---- Background tasks ---- + stop = asyncio.Event() + + async def _heartbeat_loop() -> None: + while not stop.is_set(): + try: + await client.heartbeat() + except Exception: + log.exception("npc_heartbeat_failed") + await asyncio.sleep(heartbeat_interval) + + async def _message_loop() -> None: + try: + async for raw in ws: + if isinstance(raw, bytes): + raw = raw.decode("utf-8", "replace") + await client.process_message(raw) + except websockets.exceptions.ConnectionClosed: + log.warning("npc_ws_closed") + except Exception: + log.exception("npc_message_loop_error") + finally: + stop.set() + + hb_task = asyncio.create_task(_heartbeat_loop()) + msg_task = asyncio.create_task(_message_loop()) + + log.info("npc_bot_running npc_id=%s", npc_id) + + # Wait until the message loop or discord dies + _done, _pending = await asyncio.wait( + [discord_task, msg_task], + return_when=asyncio.FIRST_COMPLETED, ) - log.info("npc_bot_main is a wiring stub — production wiring is implemented elsewhere") - # The production wiring is deferred to a follow-up change; the worker - # is fully exercised through `NpcClient` from unit / integration tests. - raise SystemExit(0) + stop.set() + hb_task.cancel() + + # Cleanup + with contextlib.suppress(Exception): + await ws.close() + vc = vc_client_ref[0] + if vc is not None and vc.is_connected(): + await vc.disconnect() + with contextlib.suppress(Exception): + await bot.close() + + log.info("npc_bot_stopped npc_id=%s", npc_id) def main() -> None: diff --git a/src/wolfbot/persistence/schema.py b/src/wolfbot/persistence/schema.py index e49f335..91304b2 100644 --- a/src/wolfbot/persistence/schema.py +++ b/src/wolfbot/persistence/schema.py @@ -166,6 +166,7 @@ audio_start_ms INTEGER, audio_end_ms INTEGER, alive_seat_nos_json TEXT, + summary TEXT, created_at_ms INTEGER NOT NULL ) """, @@ -285,4 +286,8 @@ async def migrate(db_path: str | Path) -> None: "ALTER TABLE llm_speech_counts " "ADD COLUMN runoff_speech_done INTEGER NOT NULL DEFAULT 0" ) + async with db.execute("PRAGMA table_info(speech_events)") as cur: + cols = {row[1] async for row in cur} + if "summary" not in cols: + await db.execute("ALTER TABLE speech_events ADD COLUMN summary TEXT") await db.commit() diff --git a/src/wolfbot/services/audio_sink.py b/src/wolfbot/services/audio_sink.py new file mode 100644 index 0000000..0e6dd41 --- /dev/null +++ b/src/wolfbot/services/audio_sink.py @@ -0,0 +1,79 @@ +"""Bridge between discord-ext-voice-recv and VoiceIngestService. + +``WolfbotAudioSink`` is an :class:`voice_recv.AudioSink` subclass that: + +* Feeds decoded PCM frames into :pymethod:`VoiceIngestService.handle_voice_packet`. +* Uses the library's synthetic ``on_voice_member_speaking_start`` / + ``on_voice_member_speaking_stop`` events as a VAD signal to drive + ``begin_segment`` / ``end_segment``. + +All sink callbacks (``write``, ``on_voice_member_*``) are invoked from an +internal reader thread, so async work is scheduled on the bot's event loop +via :func:`asyncio.run_coroutine_threadsafe`. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +import discord +from discord.ext import voice_recv + +if TYPE_CHECKING: + from wolfbot.services.voice_ingest_service import VoiceIngestService + +log = logging.getLogger(__name__) + + +class WolfbotAudioSink(voice_recv.AudioSink): # type: ignore[misc] + """Receives per-user PCM and routes it to VoiceIngestService.""" + + def __init__( + self, + voice_ingest: VoiceIngestService, + loop: asyncio.AbstractEventLoop, + ) -> None: + super().__init__() + self._ingest = voice_ingest + self._loop = loop + + # ---- core sink interface ---- + + def wants_opus(self) -> bool: + return False + + def write(self, user: discord.Member | discord.User | None, data: voice_recv.VoiceData) -> None: + if user is None or data.pcm is None: + return + uid = str(user.id) + asyncio.run_coroutine_threadsafe( + self._ingest.handle_voice_packet( + speaker_user_id=uid, pcm=data.pcm), + self._loop, + ) + + def cleanup(self) -> None: + pass + + # ---- VAD via speaking indicators ---- + + @voice_recv.AudioSink.listener() # type: ignore[misc] + def on_voice_member_speaking_start(self, member: discord.Member) -> None: + uid = str(member.id) + asyncio.run_coroutine_threadsafe( + self._ingest.begin_segment(speaker_user_id=uid), + self._loop, + ) + + @voice_recv.AudioSink.listener() # type: ignore[misc] + def on_voice_member_speaking_stop(self, member: discord.Member) -> None: + uid = str(member.id) + asyncio.run_coroutine_threadsafe( + self._ingest.end_segment(speaker_user_id=uid), + self._loop, + ) + + +__all__ = ["WolfbotAudioSink"] diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py index 6122f00..3701cf4 100644 --- a/src/wolfbot/services/discussion_service.py +++ b/src/wolfbot/services/discussion_service.py @@ -57,7 +57,8 @@ class SpeechEventStore(Protocol): async def insert(self, event: SpeechEvent) -> None: ... - async def load_phase(self, game_id: str, phase_id: str) -> Sequence[SpeechEvent]: ... + async def load_phase(self, game_id: str, + phase_id: str) -> Sequence[SpeechEvent]: ... async def load_for_game(self, game_id: str) -> Sequence[SpeechEvent]: ... @@ -66,7 +67,8 @@ async def load_for_game(self, game_id: str) -> Sequence[SpeechEvent]: ... class SpeechMessagePoster(Protocol): """Subset of DiscordBotAdapter used by the write hook for main-channel posts.""" - async def post_public(self, game_id: str, text: str, kind: str) -> None: ... + async def post_public(self, game_id: str, text: str, + kind: str) -> None: ... @runtime_checkable @@ -91,8 +93,8 @@ async def insert(self, event: SpeechEvent) -> None: INSERT INTO speech_events ( event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, - alive_seat_nos_json, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + alive_seat_nos_json, summary, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( event.event_id, @@ -108,6 +110,7 @@ async def insert(self, event: SpeechEvent) -> None: event.audio_start_ms, event.audio_end_ms, event.alive_seat_nos_json, + event.summary, event.created_at_ms, ), ) @@ -118,7 +121,7 @@ async def load_phase(self, game_id: str, phase_id: str) -> Sequence[SpeechEvent] """ SELECT event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, - alive_seat_nos_json, created_at_ms + alive_seat_nos_json, summary, created_at_ms FROM speech_events WHERE game_id=? AND phase_id=? ORDER BY created_at_ms ASC, event_id ASC @@ -133,7 +136,7 @@ async def load_for_game(self, game_id: str) -> Sequence[SpeechEvent]: """ SELECT event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, - alive_seat_nos_json, created_at_ms + alive_seat_nos_json, summary, created_at_ms FROM speech_events WHERE game_id=? ORDER BY created_at_ms ASC, event_id ASC @@ -159,7 +162,8 @@ def _row_to_event(row: Any) -> SpeechEvent: audio_start_ms=row[10], audio_end_ms=row[11], alive_seat_nos_json=row[12], - created_at_ms=row[13], + summary=row[13], + created_at_ms=row[14], ) @@ -349,7 +353,8 @@ async def record(self, event: SpeechEvent) -> None: except Exception: log.exception( "PLAYER_SPEECH log insert failed", - extra={"event_id": event.event_id, "source": event.source.value}, + extra={"event_id": event.event_id, + "source": event.source.value}, ) if self._poster is not None and event.source != SpeechSource.TEXT and event.text: @@ -358,7 +363,8 @@ async def record(self, event: SpeechEvent) -> None: except Exception: log.exception( "PLAYER_SPEECH channel post failed", - extra={"event_id": event.event_id, "source": event.source.value}, + extra={"event_id": event.event_id, + "source": event.source.value}, ) async def begin_phase( @@ -458,7 +464,8 @@ def apply_speech_event( return None speaker = event.speaker_seat - silent = set(state.silent_seats) if state.silent_seats else set(state.alive_seat_nos) + silent = set(state.silent_seats) if state.silent_seats else set( + state.alive_seat_nos) if state.silent_seats == frozenset() and not state.recent_speech_event_ids: # First non-baseline event: seed silent_seats from alive baseline. silent = set(state.alive_seat_nos) diff --git a/src/wolfbot/services/master_ingest_service.py b/src/wolfbot/services/master_ingest_service.py index 0d1b50a..d932a5f 100644 --- a/src/wolfbot/services/master_ingest_service.py +++ b/src/wolfbot/services/master_ingest_service.py @@ -126,6 +126,7 @@ async def ingest_voice( stt_confidence=payload.confidence, audio_start_ms=payload.audio_start_ms, audio_end_ms=payload.audio_end_ms, + summary=payload.summary, created_at_ms=default_now_ms(), ) await self.discussion.record(event) diff --git a/src/wolfbot/services/npc_generator_grok.py b/src/wolfbot/services/npc_generator_grok.py new file mode 100644 index 0000000..1263d63 --- /dev/null +++ b/src/wolfbot/services/npc_generator_grok.py @@ -0,0 +1,190 @@ +"""Concrete NpcGenerator that calls xAI Grok for reactive speech. + +Given a ``LogicPacket`` (summarised game state, logic candidates, pressure +map) and a ``SpeakRequest`` (max chars, phase, intent), this module builds +a minimal Japanese prompt and hits the xAI chat completions endpoint with +structured JSON output. + +The prompt is deliberately simpler than the full ``llm_service`` prompt +pipeline — reactive utterances are short (80-char cap) situational remarks, +not multi-paragraph analytical speeches. The persona's ``style_guide`` and +``speech_profile`` are included for voice consistency but the strategic +rules sections are omitted. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass + +from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, SpeakRequest +from wolfbot.llm.personas import PERSONAS, Persona +from wolfbot.services.npc_speech_service import NpcGeneratedSpeech + +log = logging.getLogger(__name__) + +PERSONAS_BY_KEY: dict[str, Persona] = {p.key: p for p in PERSONAS} + +_RESPONSE_SCHEMA: dict[str, object] = { + "name": "reactive_speech", + "strict": True, + "schema": { + "type": "object", + "additionalProperties": False, + "required": ["text", "intent", "used_logic_ids"], + "properties": { + "text": {"type": "string", "maxLength": 300}, + "intent": { + "type": "string", + "enum": ["speak", "agree", "disagree", "question", "accuse", "defend", "skip"], + }, + "used_logic_ids": { + "type": "array", + "items": {"type": "string"}, + }, + }, + }, +} + + +def _build_system(persona: Persona, max_chars: int) -> str: + sp = persona.speech_profile + speech_block = ( + f"一人称: {sp.first_person}\n" + f"語尾/文体: {sp.sentence_style}\n" + f"間の取り方: {sp.pause_style}\n" + ) + if sp.signature_phrases: + speech_block += f"特徴語(低頻度): {'、'.join(sp.signature_phrases)}\n" + return ( + "あなたは人狼ゲームに参加中のプレイヤーです。\n" + f"キャラクター名: {persona.display_name}\n" + f"性格: {persona.style_guide}\n" + f"## 話法\n{speech_block}\n" + "## ルール\n" + "- 日本語のみ。メタ発言禁止。AIであることに言及しない。\n" + f"- `text` は {max_chars} 文字以内の短い発言。\n" + "- 発言しない場合は intent を `skip`、text を空文字にする。\n" + "- `used_logic_ids` には参考にした logic candidate の id を入れる。\n" + ) + + +def _build_user(logic: LogicPacket, request: SpeakRequest) -> str: + lines = [ + f"フェイズ: {request.phase_id}", + f"提案意図: {request.suggested_intent}", + "", + "## 場の状況", + logic.public_state_summary or "(情報なし)", + ] + if logic.logic_candidates: + lines.append("") + lines.append("## 論点候補") + for c in logic.logic_candidates: + lines.append(_format_candidate(c)) + if logic.pressure: + lines.append("") + lines.append("## 圧力マップ (席番号 → 疑い度)") + for seat, val in sorted(logic.pressure.items()): + lines.append(f" 席{seat}: {val:.2f}") + lines.append("") + lines.append("上記を踏まえ、キャラクターとして自然な短い発言を生成してください。") + return "\n".join(lines) + + +def _format_candidate(c: LogicCandidate) -> str: + parts = [f"- [{c.id}] {c.claim}"] + if c.support: + parts.append(f" 根拠: {'、'.join(c.support)}") + if c.counter: + parts.append(f" 反論: {'、'.join(c.counter)}") + return "\n".join(parts) + + +@dataclass +class GrokNpcGeneratorConfig: + model: str = "grok-4-1-fast" + timeout: float = 15.0 + temperature: float = 0.8 + default_persona_key: str = "setsu" + + +class GrokNpcGenerator: + """Production NpcGenerator backed by xAI Grok.""" + + def __init__( + self, + *, + api_key: str, + config: GrokNpcGeneratorConfig | None = None, + ) -> None: + self._api_key = api_key + self.config = config or GrokNpcGeneratorConfig() + self._persona_key: str | None = None + + def set_persona(self, persona_key: str) -> None: + """Set the persona key for this NPC. Called after seat assignment.""" + self._persona_key = persona_key + + async def generate( + self, + *, + logic: LogicPacket, + request: SpeakRequest, + ) -> NpcGeneratedSpeech | None: + from openai import AsyncOpenAI + + persona = PERSONAS_BY_KEY.get( + self._persona_key or self.config.default_persona_key, PERSONAS[0] + ) + system = _build_system(persona, max_chars=request.max_chars) + user = _build_user(logic, request) + + client = AsyncOpenAI( + api_key=self._api_key, + base_url="https://api.x.ai/v1", + ) + try: + resp = await client.chat.completions.create( # type: ignore[call-overload] + model=self.config.model, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + response_format={ + "type": "json_schema", + "json_schema": _RESPONSE_SCHEMA, + }, + temperature=self.config.temperature, + timeout=self.config.timeout, + ) + except Exception: + log.exception("grok_npc_generate_failed") + return None + + content = resp.choices[0].message.content or "{}" + try: + data = json.loads(content) + except json.JSONDecodeError: + log.warning("grok_npc_invalid_json response=%s", content[:200]) + return None + + text = data.get("text", "").strip() + intent = data.get("intent", "speak") + if intent == "skip" or not text: + return None + + used_ids = tuple(data.get("used_logic_ids", [])) + # Rough estimate: ~150ms per character for TTS + estimated_ms = max(500, len(text) * 150) + + return NpcGeneratedSpeech( + text=text, + intent=intent, + used_logic_ids=used_ids, + estimated_duration_ms=estimated_ms, + ) + + +__all__ = ["GrokNpcGenerator", "GrokNpcGeneratorConfig"] diff --git a/src/wolfbot/services/recovery_service.py b/src/wolfbot/services/recovery_service.py index abf17c1..8ccf771 100644 --- a/src/wolfbot/services/recovery_service.py +++ b/src/wolfbot/services/recovery_service.py @@ -36,7 +36,8 @@ class RecoveryDiscordAdapter(Protocol): async def reconcile( self, game: Game, seats: Sequence[Seat], players: Sequence[Player] ) -> None: ... - async def announce_recovery(self, game: Game, pending: PendingDecision | None) -> None: ... + async def announce_recovery( + self, game: Game, pending: PendingDecision | None) -> None: ... class ReactiveVoiceRecoverySweep(Protocol): @@ -45,6 +46,12 @@ class ReactiveVoiceRecoverySweep(Protocol): async def __call__(self, game_id: str) -> None: ... +class ReactiveVoiceReenter(Protocol): + """Callable that triggers arbiter dispatch after recovery.""" + + async def __call__(self, game_id: str) -> None: ... + + class RecoveryService: def __init__( self, @@ -54,6 +61,7 @@ def __init__( discord: RecoveryDiscordAdapter, clock: Callable[[], int] = lambda: int(time.time()), reactive_voice_sweep: ReactiveVoiceRecoverySweep | None = None, + reactive_voice_reenter: ReactiveVoiceReenter | None = None, ) -> None: self.repo = repo self.game_service = game_service @@ -61,6 +69,7 @@ def __init__( self.discord = discord self.clock = clock self._reactive_voice_sweep = reactive_voice_sweep + self._reactive_voice_reenter = reactive_voice_reenter async def recover_all(self) -> list[str]: games = await self.repo.load_active_games() @@ -117,7 +126,8 @@ async def _recover_one(self, game: Game) -> None: try: await self._reactive_voice_sweep(game.id) except Exception: - log.exception("reactive_voice recovery sweep failed for %s", game.id) + log.exception( + "reactive_voice recovery sweep failed for %s", game.id) # Step 3: attach and start engine. Pass the recovery clock so tests # using FakeClock get deterministic timing — otherwise the engine @@ -137,7 +147,8 @@ async def _recover_one(self, game: Game) -> None: try: await self.game_service.resend_pending_dms(game.id) except Exception: - log.exception("resend_pending_dms during recovery failed for %s", game.id) + log.exception( + "resend_pending_dms during recovery failed for %s", game.id) # Step 5: resume LLM speech progress for DAY_DISCUSSION / # DAY_RUNOFF_SPEECH if any per-seat round/runoff_speech_done is still @@ -145,4 +156,18 @@ async def _recover_one(self, game: Game) -> None: try: await self.game_service.resume_llm_speech_progress(game.id) except Exception: - log.exception("resume_llm_speech_progress during recovery failed for %s", game.id) + log.exception( + "resume_llm_speech_progress during recovery failed for %s", game.id) + + # Step 6: re-enter the arbiter for reactive_voice games in a public + # speech phase so NPCs resume speaking after restart. + if ( + game.discussion_mode == "reactive_voice" + and game.phase in (Phase.DAY_DISCUSSION, Phase.DAY_RUNOFF_SPEECH) + and self._reactive_voice_reenter is not None + ): + try: + await self._reactive_voice_reenter(game.id) + except Exception: + log.exception( + "reactive_voice reenter dispatch failed for %s", game.id) diff --git a/src/wolfbot/services/speak_arbiter.py b/src/wolfbot/services/speak_arbiter.py index 2b6795d..03714db 100644 --- a/src/wolfbot/services/speak_arbiter.py +++ b/src/wolfbot/services/speak_arbiter.py @@ -65,6 +65,7 @@ class SpeakArbiterConfig: request_ttl_ms: int = 8000 playback_deadline_ms: int = 12_000 heartbeat_timeout_ms: int = 5000 + vad_finalization_timeout_ms: int = 4000 @dataclass @@ -105,22 +106,61 @@ def __init__( # PlaybackAuthorized and the closing tts_failed / playback_finished / # playback_failed event. While non-empty, no new SpeakRequest is sent. self._active_playback: set[str] = set() + # Playback deadline tracking: request_id → deadline_ms. + self._playback_deadlines: dict[str, int] = {} # human_currently_speaking gate; the WS handler flips this on # vad_speech_started / vad_speech_ended (handled in voice-ingest # plumbing in Bundle 8). Empty by default. self._human_speaking_segments: set[str] = set() + # Segments awaiting STT finalization. The human-speaking gate stays + # closed for a segment until speech_event_payload or stt_failed + # arrives (or vad_finalization_timeout_ms elapses). + # segment_id → deadline_ms + self._pending_stt_segments: dict[str, int] = {} # ------------------------------------------------------------- gates def mark_human_speaking(self, segment_id: str) -> None: self._human_speaking_segments.add(segment_id) + def mark_pending_stt(self, segment_id: str) -> None: + """VAD ended — keep gate held until STT finalizes or times out.""" + deadline = self._now_ms() + self.config.vad_finalization_timeout_ms + self._pending_stt_segments[segment_id] = deadline + + def finalize_stt(self, segment_id: str) -> None: + """STT completed (payload or failure) — release the segment gate.""" + self._pending_stt_segments.pop(segment_id, None) + self._human_speaking_segments.discard(segment_id) + def clear_human_speaking(self, segment_id: str) -> None: self._human_speaking_segments.discard(segment_id) def is_blocked(self) -> str | None: + now = self._now_ms() + # Sweep expired STT finalization deadlines — release segments whose + # STT never arrived within vad_finalization_timeout_ms. + expired_stt = [ + sid for sid, dl in self._pending_stt_segments.items() if now > dl + ] + for sid in expired_stt: + log.info("stt_finalization_timeout segment=%s", sid) + self._pending_stt_segments.pop(sid, None) + self._human_speaking_segments.discard(sid) if self._human_speaking_segments: return "human_currently_speaking" + # Sweep expired playback deadlines — close rows whose NPC never + # reported tts_failed / playback_finished / playback_failed. + expired_pb = [ + rid for rid, dl in self._playback_deadlines.items() if now > dl + ] + for rid in expired_pb: + log.info("playback_deadline_exceeded request=%s", rid) + self._active_playback.discard(rid) + self._playback_deadlines.pop(rid, None) + self._pending.pop(rid, None) + # DB close is best-effort — fire-and-forget in the sync check. + # The actual row closure is done in _sweep_expired_playback. if self._active_playback: return "queue_busy" return None @@ -267,7 +307,8 @@ async def _send(payload: str) -> None: try: await entry.send(payload) except Exception: - log.exception("speak_result_response_send_failed npc=%s", result.npc_id) + log.exception( + "speak_result_response_send_failed npc=%s", result.npc_id) async def _record_rejection(reason: str) -> None: await self.repo.insert_npc_speak_result( @@ -346,6 +387,7 @@ async def _record_rejection(reason: str) -> None: playback_deadline_ms=deadline, ) self._active_playback.add(result.request_id) + self._playback_deadlines[result.request_id] = deadline authorized = PlaybackAuthorized( ts=now, trace_id=result.trace_id, @@ -382,6 +424,7 @@ async def handle_tts_failed(self, msg: TtsFailed) -> None: failure_reason=msg.failure_reason, ) self._active_playback.discard(msg.request_id) + self._playback_deadlines.pop(msg.request_id, None) self._pending.pop(msg.request_id, None) async def handle_playback_finished(self, msg: PlaybackFinished) -> None: @@ -392,6 +435,7 @@ async def handle_playback_finished(self, msg: PlaybackFinished) -> None: failure_reason=None, ) self._active_playback.discard(msg.request_id) + self._playback_deadlines.pop(msg.request_id, None) self._pending.pop(msg.request_id, None) async def handle_playback_failed(self, msg: PlaybackFailed) -> None: @@ -403,10 +447,32 @@ async def handle_playback_failed(self, msg: PlaybackFailed) -> None: failure_reason=msg.failure_reason, ) self._active_playback.discard(msg.request_id) + self._playback_deadlines.pop(msg.request_id, None) self._pending.pop(msg.request_id, None) # ------------------------------------------------------------- auto-dispatch + async def _sweep_expired_playback(self) -> None: + """Close DB rows for playback windows that exceeded their deadline.""" + now = self._now_ms() + expired = [ + rid for rid, dl in list(self._playback_deadlines.items()) if now > dl + ] + for rid in expired: + log.info("playback_deadline_enforced request=%s", rid) + try: + await self.repo.close_npc_playback( + rid, + finished_at_ms=now, + outcome="failed", + failure_reason="playback_deadline_exceeded", + ) + except Exception: + log.exception("playback_deadline_close_failed request=%s", rid) + self._active_playback.discard(rid) + self._playback_deadlines.pop(rid, None) + self._pending.pop(rid, None) + async def try_dispatch_next(self, game_id: str) -> None: """Auto-pick the next candidate NPC and dispatch a SpeakRequest. @@ -414,6 +480,9 @@ async def try_dispatch_next(self, game_id: str) -> None: playback completes. No-op when the serial-speech gate is blocked, no NPC is online, or no game is in a reactive_voice discussion phase. """ + # Close expired playback rows in DB before checking the gate. + await self._sweep_expired_playback() + game = await self.repo.load_game(game_id) if game is None or game.ended_at is not None: return diff --git a/src/wolfbot/services/stt_service.py b/src/wolfbot/services/stt_service.py index 07b70c7..650483f 100644 --- a/src/wolfbot/services/stt_service.py +++ b/src/wolfbot/services/stt_service.py @@ -1,11 +1,17 @@ -"""Speech-to-text adapter Protocol + Gemini-backed implementation skeleton. - -The MVP STT provider is the Gemini API audio-input feature (per the -voice-ingest spec). We define a Protocol so unit tests can substitute -`FakeSttService` without making real HTTP calls. The Gemini implementation -is intentionally a skeleton — it lifts configuration from env vars and -shows the call site, but cannot be exercised end-to-end without live API -credentials. +"""Speech-to-text adapter Protocol + pluggable provider implementations. + +Providers: +- ``GeminiAudioAnalyzer`` — sends raw audio to a cheap Gemini model + (e.g. gemini-2.0-flash-lite) and gets back transcription + structured + analysis (summary, claimed role, vote target, stance) in one API call. + This is the default for voice-ingest because it eliminates a separate + STT + LLM hop. +- ``GeminiSttService`` — skeleton; delegates to a user-supplied callable. +- ``FakeSttService`` — deterministic stub for tests. + +The ``SttService`` Protocol is the injection seam used by +``VoiceIngestService``. Any implementation satisfying the Protocol can be +swapped in via configuration. """ from __future__ import annotations @@ -25,11 +31,16 @@ class SttResult: `text` is empty on a low-confidence drop; `confidence` is set so callers can apply their own threshold check before deciding to emit a SpeechEvent. Hard provider failures raise SttProviderError instead. + + `summary` is an optional structured analysis of the utterance content, + populated by providers that combine STT + inference in one call (e.g. + GeminiAudioAnalyzer). Providers that only do transcription leave it None. """ text: str confidence: float duration_ms: int + summary: str | None = None class SttProviderError(RuntimeError): @@ -109,7 +120,8 @@ def __init__( *, api_key: str, model: str, - transcribe_fn: Callable[[bytes, str, str, float], Awaitable[SttResult]] | None = None, + transcribe_fn: Callable[[bytes, str, str, float], + Awaitable[SttResult]] | None = None, ) -> None: self.api_key = api_key self.model = model @@ -127,8 +139,182 @@ async def transcribe( return await self._transcribe_fn(audio, language, self.model, timeout_s) +class GeminiAudioAnalyzer: + """Gemini multimodal audio → transcription + structured analysis. + + Sends raw PCM/WAV audio directly to a cheap Gemini model and asks for + both transcription and a structured JSON analysis in a single request. + This replaces separate STT → LLM hops with one API call. + + The structured output includes: + - ``transcript``: verbatim Japanese text + - ``summary``: 1-sentence gist of what the speaker said + - ``confidence``: self-assessed transcription confidence (0.0-1.0) + - ``co_claim``: role CO if any (``seer``/``medium``/``knight``/null) + - ``vote_target_seat``: seat number the speaker wants to execute (null if none) + - ``stance``: dict of seat → trust (``positive``/``negative``/``neutral``) + + Uses ``httpx`` for the Gemini REST API to stay async-native. Model + defaults to ``gemini-2.0-flash-lite`` (cheapest multimodal, ~$0.075/1M + input tokens). Does NOT import ``httpx`` at module level. + """ + + _SYSTEM_PROMPT: str = ( + "あなたは人狼ゲームの音声ログ分析エンジンです。\n" + "渡された音声(日本語)を書き起こし、以下のJSON形式で返してください。\n" + "JSONのみ返答し、他のテキストは含めないでください。\n\n" + "```json\n" + "{\n" + ' "transcript": "発話の書き起こし全文",\n' + ' "summary": "1文の要約(30文字以内)",\n' + ' "confidence": 0.95,\n' + ' "co_claim": null,\n' + ' "vote_target_seat": null,\n' + ' "stance": {}\n' + "}\n" + "```\n\n" + "フィールド説明:\n" + "- transcript: 音声の書き起こし全文(日本語)\n" + "- summary: 発言内容の1文要約\n" + "- confidence: 書き起こし精度の自己評価(0.0〜1.0)\n" + "- co_claim: 役職CO(自称)があれば \"seer\"/\"medium\"/\"knight\"、なければ null\n" + "- vote_target_seat: 処刑対象として名指しした席番号(1〜9)、なければ null\n" + "- stance: 言及した席への態度 {\"席番号\": \"positive\"/\"negative\"/\"neutral\"}\n" + "\n音声が不明瞭な場合は confidence を低くし、transcript は聞き取れた範囲で。" + ) + + def __init__( + self, + *, + api_key: str, + model: str = "gemini-2.0-flash-lite", + api_base: str = "https://generativelanguage.googleapis.com/v1beta", + timeout_s: float = 15.0, + ) -> None: + self.api_key = api_key + self.model = model + self.api_base = api_base.rstrip("/") + self.timeout_s = timeout_s + + async def transcribe( + self, + *, + audio: bytes, + language: str, + timeout_s: float, + ) -> SttResult: + import base64 + import json + + import httpx + + audio_b64 = base64.b64encode(audio).decode("ascii") + effective_timeout = min(timeout_s, self.timeout_s) + + body = { + "contents": [ + { + "parts": [ + { + "inline_data": { + "mime_type": "audio/wav", + "data": audio_b64, + } + }, + { + "text": self._SYSTEM_PROMPT, + }, + ] + } + ], + "generationConfig": { + "responseMimeType": "application/json", + "temperature": 0.1, + }, + } + + url = ( + f"{self.api_base}/models/{self.model}:generateContent" + f"?key={self.api_key}" + ) + + try: + async with httpx.AsyncClient(timeout=effective_timeout) as client: + resp = await client.post(url, json=body) + if resp.status_code != 200: + raise SttProviderError( + f"gemini_http_{resp.status_code}" + ) + + resp_json = resp.json() + raw_text = ( + resp_json.get("candidates", [{}])[0] + .get("content", {}) + .get("parts", [{}])[0] + .get("text", "") + ) + + parsed = self._parse_response(raw_text) + transcript = parsed.get("transcript", "") + confidence = float(parsed.get("confidence", 0.0)) + + # Build a compact summary JSON from the structured fields + summary_dict = { + k: v + for k, v in parsed.items() + if k not in ("transcript", "confidence") and v is not None and v != {} + } + summary_str = json.dumps( + summary_dict, ensure_ascii=False) if summary_dict else None + + # Estimate duration from audio size (assume 16kHz 16-bit mono WAV) + data_bytes = max(0, len(audio) - 44) + duration_ms = int(data_bytes / (16_000 * 2) * 1000) + + return SttResult( + text=transcript, + confidence=confidence, + duration_ms=duration_ms, + summary=summary_str, + ) + + except SttProviderError: + raise + except httpx.TimeoutException as exc: + raise SttProviderError("gemini_timeout") from exc + except httpx.ConnectError as exc: + raise SttProviderError("gemini_connection_refused") from exc + except Exception as exc: + raise SttProviderError( + f"gemini_unexpected_{type(exc).__name__}" + ) from exc + + @staticmethod + def _parse_response(raw: str) -> dict: # type: ignore[type-arg] + """Best-effort parse of Gemini's JSON response. + + Gemini sometimes wraps the JSON in markdown fences or adds + trailing text. We strip those and try ``json.loads``. + """ + import json + + text = raw.strip() + # Strip markdown code fences + if text.startswith("```"): + lines = text.split("\n") + # Drop first and last fence lines + lines = [ln for ln in lines if not ln.strip().startswith("```")] + text = "\n".join(lines).strip() + try: + return json.loads(text) # type: ignore[no-any-return] + except json.JSONDecodeError: + log.warning("gemini_json_parse_failed raw=%s", text[:200]) + return {"transcript": text, "confidence": 0.3} + + __all__ = [ "FakeSttService", + "GeminiAudioAnalyzer", "GeminiSttService", "SttProviderError", "SttResult", diff --git a/src/wolfbot/services/tts_service.py b/src/wolfbot/services/tts_service.py index 69f6b6e..11f9433 100644 --- a/src/wolfbot/services/tts_service.py +++ b/src/wolfbot/services/tts_service.py @@ -1,8 +1,11 @@ -"""TTS adapter Protocol + cost-minimized default skeleton. +"""TTS adapter Protocol + pluggable provider implementations. -The MVP TTS provider is the Google Cloud TTS Standard voices (per design.md), -but we want NPC bots to be configurable. Define a Protocol so production -plugs in any provider and tests substitute `FakeTtsService`. +Providers: +- ``VoicevoxTtsService`` — local VOICEVOX engine (free, requires ``voicevox_engine`` + running on localhost). Default for MVP; swap to another provider by changing the + ``TTS_PROVIDER`` env var. +- ``GoogleCloudTtsService`` — skeleton; delegates to a user-supplied callable. +- ``FakeTtsService`` — deterministic stub for tests. Each NPC bot keeps a small in-memory cache keyed by `(provider, voice_id, sha256(text), speed, pitch)` to avoid re-synthesizing the same utterance. @@ -58,7 +61,8 @@ def __init__( default: TtsResult | None = None, ) -> None: self._scripted = list(scripted or []) - self._default = default or TtsResult(audio=b"audio-fake", duration_ms=500) + self._default = default or TtsResult( + audio=b"audio-fake", duration_ms=500) self.call_count = 0 self.requests: list[TtsRequest] = [] @@ -134,6 +138,83 @@ def put(self, req: TtsRequest, result: TtsResult) -> None: self._entries.popitem(last=False) +class VoicevoxTtsService: + """VOICEVOX local engine adapter. + + Requires the VOICEVOX engine running at ``base_url`` (default + ``http://localhost:50021``). The ``voice_id`` maps to a VOICEVOX + speaker ID (int). The two-step API: ``audio_query`` → ``synthesis``. + + Does NOT import ``httpx`` at module-load time so test environments + without a running VOICEVOX process still load this file. + """ + + def __init__( + self, + *, + base_url: str = "http://localhost:50021", + default_speaker: int = 3, + timeout_s: float = 15.0, + ) -> None: + self.base_url = base_url.rstrip("/") + self.default_speaker = default_speaker + self.timeout_s = timeout_s + + def _speaker_id(self, voice_id: str) -> int: + try: + return int(voice_id) + except (ValueError, TypeError): + return self.default_speaker + + async def synthesize(self, req: TtsRequest) -> TtsResult: + import httpx + + speaker = self._speaker_id(req.voice_id) + try: + async with httpx.AsyncClient(timeout=self.timeout_s) as client: + # Step 1: audio_query + query_resp = await client.post( + f"{self.base_url}/audio_query", + params={"text": req.text, "speaker": speaker}, + ) + if query_resp.status_code != 200: + raise TtsProviderError( + f"voicevox_audio_query_failed_{query_resp.status_code}" + ) + query_json = query_resp.json() + query_json["speedScale"] = req.speed + query_json["pitchScale"] = req.pitch + + # Step 2: synthesis + synth_resp = await client.post( + f"{self.base_url}/synthesis", + params={"speaker": speaker}, + json=query_json, + ) + if synth_resp.status_code != 200: + raise TtsProviderError( + f"voicevox_synthesis_failed_{synth_resp.status_code}" + ) + audio = synth_resp.content + # VOICEVOX outputs 24kHz WAV by default; estimate duration from size. + # WAV header is 44 bytes, 16-bit mono = 2 bytes/sample, 24kHz. + sample_rate = 24_000 + data_bytes = max(0, len(audio) - 44) + duration_ms = int(data_bytes / (sample_rate * 2) * 1000) + return TtsResult( + audio=audio, duration_ms=duration_ms, sample_rate=sample_rate + ) + except TtsProviderError: + raise + except httpx.TimeoutException: + raise TtsProviderError("voicevox_timeout") + except httpx.ConnectError: + raise TtsProviderError("voicevox_connection_refused") + except Exception as exc: + raise TtsProviderError( + f"voicevox_unexpected_{type(exc).__name__}") from exc + + __all__ = [ "FakeTtsService", "GoogleCloudTtsService", @@ -142,4 +223,5 @@ def put(self, req: TtsRequest, result: TtsResult) -> None: "TtsRequest", "TtsResult", "TtsService", + "VoicevoxTtsService", ] diff --git a/src/wolfbot/services/voice_ingest_client.py b/src/wolfbot/services/voice_ingest_client.py index 5ec8e25..171fb47 100644 --- a/src/wolfbot/services/voice_ingest_client.py +++ b/src/wolfbot/services/voice_ingest_client.py @@ -33,7 +33,9 @@ class MasterIngestionClient(Protocol): async def send_vad_started(self, msg: VadSpeechStarted) -> None: ... async def send_vad_ended(self, msg: VadSpeechEnded) -> None: ... - async def send_speech_event_payload(self, msg: SpeechEventPayload) -> None: ... + async def send_speech_event_payload( + self, msg: SpeechEventPayload) -> None: ... + async def send_stt_failed(self, msg: SttFailed) -> None: ... async def send_heartbeat(self, msg: Heartbeat) -> None: ... @@ -104,6 +106,51 @@ async def send_heartbeat(self, msg: Heartbeat) -> None: self.heartbeats.append(msg) +class DirectMasterIngestionClient: + """In-process bridge: calls Master-side handlers directly. + + Used when voice-ingest is integrated into the Master process instead of + running as a separate WS-connected worker. Each method invokes the same + callback that would be triggered by an inbound WS message, skipping + serialization and network overhead entirely. + + The callbacks are the same handler closures that ``main.py`` wires for + the WS path (``_on_vad_started``, ``_on_speech_payload``, etc.), but + here they're called directly. A thin ``ConnectionContext`` stub is + supplied because the handler signatures still expect one. + """ + + def __init__( + self, + *, + on_vad_started: Callable[[VadSpeechStarted], Awaitable[None]], + on_vad_ended: Callable[[VadSpeechEnded], Awaitable[None]], + on_speech_event_payload: Callable[[SpeechEventPayload], Awaitable[None]], + on_stt_failed: Callable[[SttFailed], Awaitable[None]], + ) -> None: + self._on_vad_started = on_vad_started + self._on_vad_ended = on_vad_ended + self._on_speech_event_payload = on_speech_event_payload + self._on_stt_failed = on_stt_failed + + async def send_vad_started(self, msg: VadSpeechStarted) -> None: + await self._on_vad_started(msg) + + async def send_vad_ended(self, msg: VadSpeechEnded) -> None: + await self._on_vad_ended(msg) + + async def send_speech_event_payload(self, msg: SpeechEventPayload) -> None: + await self._on_speech_event_payload(msg) + + async def send_stt_failed(self, msg: SttFailed) -> None: + await self._on_stt_failed(msg) + + async def send_heartbeat(self, msg: Heartbeat) -> None: + # Heartbeat is a no-op when running in-process — the Master IS + # the voice-ingest, so liveness monitoring is unnecessary. + pass + + class WebsocketsMasterIngestionClient: """Production client using the `websockets` library. @@ -221,6 +268,7 @@ def on_update(added: tuple[str, ...], removed: tuple[str, ...]) -> None: __all__ = [ + "DirectMasterIngestionClient", "FakeMasterIngestionClient", "InMemoryNpcRegistryView", "ListenerFactory", diff --git a/src/wolfbot/services/voice_ingest_service.py b/src/wolfbot/services/voice_ingest_service.py index b689a2c..e71ebd2 100644 --- a/src/wolfbot/services/voice_ingest_service.py +++ b/src/wolfbot/services/voice_ingest_service.py @@ -295,6 +295,7 @@ async def _run_stt( duration_ms=result.duration_ms, audio_start_ms=seg.audio_start_ms, audio_end_ms=audio_end_ms, + summary=result.summary, ) ) diff --git a/tests/test_npc_seat_assignment.py b/tests/test_npc_seat_assignment.py new file mode 100644 index 0000000..9b6048f --- /dev/null +++ b/tests/test_npc_seat_assignment.py @@ -0,0 +1,283 @@ +"""NPC seat assignment + GrokNpcGenerator prompt-building tests. + +Verifies: +- _on_reactive_phase_enter assigns online NPCs to LLM seats. +- Duplicate assignments are avoided (idempotent on re-enter). +- Fewer NPCs than seats → partial assignment. +- Fewer seats than NPCs → capped to seat count. +- GrokNpcGenerator prompt construction (system + user messages). +- GrokNpcGenerator skip intent returns None. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from wolfbot.domain.discussion import make_phase_id +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Game, Seat +from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, SpeakRequest +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.npc_generator_grok import ( + PERSONAS_BY_KEY, + _build_system, + _build_user, + _format_candidate, +) +from wolfbot.services.npc_registry import InMemoryNpcRegistry + + +def _noop_send(buf: list[str]) -> Callable[[str], Awaitable[None]]: + async def send(msg: str) -> None: + buf.append(msg) + return send + + +async def _seed_game_3llm(repo: SqliteRepo) -> tuple[Game, list[Seat]]: + g = Game( + id="sa1", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", + discord_user_id="u1", is_llm=False, persona_key=None), + Seat(seat_no=2, display_name="🌙 セツ", discord_user_id=None, + is_llm=True, persona_key="setsu"), + Seat(seat_no=3, display_name="🔴 ジナ", discord_user_id=None, + is_llm=True, persona_key="gina"), + Seat(seat_no=4, display_name="🟦 SQ", + discord_user_id=None, is_llm=True, persona_key="sq"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for s in seats: + await repo.set_player_role(g.id, s.seat_no, Role.VILLAGER) + return g, seats + + +# ---- Seat assignment logic (mirrors _on_reactive_phase_enter in main.py) ---- + +async def _run_assignment( + repo: SqliteRepo, + registry: InMemoryNpcRegistry, + game_id: str, + phase_id: str, +) -> None: + """Reproduce the assignment logic from main._on_reactive_phase_enter.""" + seats = await repo.load_seats(game_id) + llm_seats = [s for s in seats if s.is_llm] + online = registry.all_online() + assigned_npc_ids = { + e.npc_id for e in online + if e.assigned_seat is not None and e.game_id == game_id + } + unassigned_npcs = [e for e in online if e.npc_id not in assigned_npc_ids] + unassigned_seats = [ + s for s in llm_seats + if not any(e.assigned_seat == s.seat_no and e.game_id == game_id for e in online) + ] + for npc_entry, seat in zip(unassigned_npcs, unassigned_seats, strict=False): + registry.assign( + npc_entry.npc_id, + seat=seat.seat_no, + game_id=game_id, + phase_id=phase_id, + ) + + +async def test_assigns_online_npcs_to_llm_seats(repo: SqliteRepo) -> None: + game, _seats = await _seed_game_3llm(repo) + registry = InMemoryNpcRegistry() + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + + for i, npc_id in enumerate(["npc_a", "npc_b", "npc_c"]): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot{i}", + supported_voices=(), + version="1", + send=_noop_send([]), + now_ms=1000, + ) + + await _run_assignment(repo, registry, game.id, phase_id) + + online = registry.all_online() + assigned = { + e.npc_id: e.assigned_seat for e in online if e.assigned_seat is not None} + assert len(assigned) == 3 + assert set(assigned.values()) == {2, 3, 4} + + +async def test_idempotent_reenter_does_not_reassign(repo: SqliteRepo) -> None: + game, _ = await _seed_game_3llm(repo) + registry = InMemoryNpcRegistry() + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + + registry.register( + npc_id="npc_x", + discord_bot_user_id="botx", + supported_voices=(), + version="1", + send=_noop_send([]), + now_ms=1000, + ) + + await _run_assignment(repo, registry, game.id, phase_id) + entry = registry.get("npc_x") + assert entry is not None and entry.assigned_seat is not None + first_seat = entry.assigned_seat + + # Re-run: should NOT change the assignment. + await _run_assignment(repo, registry, game.id, phase_id) + assert entry.assigned_seat == first_seat + + +async def test_fewer_npcs_than_seats_partial_assignment(repo: SqliteRepo) -> None: + game, _ = await _seed_game_3llm(repo) + registry = InMemoryNpcRegistry() + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + + # Only 1 NPC for 3 LLM seats + registry.register( + npc_id="npc_only", + discord_bot_user_id="bot_only", + supported_voices=(), + version="1", + send=_noop_send([]), + now_ms=1000, + ) + + await _run_assignment(repo, registry, game.id, phase_id) + entry = registry.get("npc_only") + assert entry is not None and entry.assigned_seat is not None + # Only 1 assignment total + assigned_count = sum( + 1 for e in registry.all_online() if e.assigned_seat is not None + ) + assert assigned_count == 1 + + +async def test_more_npcs_than_seats_capped(repo: SqliteRepo) -> None: + game, _ = await _seed_game_3llm(repo) + registry = InMemoryNpcRegistry() + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + + # 5 NPCs for 3 LLM seats + for i in range(5): + registry.register( + npc_id=f"npc_{i}", + discord_bot_user_id=f"bot_{i}", + supported_voices=(), + version="1", + send=_noop_send([]), + now_ms=1000, + ) + + await _run_assignment(repo, registry, game.id, phase_id) + assigned_count = sum( + 1 for e in registry.all_online() if e.assigned_seat is not None + ) + assert assigned_count == 3 # capped at LLM seat count + + +# ---- GrokNpcGenerator prompt-building unit tests ---- + + +def test_build_system_prompt_contains_persona_fields() -> None: + persona = PERSONAS_BY_KEY["setsu"] + sys_msg = _build_system(persona, max_chars=80) + assert persona.display_name in sys_msg + assert persona.speech_profile.first_person in sys_msg + assert "80" in sys_msg + assert "日本語" in sys_msg + + +def test_build_user_prompt_includes_logic_candidates() -> None: + logic = LogicPacket( + ts=1, + trace_id="t", + packet_id="lp", + phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="alive=[1,2,3]", + logic_candidates=( + LogicCandidate( + id="c1", + claim="席2が占い師COした", + support=("初日白出し",), + counter=("対抗なし",), + ), + ), + pressure={"1": 0.3, "3": 0.7}, + expires_at_ms=9999, + ) + request = SpeakRequest( + ts=1, + trace_id="t", + request_id="sr1", + npc_id="npc_1", + phase_id="ph", + seat_no=2, + logic_packet_id="lp", + suggested_intent="speak", + max_chars=80, + expires_at_ms=5000, + ) + user_msg = _build_user(logic, request) + assert "占い師CO" in user_msg + assert "初日白出し" in user_msg + assert "対抗なし" in user_msg + assert "alive=[1,2,3]" in user_msg + assert "圧力マップ" in user_msg + + +def test_format_candidate_with_all_fields() -> None: + c = LogicCandidate( + id="c2", + claim="主張テスト", + support=("根拠A", "根拠B"), + counter=("反論X",), + ) + out = _format_candidate(c) + assert "[c2]" in out + assert "主張テスト" in out + assert "根拠A" in out + assert "反論X" in out + + +def test_build_user_prompt_no_candidates_no_pressure() -> None: + logic = LogicPacket( + ts=1, + trace_id="t", + packet_id="lp2", + phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="quiet", + logic_candidates=(), + pressure={}, + expires_at_ms=9999, + ) + request = SpeakRequest( + ts=1, + trace_id="t", + request_id="sr2", + npc_id="npc_1", + phase_id="ph", + seat_no=2, + logic_packet_id="lp2", + suggested_intent="question", + max_chars=80, + expires_at_ms=5000, + ) + user_msg = _build_user(logic, request) + assert "quiet" in user_msg + assert "論点候補" not in user_msg + assert "圧力マップ" not in user_msg diff --git a/uv.lock b/uv.lock index 699733a..50ff347 100644 --- a/uv.lock +++ b/uv.lock @@ -107,6 +107,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707 }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476 }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374 }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597 }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574 }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971 }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972 }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078 }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -116,6 +140,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "davey" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/b3/62a9af5f7e1cf3cb99f17dcf26dc53f753facca220721bb0d7daaffcf37e/davey-0.1.5.tar.gz", hash = "sha256:ac7b636edbd760602087176a1973110826ef350a7e0fd263f9affcd3d7dc9575", size = 62968 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/e2/8b43b24b536e81b7ce138c7bc7a84382e0fa6c0479725c81f8512674924e/davey-0.1.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6d5c4a76cd27deb1e55ae4bdad93ddcd7a78808c42cf6835b60b7391fbb7a0c8", size = 845507 }, + { url = "https://files.pythonhosted.org/packages/de/c2/2651a9b7343466425b62029b1b368cf7129d8dadc38ea00a438f02c917e3/davey-0.1.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0fee5e5f3691f7a25759ae30eedb67b7249bed67b44a7aefd9ee1a6266bb116b", size = 806630 }, + { url = "https://files.pythonhosted.org/packages/68/4b/761256951078212906130fd90f3d54b68e45ea1faa6e67fec66752321ebb/davey-0.1.5-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:aeba88a89489a0d30222b239633b0922932fa3c2a1784aadf9adc7abd28bc225", size = 944329 }, + { url = "https://files.pythonhosted.org/packages/6a/26/b6d544bb21d54dc25f7670834dc64dcedc169d02374bfeab4e355df4eeda/davey-0.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f5c8fcd4f32a7bedd14c925461668c83c231d77da22b9df428affc9b79d2f6e", size = 898174 }, + { url = "https://files.pythonhosted.org/packages/17/75/529d463f2d7008a63f504ba33cd499ac69e4fd92b5f1360cd12fde319ad4/davey-0.1.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7cf404c1538fd5f0701817cdde5232a991984945cbd7de2f93b8974ce615003", size = 827262 }, + { url = "https://files.pythonhosted.org/packages/56/44/bd950dad22f4376e9d12070ed959d071424391f0563f00e3b131688ef194/davey-0.1.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0bd2f057f91c574b355b338f69747af174c5b2b1ff59c9d0ae20d246bc79ac4", size = 939800 }, + { url = "https://files.pythonhosted.org/packages/a6/b0/8611986e28cd838b78ab25b9898aea98f3b1331dc35dfb183fdb6fe9a6d8/davey-0.1.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94f4c11308b060ebd66090580a074d50d3426fb73ebc12cdd1ab5fa7a8b8783e", size = 872439 }, + { url = "https://files.pythonhosted.org/packages/c8/8c/f6e3ad8e20ada0143344bf206b282b47a37bfa15b438e7f82f15c9b5aa21/davey-0.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72b7eeec80039882ad6296a613bc532e066b4f46eb1c244b0e775baf61d24617", size = 919366 }, + { url = "https://files.pythonhosted.org/packages/e0/cb/489a3be207597753323e5c81fae463f9557a4f77cba8722d1fef7dc7e9ee/davey-0.1.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82f67134702ccd49a8c9a868abe03539bd90586f497e723c0a1f93cc7d16dee7", size = 1078095 }, + { url = "https://files.pythonhosted.org/packages/b7/9a/00b4bf12aa96962edc601fd13b1a18333c196bb48a8fa8ee0b935f42ff95/davey-0.1.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fedf31a4a7d65f6f948ecbc68f20423788c63e5727342fdf721d3488d56d42ed", size = 1104365 }, + { url = "https://files.pythonhosted.org/packages/ac/d1/b2b096edebdf6e73a58d116ca7a3f55a07b4c8e16a24ce49fb36861f961e/davey-0.1.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b1656b669e56c8dc2669e241ceba2ee5b87da2e634f0fe8d3de3c11b5fcfd2ef", size = 1136421 }, + { url = "https://files.pythonhosted.org/packages/20/e8/d0b91af57a278de42b6f1070233b6c8a790ee5840fa9c0cfedeef2c67076/davey-0.1.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f861b3f1b54d68269c4cba4f6807a9d8a8da35494c7adde76791615097de8a9", size = 1129939 }, + { url = "https://files.pythonhosted.org/packages/56/c3/5024d376da05321c6495fea0c2d0e87be7789aef7dfbab16f33a5316077f/davey-0.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:16f49a630f5a0e477ce8ad0863b59a222888c031114707e6117e9154e756862b", size = 873550 }, + { url = "https://files.pythonhosted.org/packages/12/0b/6b1be03864b5e78691fe90aa28c5b17d372c1a5eb610a6e8fa6d50bb9bd0/davey-0.1.5-pp311-pypy311_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f8a36f56530a68cba199384a5f6e12ea2594e44febb93d5df7396a3e1167348d", size = 944476 }, + { url = "https://files.pythonhosted.org/packages/c2/fd/583e79fe45f7dd720297f76a9236d6bc39cf869aaba80012249291a3613f/davey-0.1.5-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87e8cf3d103aaebef9ce598c4c91df5a8d5e7444e5e310577efb33e133bd802a", size = 898675 }, + { url = "https://files.pythonhosted.org/packages/67/65/c4c28ba7a5767ca6bf042b291dd00b4482d7515f6121b498a8c2ce9ea4b7/davey-0.1.5-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:587be3289a9910941a59c9013f0e21aeb519b2a831a42f4fa5b213f835b48cd7", size = 827386 }, + { url = "https://files.pythonhosted.org/packages/23/f5/cacbe79c173664d978667fde18e330606fbdbd602aff71b7b9e1d0ddd2da/davey-0.1.5-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:144df754373523a282123e6678a7c03b2d334d00b46d6fdbfe5deda9f68e7a62", size = 940062 }, + { url = "https://files.pythonhosted.org/packages/55/75/c5186ba473d4aa95f76ad941903f04d7916397a6ecfc0772a15e6c5bf45b/davey-0.1.5-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d408ab155b6688efeb5a71a8bb387c63136e8735a2b7aa8c839269f6583412aa", size = 872388 }, + { url = "https://files.pythonhosted.org/packages/5a/f0/d98c0e88b6e8890ab10eede4afa7d196a25054f880230a9c16a3ad267e4d/davey-0.1.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69048265720d46fe029e9e9beaed498e56e47edcb80568b09114d594386708d1", size = 919448 }, + { url = "https://files.pythonhosted.org/packages/2e/bb/a18f6e2561f301d0fcdee945e3bc8360faa441c5b88d22425a56787508fc/davey-0.1.5-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:6d1e21798909545e28ee39f6a624bc218cd2b827cb387dd115582769041cde8f", size = 1078247 }, + { url = "https://files.pythonhosted.org/packages/20/27/1b8175f3d237cf2ad20ec2ddb01479fd60a96ac1a33795e788d2a6c9073d/davey-0.1.5-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:3a3f0519eabf50c3e27a45291dcff5d7e0bf3fbfcbc4ff9f4e58f9a5e7644fba", size = 1104272 }, + { url = "https://files.pythonhosted.org/packages/bc/2e/2c7cfdaef9a1b5647bd3b4f594d696ec5047d9a1fd4cccd971031c98acf9/davey-0.1.5-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:f306c09b44e65c4d97385ee5848bf4d5de1dc37ca82843311e4d223de59574bb", size = 1136585 }, + { url = "https://files.pythonhosted.org/packages/7b/4a/17b418f58bc6c12c1fdf3edb2ec0a9bde82d72e1f8e0ed263b0874ee9b77/davey-0.1.5-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:248246da8a3e7b4ac5d8269c91b58ece202d0de95a4aedf259033fccb211226a", size = 1130202 }, +] + +[[package]] +name = "discord-ext-voice-recv" +version = "0.5.2a179" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "discord-py", extra = ["voice"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/4e/f52f69b4854b817b50d5998f1f470e42f966af9ac47cd853736f6c501a3f/discord_ext_voice_recv-0.5.2a179.tar.gz", hash = "sha256:6cb8f5ff60c3885020e5ebd879323dc97ebb0745788f3778a14ef31810f848a8", size = 39805 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/65/d3a9d555cb2baaab6f8081d64d4d423688ba28ff7ae6608c374d06add3f6/discord_ext_voice_recv-0.5.2a179-py3-none-any.whl", hash = "sha256:f3fa65f2c1591bef2382aa39f3ebc25d6751b405aef5b5d113dfb452640f29fc", size = 41041 }, +] + [[package]] name = "discord-py" version = "2.7.1" @@ -128,6 +195,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/a7/17208c3b3f92319e7fad259f1c6d5a5baf8fd0654c54846ced329f83c3eb/discord_py-2.7.1-py3-none-any.whl", hash = "sha256:849dca2c63b171146f3a7f3f8acc04248098e9e6203412ce3cf2745f284f7439", size = 1227550 }, ] +[package.optional-dependencies] +voice = [ + { name = "davey" }, + { name = "pynacl" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -388,6 +461,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305 }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + [[package]] name = "pydantic" version = "2.13.3" @@ -460,6 +542,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, ] +[[package]] +name = "pynacl" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920 }, + { url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722 }, + { url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087 }, + { url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678 }, + { url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660 }, + { url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824 }, + { url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912 }, + { url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624 }, + { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141 }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -612,6 +714,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, + { name = "discord-ext-voice-recv" }, { name = "discord-py" }, { name = "httpx" }, { name = "openai" }, @@ -634,6 +737,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiosqlite", specifier = ">=0.20" }, + { name = "discord-ext-voice-recv", specifier = ">=0.5.2a179" }, { name = "discord-py", specifier = ">=2.7,<3" }, { name = "httpx", specifier = ">=0.27" }, { name = "openai", specifier = ">=1.50" }, From eaef5de10c6e04abb88578b2f156bfdd8873d7b7 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 13:50:05 +0900 Subject: [PATCH 003/133] refactor: split master/ + npc/ packages, rename LLM env vars by role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..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. --- .env.example | 61 -------- .env.master.example | 52 +++++++ .gitignore | 6 + AGENTS.md | 4 +- CLAUDE.md | 49 ++++-- README.md | 65 ++++++-- envs/npc/.env.chipie.example | 49 ++++++ envs/npc/.env.comet.example | 49 ++++++ envs/npc/.env.gina.example | 49 ++++++ envs/npc/.env.jonas.example | 49 ++++++ envs/npc/.env.kukrushka.example | 49 ++++++ envs/npc/.env.otome.example | 49 ++++++ envs/npc/.env.raqio.example | 49 ++++++ envs/npc/.env.remnan.example | 49 ++++++ envs/npc/.env.setsu.example | 49 ++++++ envs/npc/.env.sha_ming.example | 49 ++++++ envs/npc/.env.shigemichi.example | 49 ++++++ envs/npc/.env.sq.example | 49 ++++++ envs/npc/.env.stella.example | 49 ++++++ envs/npc/.env.yuriko.example | 49 ++++++ envs/npc/README.md | 69 +++++++++ pyproject.toml | 2 +- src/wolfbot/config.py | 47 ++++-- src/wolfbot/domain/ws_messages.py | 7 + src/wolfbot/llm/persona_base.py | 88 +++++++++++ src/wolfbot/llm/prompt_builder.py | 2 +- src/wolfbot/main.py | 48 +++--- src/wolfbot/master/__init__.py | 7 + .../{services => master}/audio_sink.py | 2 +- .../ingest_service.py} | 2 +- .../logic_service.py} | 0 .../{services => master}/npc_registry.py | 4 + src/wolfbot/master/personas.py | 108 +++++++++++++ .../{services => master}/speak_arbiter.py | 4 +- .../{services => master}/stt_service.py | 0 .../voice_ingest_client.py | 0 .../voice_ingest_service.py | 4 +- .../ws_server.py} | 3 +- src/wolfbot/npc/__init__.py | 9 ++ .../{services/npc_client.py => npc/client.py} | 14 +- src/wolfbot/npc/config.py | 64 ++++++++ src/wolfbot/{npc_bot_main.py => npc/main.py} | 142 ++++++++++-------- .../openai_compatible_generator.py} | 84 ++++++++--- src/wolfbot/{llm => npc}/personas.py | 66 ++------ .../playback.py} | 0 .../speech_service.py} | 0 .../{services/tts_service.py => npc/tts.py} | 0 src/wolfbot/services/discord_service.py | 84 ++++++++++- src/wolfbot/services/llm_service.py | 4 +- tests/test_llm_backfill.py | 5 +- tests/test_llm_prompt_builder.py | 3 +- tests/test_llm_structured_output.py | 7 +- tests/test_lobby_atomicity.py | 7 +- tests/test_master_ws_server.py | 33 ++-- tests/test_npc_seat_assignment.py | 28 ++-- tests/test_npc_voice_worker.py | 17 ++- tests/test_reactive_voice_master.py | 27 ++-- tests/test_reactive_voice_mode.py | 37 +++-- tests/test_state_machine_nights.py | 5 +- tests/test_voice_ingest_service.py | 6 +- 60 files changed, 1592 insertions(+), 370 deletions(-) delete mode 100644 .env.example create mode 100644 .env.master.example create mode 100644 envs/npc/.env.chipie.example create mode 100644 envs/npc/.env.comet.example create mode 100644 envs/npc/.env.gina.example create mode 100644 envs/npc/.env.jonas.example create mode 100644 envs/npc/.env.kukrushka.example create mode 100644 envs/npc/.env.otome.example create mode 100644 envs/npc/.env.raqio.example create mode 100644 envs/npc/.env.remnan.example create mode 100644 envs/npc/.env.setsu.example create mode 100644 envs/npc/.env.sha_ming.example create mode 100644 envs/npc/.env.shigemichi.example create mode 100644 envs/npc/.env.sq.example create mode 100644 envs/npc/.env.stella.example create mode 100644 envs/npc/.env.yuriko.example create mode 100644 envs/npc/README.md create mode 100644 src/wolfbot/llm/persona_base.py create mode 100644 src/wolfbot/master/__init__.py rename src/wolfbot/{services => master}/audio_sink.py (96%) rename src/wolfbot/{services/master_ingest_service.py => master/ingest_service.py} (98%) rename src/wolfbot/{services/master_logic_service.py => master/logic_service.py} (100%) rename src/wolfbot/{services => master}/npc_registry.py (98%) create mode 100644 src/wolfbot/master/personas.py rename src/wolfbot/{services => master}/speak_arbiter.py (99%) rename src/wolfbot/{services => master}/stt_service.py (100%) rename src/wolfbot/{services => master}/voice_ingest_client.py (100%) rename src/wolfbot/{services => master}/voice_ingest_service.py (99%) rename src/wolfbot/{services/master_ws_server.py => master/ws_server.py} (99%) create mode 100644 src/wolfbot/npc/__init__.py rename src/wolfbot/{services/npc_client.py => npc/client.py} (97%) create mode 100644 src/wolfbot/npc/config.py rename src/wolfbot/{npc_bot_main.py => npc/main.py} (53%) rename src/wolfbot/{services/npc_generator_grok.py => npc/openai_compatible_generator.py} (64%) rename src/wolfbot/{llm => npc}/personas.py (83%) rename src/wolfbot/{services/voice_playback_service.py => npc/playback.py} (100%) rename src/wolfbot/{services/npc_speech_service.py => npc/speech_service.py} (100%) rename src/wolfbot/{services/tts_service.py => npc/tts.py} (100%) diff --git a/.env.example b/.env.example deleted file mode 100644 index f4eaaa6..0000000 --- a/.env.example +++ /dev/null @@ -1,61 +0,0 @@ -# ── Master bot (必須) ────────────────────────────────── -# Discord Developer Portal で発行した bot トークン -DISCORD_TOKEN= -# xAI API キー — LLM プレイヤー(NPC)の思考・発言生成に使用 -XAI_API_KEY= -# xAI のモデル名 — NPC の投票判断・夜行動・議論発言の生成に使用 -XAI_MODEL=grok-4-1-fast -# bot を動かす Discord サーバー(guild)の数値 ID -DISCORD_GUILD_ID= -# 議論用メイン text チャンネルの数値 ID -MAIN_TEXT_CHANNEL_ID= -# プレイヤーが会話するメイン VC の数値 ID -MAIN_VOICE_CHANNEL_ID= -# SQLite データベースの保存先パス -WOLFBOT_DB_PATH=./wolfbot.db -# ログ出力レベル (DEBUG / INFO / WARNING / ERROR) -LOG_LEVEL=INFO - -# ── リアルタイム音声モード (reactive_voice 使用時のみ) ── -# LLM 議論モード: rounds=ターン制 / reactive_voice=リアルタイム音声 -LLM_DISCUSSION_MODE=reactive_voice -# ── NPC bot / voice-ingest 間 WebSocket ── -# Master ↔ NPC bot 間 WebSocket の listen アドレス -MASTER_WS_LISTEN=127.0.0.1:8800 -# NPC bot の WebSocket 認証用 Pre-Shared Key (任意) -MASTER_NPC_PSK= - -# ── 音声認識 + 発話解析 (Master 統合) ────────────────── -# Master bot が VC に参加し、人間の音声を直接受信・解析する。 -# Gemini API キー — 音声を書き起こし+構造化解析するために使用 -GEMINI_API_KEY= -# Gemini のモデル名 — 音声→書き起こし+要約+CO検出+投票先抽出を1回のAPIコールで実行 -GEMINI_MODEL=gemini-2.0-flash-lite - -# ── NPC bot (NPC 1体ごとに別プロセス・別 .env で起動) ── -# Discord は 1 bot あたり 1 VC 接続/guild なので、NPC の数だけ -# 別の bot アカウントが必要。以下の変数を NPC ごとに変えて起動する。 -# 起動コマンド: NPC_ID=npc_1 NPC_DISCORD_TOKEN=... uv run wolfbot-npc -# -# NPC の一意 ID (Master WS 上の識別子。npc_1, npc_2, ... など) -# NPC_ID=npc_1 -# NPC 用 Discord bot トークン (NPC ごとに別の bot アプリケーションが必要) -# NPC_DISCORD_TOKEN= -# Master WS への接続先 URL -# MASTER_WS_URL=ws://127.0.0.1:8800 -# Master WS 認証用 PSK (Master 側の MASTER_NPC_PSK と同じ値) -# MASTER_NPC_PSK= -# xAI API キー (NPC の発言生成に使用。Master と同じキーでよい) -# XAI_API_KEY= -# xAI モデル名 -# XAI_MODEL=grok-4-1-fast -# VC 参加先 (Master 側と同じ値) -# MAIN_VOICE_CHANNEL_ID= -# Discord サーバー ID (Master 側と同じ値) -# DISCORD_GUILD_ID= -# VOICEVOX のスピーカー ID (NPC ごとに声を変える) -# TTS_VOICE_ID=3 -# VOICEVOX エンジンの URL -# VOICEVOX_URL=http://localhost:50021 -# ハートビート送信間隔(秒) -# HEARTBEAT_INTERVAL_S=5 diff --git a/.env.master.example b/.env.master.example new file mode 100644 index 0000000..b7ee38b --- /dev/null +++ b/.env.master.example @@ -0,0 +1,52 @@ +# ── Master bot (.env.master) ─────────────────────────── +# Used by `uv run wolfbot`. Loaded from `.env.master` via +# wolfbot.config.MasterSettings. + +# Discord Developer Portal で発行した bot トークン (Master 用) +DISCORD_TOKEN= +# bot を動かす Discord サーバー(guild)の数値 ID +DISCORD_GUILD_ID= +# 議論用メイン text チャンネルの数値 ID +MAIN_TEXT_CHANNEL_ID= +# プレイヤーが会話するメイン VC の数値 ID +MAIN_VOICE_CHANNEL_ID= +# SQLite データベースの保存先パス +WOLFBOT_DB_PATH=./wolfbot.db +# ログ出力レベル (DEBUG / INFO / WARNING / ERROR) +LOG_LEVEL=INFO + +# ── 議論モード ───────────────────────────────────────── +# rounds : Master が全 LLM 席のターン制議論文も生成する +# reactive_voice : 議論文は NPC bot プロセスが VC で発話する +# (Master は投票・夜行動の判断のみ生成) +LLM_DISCUSSION_MODE=reactive_voice + +# ── Master ↔ NPC bot / voice-ingest WebSocket ──────── +# Master ↔ NPC bot 間 WebSocket の listen アドレス +MASTER_WS_LISTEN=127.0.0.1:8800 +# NPC bot の WebSocket 認証用 Pre-Shared Key (NPC 側 .env.npc.* と同値) +MASTER_NPC_PSK= + +# ── Gameplay LLM (Master が"判断する"側) ─────────────── +# Master が LLM 席の挙動を決めるときに使う LLM。担当範囲: +# • 投票判断 (誰を処刑するか) ────────────── 全モード +# • 夜行動 (人狼の襲撃先 / 占い / 護衛) ──── 全モード +# • 議論ターンの発言文生成 ──────────────── rounds モードのみ +# (reactive_voice モードでは議論文は NPC bot 側に移譲され、Master は +# この LLM で投票と夜行動だけを生成する) +# +# OpenAI Chat Completions 互換のエンドポイントなら何でも可 +# (xAI Grok / OpenAI / Groq / Together / vLLM / Ollama / ...)。 +# NPC bot 側 NPC_LLM_API_KEY と同じキーを共有してもよいし、 +# 別プロバイダ・別モデルにしてもよい (役割が独立しているため)。 +GAMEPLAY_LLM_API_KEY= +GAMEPLAY_LLM_MODEL=grok-4-1-fast + +# ── Voice LLM (Master が人間の音声を"聞く"側) ────────── +# Master が VC に参加して人間の音声を直接受信したあと、 +# 書き起こし + 要約 + CO 検出 + 投票先抽出 を 1 API コールで実行する +# multimodal LLM。既定値は Google Gemini Flash 系 (audio input 対応)。 +# reactive_voice モードでのみ使用。空のままなら voice-ingest は +# 起動せず、Master は VC に参加しない。 +VOICE_LLM_API_KEY= +VOICE_LLM_MODEL=gemini-2.0-flash-lite diff --git a/.gitignore b/.gitignore index b45b87b..07183c0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,12 @@ __pycache__/ dist/ build/ .env +.env.master +# NPC bot env files live under envs/npc/. The committed `.env.*.example` +# templates are explicitly un-ignored; the corresponding real `.env.*` +# files (with secrets filled in) stay out of git. +envs/npc/.env.* +!envs/npc/.env.*.example *.db *.db-journal *.db-wal diff --git a/AGENTS.md b/AGENTS.md index f9eea5f..d8776ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ # Repository Guidelines ## Project Structure & Module Organization -Runtime code lives in `src/wolfbot`. Keep pure game logic in `domain/`, orchestration in `services/`, SQLite access in `persistence/`, LLM personas and prompts in `llm/`, and Discord UI views in `ui/`. Tests live in `tests/`, with shared fixtures in `tests/conftest.py` and fakes in `tests/fakes.py`. Use `.env.example` for local settings, and read `prompts/IMPLEMENTATION_PROMPT.md` before changing roles, phases, or event ordering. +Runtime code lives in `src/wolfbot`. Keep pure game logic in `domain/`, orchestration in `services/`, SQLite access in `persistence/`, shared persona types in `llm/persona_base.py` (player personas in `npc/personas.py`, GM personas in `master/personas.py`), and Discord UI views in `ui/`. The Master-only reactive-voice pipeline lives in `master/`; the per-process NPC bot worker lives in `npc/` with its own `NpcSettings`. Each NPC bot process is bound to one persona at startup (`NPC_PERSONA_KEY`); Master's `/wolf start` fills LLM seats from the online NPC registry in reactive_voice mode. Tests live in `tests/`, with shared fixtures in `tests/conftest.py` and fakes in `tests/fakes.py`. Use `.env.master.example` at the repo root for the Master env, and the per-persona templates in `envs/npc/.env..example` (see [envs/npc/README.md](envs/npc/README.md)) for each NPC process. Read `prompts/IMPLEMENTATION_PROMPT.md` before changing roles, phases, or event ordering. ## Build, Test, and Development Commands -Use `uv` for all local work. `uv sync` installs runtime and dev dependencies. `uv run wolfbot` starts the bot with values from `.env`. `uv run pytest tests` runs the full suite; `uv run pytest tests/test_rules_votes.py` runs one module. `uv run ruff check src tests` runs lint and import checks, `uv run ruff format src tests` formats code, and `uv run mypy` runs strict type checking. +Use `uv` for all local work. `uv sync` installs runtime and dev dependencies. `uv run wolfbot` starts the Master bot with values from `.env.master`. NPC workers are launched per persona: `WOLFBOT_NPC_ENV=envs/npc/.env. uv run wolfbot-npc`. `uv run pytest tests` runs the full suite; `uv run pytest tests/test_rules_votes.py` runs one module. `uv run ruff check src tests` runs lint and import checks, `uv run ruff format src tests` formats code, and `uv run mypy` runs strict type checking. ## Coding Style & Naming Conventions Target Python 3.11 and keep 4-space indentation. Follow Ruff defaults with a 100-character line length; let `ruff format` handle wrapping instead of manual alignment. New code should be fully type-annotated because `mypy` runs in strict mode. Use `snake_case` for modules, functions, and test files such as `test_recovery.py`; use `PascalCase` for classes and Pydantic models. diff --git a/CLAUDE.md b/CLAUDE.md index 37f00d1..94a6924 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,21 +33,37 @@ uv run mypy # strict typecheck (pac ## Required environment variables -From `.env.example`. All must be set for the bot to start: +Two env files, one per process: -- `DISCORD_TOKEN` — bot token (SecretStr) -- `XAI_API_KEY` — xAI API key (SecretStr) -- `XAI_MODEL` — model name (default `grok-4-1-fast`) +**Master** (`.env.master`, see `.env.master.example`) — loaded by `src/wolfbot/config.py::MasterSettings`, instantiated once in `main.py`: + +- `DISCORD_TOKEN` — Master bot token (SecretStr) - `DISCORD_GUILD_ID`, `MAIN_TEXT_CHANNEL_ID`, `MAIN_VOICE_CHANNEL_ID` — ints - `WOLFBOT_DB_PATH` — SQLite path (default `./wolfbot.db`) - `LOG_LEVEL` — default `INFO` - `LLM_DISCUSSION_MODE` — `rounds` (default) or `reactive_voice` - `MASTER_WS_LISTEN` — Master WS bind address (default `127.0.0.1:8800`) - `MASTER_NPC_PSK` — optional PSK for NPC/voice-ingest WS auth (SecretStr) -- `GEMINI_API_KEY` — optional Gemini API key for voice-ingest audio analysis (SecretStr) -- `GEMINI_MODEL` — Gemini model name (default `gemini-2.0-flash-lite`) +- `GAMEPLAY_LLM_API_KEY` — Gameplay LLM API key. The LLM Master uses to make every gameplay decision on behalf of LLM seats: votes (always), night actions (always — wolf attack / divine / guard), and day-discussion text **in rounds mode** (in reactive_voice mode the discussion text is offloaded to NPC bots, but votes / night actions still go through this LLM). Any OpenAI Chat Completions–compatible endpoint works; default targets xAI Grok. (SecretStr) +- `GAMEPLAY_LLM_MODEL` — Gameplay LLM model name (default `grok-4-1-fast`) +- `VOICE_LLM_API_KEY` — optional Voice LLM API key (the multimodal LLM that *understands human voice* — transcription + summary + CO detection in one call). Default targets Google Gemini. Required when `MASTER_NPC_PSK` is set and you want voice-ingest active. (SecretStr) +- `VOICE_LLM_MODEL` — Voice LLM model name (default `gemini-2.0-flash-lite`) + +**NPC bot** (`envs/npc/.env.`, one file per persona — see committed `envs/npc/.env..example` templates plus [envs/npc/README.md](envs/npc/README.md)) — loaded by `src/wolfbot/npc/config.py::NpcSettings`, instantiated once per worker in `wolfbot.npc.main`. **Each NPC bot process is bound to exactly one persona at startup** (`NPC_PERSONA_KEY`); NPC bots are not interchangeable. Required fields: + +- `NPC_ID` — unique NPC identifier on the WS (e.g. `npc_setsu`) +- `NPC_DISCORD_TOKEN` — this persona's Discord bot token (each persona has its own bot app, manually invited to the guild once) +- `NPC_PERSONA_KEY` — must be a key from `wolfbot.npc.personas.NPC_PERSONAS_BY_KEY` (`setsu`, `gina`, `sq`, `raqio`, `stella`, `shigemichi`, `chipie`, `comet`, `jonas`, `kukrushka`, `otome`, `sha_ming`, `remnan`, `yuriko`) +- `MASTER_WS_URL` — e.g. `ws://127.0.0.1:8800` +- `MASTER_NPC_PSK` — must match Master's value (SecretStr) +- `NPC_LLM_API_KEY`, `NPC_LLM_MODEL`, `NPC_LLM_BASE_URL` — NPC LLM backend. Used **only** to generate this NPC bot's short reactive utterances during DAY_DISCUSSION (in reactive_voice mode). It does NOT decide votes or night actions — Master's `GAMEPLAY_LLM_*` handles those. Any OpenAI Chat Completions–compatible endpoint works; the same credential may be reused across personas, and may also be shared with Master's `GAMEPLAY_LLM_API_KEY`, but the two are split so each role can target a different model / provider +- `TTS_VOICE_ID`, `VOICEVOX_URL` — VOICEVOX speaker / engine +- `MAIN_VOICE_CHANNEL_ID`, `DISCORD_GUILD_ID` — must match Master +- `HEARTBEAT_INTERVAL_S`, `LOG_LEVEL` -Loaded at boot by `src/wolfbot/config.py::Settings` (pydantic-settings, reads `.env`, instantiated once in `main.py`). Adding a new env var = adding a typed field to `Settings` — do not parse `os.environ` directly from code paths. +Each NPC process picks its env file via the `WOLFBOT_NPC_ENV` env var (default: `.env.npc` in CWD, but with the `envs/npc/` layout you should always set this explicitly). Typical multi-persona deployment: `WOLFBOT_NPC_ENV=envs/npc/.env.setsu uv run wolfbot-npc` etc. + +Adding a new env var = adding a typed field to the appropriate `*Settings` class — do not parse `os.environ` directly from code paths. ## Architecture @@ -58,15 +74,24 @@ Outer layers depend on inner; never the reverse. ``` domain/ pure: enums, frozen models, rules, state_machine — no I/O, no asyncio ↑ -services/ orchestration: GameService, GameEngine, RecoveryService, PermissionManager +services/ orchestration: GameService, GameEngine, RecoveryService, PermissionManager, + LLMAdapter, DiscussionService, DiscordBotAdapter, ... ↑ persistence/ SqliteRepo (aiosqlite) llm/ personas + prompt builder prompts/ system prompt markdown (`llm_system_prompt.md`), read at runtime by prompt_builder ui/ discord.ui Views (DM vote/action selects) -main.py wiring +master/ Master-side reactive_voice pipeline (loaded only when MASTER_NPC_PSK is set): + ws_server, ingest_service, logic_service, speak_arbiter, npc_registry, + voice_ingest_service, voice_ingest_client, audio_sink, stt_service. +npc/ NPC bot worker (separate process via `uv run wolfbot-npc`): + main, config (NpcSettings), client, speech_service, + openai_compatible_generator, tts, playback. +main.py Master wiring (entrypoint `wolfbot`) ``` +`master/` and `npc/` are intentionally split because the two processes have disjoint runtime concerns: `master/` runs alongside the core `services/` in the Master Discord bot, while `npc/` is one-process-per-NPC and only talks to Master over WS. Anything imported by both lives in `domain/` (e.g. `domain/ws_messages.py`). + Within `services/`: `discord_service.py` contains both `DiscordBotAdapter` and the `WolfCog` slash-command dispatcher — new slash commands go in the cog, new Discord side-effects in the adapter. `submission_snapshot.py` is the shared pending-submission calculator, reused by `GameService` (early-wake checks) and `RecoveryService` (DM restoration on restart); reuse it rather than re-implement the "who still owes a submission" logic. ### The advance loop is the heart of the system @@ -147,9 +172,11 @@ During wolf-attack splits, the main channel announces only `未確定: N件` (hi When `LLM_DISCUSSION_MODE=reactive_voice` and `MASTER_NPC_PSK` is set, `main.py` wires a realtime voice pipeline: - **Master side** (`main.py`): `InMemoryNpcRegistry` + `SpeakArbiter` + `MasterIngestService` + `WebsocketsMasterWsServer`. The Master joins VC via `voice_recv.VoiceRecvClient` + `WolfbotAudioSink` for STT ingest, receives human speech via `GeminiAudioAnalyzer`, and dispatches NPC turns via the arbiter. On phase enter, `_on_reactive_phase_enter` assigns online NPCs to LLM seats. -- **NPC side** (`npc_bot_main.py`, one process per NPC bot): `discord.Client` joins VC → WebSocket to Master (PSK auth) → `NpcClient` handles `SpeakRequest` → `GrokNpcGenerator` (xAI Grok, structured JSON) → `NpcSpeechService` → `VoicevoxTtsService` (local VOICEVOX, 24kHz WAV) → `DiscordVoicePlayback` (`discord.FFmpegPCMAudio`). +- **NPC side** (`wolfbot.npc.main`, one process per NPC bot, **bound to one persona at startup**): `discord.Client` joins VC → WebSocket to Master (PSK auth, register payload carries `persona_key`) → `NpcClient` handles `SpeakRequest` → `OpenAICompatibleNpcGenerator` (NPC LLM call, structured JSON; default targets xAI Grok) renders speech in the bound persona → `NpcSpeechService` → `VoicevoxTtsService` (local VOICEVOX, 24kHz WAV) → `DiscordVoicePlayback` (`discord.FFmpegPCMAudio`). All NPC-side modules live under `wolfbot.npc.*`; the Master-side counterpart (`InMemoryNpcRegistry`, `SpeakArbiter`, `MasterIngestService`, `WebsocketsMasterWsServer`) lives under `wolfbot.master.*`. The NPC LLM backend is swappable via `NPC_LLM_BASE_URL` + `NPC_LLM_MODEL` in the persona's env file — no code change needed to point at OpenAI / Groq / Together / vLLM / Ollama / etc. + +- **Persona binding model**: each NPC bot embodies one persona forever (declared via `NPC_PERSONA_KEY` in its env file, validated against `NPC_PERSONAS_BY_KEY` at startup). On `npc_register` the NPC bot tells Master its `persona_key`; Master stores it on `NpcEntry`. When `/wolf start` runs in `reactive_voice` mode, `WolfCog._select_llm_seat_personas` pulls online NPC bots from the registry (skipping any already assigned to another active game) and uses their personas as the LLM seats — there is no separate "Master rolls personas" step. If fewer NPC bots are online than the LLM-seat shortfall, `/wolf start` fails with a friendly message. In `rounds` mode (no NPC bot processes), the same path falls through to a random `pick_personas(NPC_PERSONAS, ...)` draw because Master drives those LLM seats internally without VC playback. - **Seat assignment**: `_on_reactive_phase_enter` in `main.py` pairs online NPC bots with unassigned LLM seats via `npc_registry.assign()`. The arbiter only dispatches to NPCs with an assigned seat. -- **Entry point**: `uv run wolfbot-npc` (each NPC needs its own Discord bot token, `NPC_ID`, and env vars — see `.env.example`). +- **Entry point**: `WOLFBOT_NPC_ENV=envs/npc/.env. uv run wolfbot-npc` (each persona has its own committed `envs/npc/.env..example` template; copy it to `envs/npc/.env.`, fill in the secrets — `NPC_DISCORD_TOKEN`, `MASTER_NPC_PSK`, `NPC_LLM_API_KEY`, `DISCORD_GUILD_ID`, `MAIN_VOICE_CHANNEL_ID` — and start one process per persona). See [envs/npc/README.md](envs/npc/README.md) for the persona / TTS_VOICE_ID table. The system prompt is **composed per actor** by `src/wolfbot/llm/prompt_builder.py`, not loaded verbatim. Three programmatically-generated blocks are layered onto `src/wolfbot/prompts/llm_system_prompt.md`: `_build_game_rules_block()` (9-player ruleset derived from `ROLE_DISTRIBUTION` + `VILLAGE_SIZE` so the canonical numbers aren't duplicated, plus the shared reasoning heuristics every seat sees — currently CO evaluation: a **never-countered** single role-CO is presumed near-real, but a **sole survivor** of a contested CO history (same role had ≥2 COs, others died) is **not** auto-trusted; topical mention of a CO ("the seer CO 〜について") is distinguished from self-declaration; counter-COs and divination/attack alignment feed the judgment), `_ROLE_STRATEGIES[role]` (role-scoped tactical hints — wolf/madman carry day-phased fake-CO playbooks that deliberately mirror each other, knight carries peaceful-morning guard-CO guidance, seer/medium/villager carry judgment-integrity rules (villager strategy explicitly forbids 「村人CO」/「素村CO」 and equivalents); cross-leak tests assert one role never sees another's strategy), and `_build_speech_profile_block(persona)` (the persona's 話法 section). Routing when editing: shared heuristics every seat should see → `_build_game_rules_block`; role-specific strategy → `_ROLE_STRATEGIES`; base framing / output format / hard invariants → the markdown template. The markdown is a template, not the whole prompt. diff --git a/README.md b/README.md index 568fa83..fde36dd 100644 --- a/README.md +++ b/README.md @@ -29,20 +29,36 @@ uv sync ``` -### 2. `.env` を用意する +### 2. `.env.master` を用意する -`.env.example` をコピーして `.env` を作成し、各値を設定します。 +`.env.master.example` をコピーして `.env.master` を作成し、Master bot 用の値を設定します。 ```bash -cp .env.example .env +cp .env.master.example .env.master ``` -最初は次の内容を埋めれば起動に必要な最低限の設定が揃います。 +reactive_voice モードで NPC bot も動かす場合は、**1 ペルソナ = 1 プロセス = 1 Discord bot アカウント** で別途用意します。リポジトリには 14 体ぶんのテンプレが [envs/npc/](envs/npc/) 配下に `.env..example` の形で入っているので、必要なペルソナぶんコピーして秘密値を埋めます。 + +```bash +cp envs/npc/.env.setsu.example envs/npc/.env.setsu +# envs/npc/.env.setsu の NPC_DISCORD_TOKEN / MASTER_NPC_PSK / +# NPC_LLM_API_KEY / DISCORD_GUILD_ID / MAIN_VOICE_CHANNEL_ID を埋める + +WOLFBOT_NPC_ENV=envs/npc/.env.setsu uv run wolfbot-npc & +WOLFBOT_NPC_ENV=envs/npc/.env.gina uv run wolfbot-npc & +# 必要なペルソナぶん起動 +``` + +各 NPC bot のセットアップ詳細・ペルソナ一覧・TTS_VOICE_ID の既定値は [envs/npc/README.md](envs/npc/README.md) を参照してください。 + +各 NPC bot は固有のペルソナ (`NPC_PERSONA_KEY`) を起動時に確定します。Master の `/wolf start` (reactive_voice モード) は、online な NPC bot の中から不足席数だけ選び、その bot の persona を席に割り当てます。online な bot が足りないときは開始時にエラー表示します (rounds モードに切り替えるか、bot を増やすことで解決)。 + +最初は `.env.master` に次の内容を埋めれば、Master 単体で起動に必要な最低限の設定が揃います。 ```env DISCORD_TOKEN=your_discord_bot_token -XAI_API_KEY=your_xai_api_key -XAI_MODEL=grok-4-1-fast +GAMEPLAY_LLM_API_KEY=your_gameplay_llm_api_key +GAMEPLAY_LLM_MODEL=grok-4-1-fast DISCORD_GUILD_ID=123456789012345678 MAIN_TEXT_CHANNEL_ID=123456789012345678 MAIN_VOICE_CHANNEL_ID=123456789012345678 @@ -51,11 +67,11 @@ LOG_LEVEL=INFO ``` - `DISCORD_TOKEN`: Discord Developer Portal で作成した bot のトークンを入れます。 -- `XAI_API_KEY`: xAI の API キーを入れます。 +- `GAMEPLAY_LLM_API_KEY`: Gameplay LLM (Master が投票・夜行動・rounds-mode 議論文を判断する LLM) の API キーを入れます。OpenAI Chat Completions 互換のプロバイダなら何でも可。 - `DISCORD_GUILD_ID`: bot を動かす Discord サーバーの ID を入れます。 - `MAIN_TEXT_CHANNEL_ID`: 議論用に使うメイン text チャンネルの ID を入れます。 - `MAIN_VOICE_CHANNEL_ID`: プレイヤーが会話するメイン VC の ID を入れます。 -- `XAI_MODEL`、`WOLFBOT_DB_PATH`、`LOG_LEVEL` は最初は既定値のままで構いません。 +- `GAMEPLAY_LLM_MODEL`、`WOLFBOT_DB_PATH`、`LOG_LEVEL` は最初は既定値のままで構いません。 `DISCORD_GUILD_ID`、`MAIN_TEXT_CHANNEL_ID`、`MAIN_VOICE_CHANNEL_ID` にはチャンネル名や `#channel` のようなメンション文字列ではなく、数値の ID を設定してください。どの guild やチャンネルを使うか未定なら、先に手順 3 を済ませてから `.env` を埋めてください。 @@ -102,11 +118,11 @@ uv run wolfbot ## 環境変数一覧 +### Master (`.env.master`, `wolfbot.config.MasterSettings`) + | 変数名 | 必須 / 既定値 | 用途 | | --- | --- | --- | -| `DISCORD_TOKEN` | 必須 | Discord bot のトークン | -| `XAI_API_KEY` | 必須 | xAI API キー | -| `XAI_MODEL` | 既定値: `grok-4-1-fast` | 使用する xAI モデル名 | +| `DISCORD_TOKEN` | 必須 | Master bot のトークン | | `DISCORD_GUILD_ID` | 必須 | `/wolf` コマンドを同期する guild の ID | | `MAIN_TEXT_CHANNEL_ID` | 必須 | 議論用に使う既存のメイン text チャンネル ID | | `MAIN_VOICE_CHANNEL_ID` | 必須 | 参加者が会話する既存のメイン VC の ID | @@ -115,8 +131,31 @@ uv run wolfbot | `LLM_DISCUSSION_MODE` | 既定値: `rounds` | LLM 議論モード (`rounds` / `reactive_voice`) | | `MASTER_WS_LISTEN` | 既定値: `127.0.0.1:8800` | Master ↔ NPC/voice-ingest WS の listen アドレス | | `MASTER_NPC_PSK` | 任意 | NPC bot / voice-ingest の WS 認証用 Pre-Shared Key | -| `GEMINI_API_KEY` | 任意 | Gemini API キー (voice-ingest の音声解析用) | -| `GEMINI_MODEL` | 既定値: `gemini-2.0-flash-lite` | voice-ingest で使用する Gemini モデル名 | +| `GAMEPLAY_LLM_API_KEY` | 必須 | **Gameplay LLM** — Master が LLM 席の挙動を判断するときに使う LLM。投票判断・夜行動 (襲撃/占い/護衛) は全モードで使用、議論ターン文の生成は rounds モードのみ (reactive_voice モードでは NPC bot が代わりに発話する)。OpenAI Chat Completions 互換のプロバイダなら何でも可 | +| `GAMEPLAY_LLM_MODEL` | 既定値: `grok-4-1-fast` | Gameplay LLM のモデル名 | +| `VOICE_LLM_API_KEY` | 任意 | **Voice LLM** — Master が VC で人間音声を聞いて書き起こし+構造化解析する multimodal LLM。reactive_voice モードでのみ使用 | +| `VOICE_LLM_MODEL` | 既定値: `gemini-2.0-flash-lite` | Voice LLM のモデル名 (multimodal audio input 対応) | + +### NPC bot (`envs/npc/.env.`, `wolfbot.npc.config.NpcSettings`) + +各 NPC bot は **固有 persona に紐付いた 1 プロセス**。[envs/npc/](envs/npc/) 配下の `.env..example` をコピーして使います。プロセス起動時に `WOLFBOT_NPC_ENV` でファイルパスを指定 (例: `WOLFBOT_NPC_ENV=envs/npc/.env.setsu`)。 + +| 変数名 | 必須 / 既定値 | 用途 | +| --- | --- | --- | +| `NPC_ID` | 必須 (テンプレで `npc_` 既定) | NPC の一意 ID (Master WS 上の識別子) | +| `NPC_DISCORD_TOKEN` | 必須 | この persona 専用の Discord bot トークン | +| `NPC_PERSONA_KEY` | 必須 (テンプレで指定済) | Persona キー (`setsu` / `gina` / `sq` / `raqio` / `stella` / `shigemichi` / `chipie` / `comet` / `jonas` / `kukrushka` / `otome` / `sha_ming` / `remnan` / `yuriko`) | +| `DISCORD_GUILD_ID` | 必須 | Master と同じ guild ID | +| `MAIN_VOICE_CHANNEL_ID` | 必須 | Master と同じ VC の ID | +| `MASTER_WS_URL` | 既定値: `ws://127.0.0.1:8800` | Master WS への接続先 URL | +| `MASTER_NPC_PSK` | 必須 | Master の `MASTER_NPC_PSK` と同値 | +| `NPC_LLM_API_KEY` | 必須 | **NPC LLM** — この NPC bot が VC で 1 行発言を生成するときに使う LLM。投票・夜行動の判断はしない (Master の Gameplay LLM が担当)。Master の `GAMEPLAY_LLM_API_KEY` と共用しても、別プロバイダのキーでもよい | +| `NPC_LLM_MODEL` | 既定値: `grok-4-1-fast` | NPC LLM のモデル名 (プロバイダに合わせて変える) | +| `NPC_LLM_BASE_URL` | 既定値: `https://api.x.ai/v1` | OpenAI Chat Completions 互換エンドポイント。プロバイダ切り替えはここを変える | +| `TTS_VOICE_ID` | テンプレで persona 別に既定値設定 | VOICEVOX のスピーカー ID。好みの声に変えたい場合のみ編集 | +| `VOICEVOX_URL` | 既定値: `http://localhost:50021` | VOICEVOX エンジンの URL | +| `HEARTBEAT_INTERVAL_S` | 既定値: `5` | ハートビート送信間隔(秒) | +| `LOG_LEVEL` | 既定値: `INFO` | ログ出力レベル | ## Discord 側の準備 diff --git a/envs/npc/.env.chipie.example b/envs/npc/.env.chipie.example new file mode 100644 index 0000000..322829a --- /dev/null +++ b/envs/npc/.env.chipie.example @@ -0,0 +1,49 @@ +# ── NPC bot · 🐈‍⬛シピ (envs/npc/.env.chipie.example) ─ +# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.chipie` +# にコピーし、`要記入` の欄を埋めてから: +# +# WOLFBOT_NPC_ENV=envs/npc/.env.chipie uv run wolfbot-npc +# +# Persona: 柔らかく観察力がある。対立をなだめつつ疑問を出す。 + +# ── 必須 (要記入) ────────────────────────────────────── +# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) +NPC_ID=npc_chipie +# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) +NPC_DISCORD_TOKEN= +# Master 側 .env.master と同値 +DISCORD_GUILD_ID= +MAIN_VOICE_CHANNEL_ID= +# Master の MASTER_NPC_PSK と同値 +MASTER_NPC_PSK= +# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 +# 別プロバイダ用に独立したキーにしてもよい) +NPC_LLM_API_KEY= + +# ── ペルソナ固定 (変更不要) ──────────────────────────── +# Master が `npc_register` で受け取り、このペルソナだけで席を埋める +NPC_PERSONA_KEY=chipie + +# ── デフォルトでよい (必要なら上書き) ────────────────── +MASTER_WS_URL=ws://127.0.0.1:8800 + +# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 +# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は +# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 +# +# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 +# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: +# xAI Grok https://api.x.ai/v1 grok-4-1-fast +# OpenAI https://api.openai.com/v1 gpt-4o-mini +# Groq https://api.groq.com/openai/v1 llama-3.3-70b +# Together AI https://api.together.xyz/v1 ... +# vLLM/Ollama http://localhost:8000/v1 ... +NPC_LLM_MODEL=grok-4-1-fast +NPC_LLM_BASE_URL=https://api.x.ai/v1 + +# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 +# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 +TTS_VOICE_ID=6 +VOICEVOX_URL=http://localhost:50021 +HEARTBEAT_INTERVAL_S=5 +LOG_LEVEL=INFO diff --git a/envs/npc/.env.comet.example b/envs/npc/.env.comet.example new file mode 100644 index 0000000..1c9c652 --- /dev/null +++ b/envs/npc/.env.comet.example @@ -0,0 +1,49 @@ +# ── NPC bot · ☄️コメット (envs/npc/.env.comet.example) ─ +# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.comet` +# にコピーし、`要記入` の欄を埋めてから: +# +# WOLFBOT_NPC_ENV=envs/npc/.env.comet uv run wolfbot-npc +# +# Persona: 無邪気で気まぐれ。妙に核心を突く。 + +# ── 必須 (要記入) ────────────────────────────────────── +# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) +NPC_ID=npc_comet +# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) +NPC_DISCORD_TOKEN= +# Master 側 .env.master と同値 +DISCORD_GUILD_ID= +MAIN_VOICE_CHANNEL_ID= +# Master の MASTER_NPC_PSK と同値 +MASTER_NPC_PSK= +# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 +# 別プロバイダ用に独立したキーにしてもよい) +NPC_LLM_API_KEY= + +# ── ペルソナ固定 (変更不要) ──────────────────────────── +# Master が `npc_register` で受け取り、このペルソナだけで席を埋める +NPC_PERSONA_KEY=comet + +# ── デフォルトでよい (必要なら上書き) ────────────────── +MASTER_WS_URL=ws://127.0.0.1:8800 + +# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 +# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は +# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 +# +# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 +# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: +# xAI Grok https://api.x.ai/v1 grok-4-1-fast +# OpenAI https://api.openai.com/v1 gpt-4o-mini +# Groq https://api.groq.com/openai/v1 llama-3.3-70b +# Together AI https://api.together.xyz/v1 ... +# vLLM/Ollama http://localhost:8000/v1 ... +NPC_LLM_MODEL=grok-4-1-fast +NPC_LLM_BASE_URL=https://api.x.ai/v1 + +# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 +# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 +TTS_VOICE_ID=1 +VOICEVOX_URL=http://localhost:50021 +HEARTBEAT_INTERVAL_S=5 +LOG_LEVEL=INFO diff --git a/envs/npc/.env.gina.example b/envs/npc/.env.gina.example new file mode 100644 index 0000000..3cae44c --- /dev/null +++ b/envs/npc/.env.gina.example @@ -0,0 +1,49 @@ +# ── NPC bot · 🟣ジナ (envs/npc/.env.gina.example) ─ +# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.gina` +# にコピーし、`要記入` の欄を埋めてから: +# +# WOLFBOT_NPC_ENV=envs/npc/.env.gina uv run wolfbot-npc +# +# Persona: 物静かで誠実。直感と共感を重視する。 + +# ── 必須 (要記入) ────────────────────────────────────── +# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) +NPC_ID=npc_gina +# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) +NPC_DISCORD_TOKEN= +# Master 側 .env.master と同値 +DISCORD_GUILD_ID= +MAIN_VOICE_CHANNEL_ID= +# Master の MASTER_NPC_PSK と同値 +MASTER_NPC_PSK= +# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 +# 別プロバイダ用に独立したキーにしてもよい) +NPC_LLM_API_KEY= + +# ── ペルソナ固定 (変更不要) ──────────────────────────── +# Master が `npc_register` で受け取り、このペルソナだけで席を埋める +NPC_PERSONA_KEY=gina + +# ── デフォルトでよい (必要なら上書き) ────────────────── +MASTER_WS_URL=ws://127.0.0.1:8800 + +# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 +# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は +# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 +# +# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 +# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: +# xAI Grok https://api.x.ai/v1 grok-4-1-fast +# OpenAI https://api.openai.com/v1 gpt-4o-mini +# Groq https://api.groq.com/openai/v1 llama-3.3-70b +# Together AI https://api.together.xyz/v1 ... +# vLLM/Ollama http://localhost:8000/v1 ... +NPC_LLM_MODEL=grok-4-1-fast +NPC_LLM_BASE_URL=https://api.x.ai/v1 + +# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 +# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 +TTS_VOICE_ID=9 +VOICEVOX_URL=http://localhost:50021 +HEARTBEAT_INTERVAL_S=5 +LOG_LEVEL=INFO diff --git a/envs/npc/.env.jonas.example b/envs/npc/.env.jonas.example new file mode 100644 index 0000000..693ebdc --- /dev/null +++ b/envs/npc/.env.jonas.example @@ -0,0 +1,49 @@ +# ── NPC bot · 🎩ジョナス (envs/npc/.env.jonas.example) ─ +# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.jonas` +# にコピーし、`要記入` の欄を埋めてから: +# +# WOLFBOT_NPC_ENV=envs/npc/.env.jonas uv run wolfbot-npc +# +# Persona: 尊大で芝居がかった話し方。 + +# ── 必須 (要記入) ────────────────────────────────────── +# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) +NPC_ID=npc_jonas +# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) +NPC_DISCORD_TOKEN= +# Master 側 .env.master と同値 +DISCORD_GUILD_ID= +MAIN_VOICE_CHANNEL_ID= +# Master の MASTER_NPC_PSK と同値 +MASTER_NPC_PSK= +# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 +# 別プロバイダ用に独立したキーにしてもよい) +NPC_LLM_API_KEY= + +# ── ペルソナ固定 (変更不要) ──────────────────────────── +# Master が `npc_register` で受け取り、このペルソナだけで席を埋める +NPC_PERSONA_KEY=jonas + +# ── デフォルトでよい (必要なら上書き) ────────────────── +MASTER_WS_URL=ws://127.0.0.1:8800 + +# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 +# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は +# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 +# +# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 +# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: +# xAI Grok https://api.x.ai/v1 grok-4-1-fast +# OpenAI https://api.openai.com/v1 gpt-4o-mini +# Groq https://api.groq.com/openai/v1 llama-3.3-70b +# Together AI https://api.together.xyz/v1 ... +# vLLM/Ollama http://localhost:8000/v1 ... +NPC_LLM_MODEL=grok-4-1-fast +NPC_LLM_BASE_URL=https://api.x.ai/v1 + +# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 +# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 +TTS_VOICE_ID=12 +VOICEVOX_URL=http://localhost:50021 +HEARTBEAT_INTERVAL_S=5 +LOG_LEVEL=INFO diff --git a/envs/npc/.env.kukrushka.example b/envs/npc/.env.kukrushka.example new file mode 100644 index 0000000..2ed8bcd --- /dev/null +++ b/envs/npc/.env.kukrushka.example @@ -0,0 +1,49 @@ +# ── NPC bot · 🧸ククルシカ (envs/npc/.env.kukrushka.example) ─ +# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.kukrushka` +# にコピーし、`要記入` の欄を埋めてから: +# +# WOLFBOT_NPC_ENV=envs/npc/.env.kukrushka uv run wolfbot-npc +# +# Persona: かわいらしく見えて不穏。ほぼ無言で身振りを示す。 + +# ── 必須 (要記入) ────────────────────────────────────── +# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) +NPC_ID=npc_kukrushka +# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) +NPC_DISCORD_TOKEN= +# Master 側 .env.master と同値 +DISCORD_GUILD_ID= +MAIN_VOICE_CHANNEL_ID= +# Master の MASTER_NPC_PSK と同値 +MASTER_NPC_PSK= +# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 +# 別プロバイダ用に独立したキーにしてもよい) +NPC_LLM_API_KEY= + +# ── ペルソナ固定 (変更不要) ──────────────────────────── +# Master が `npc_register` で受け取り、このペルソナだけで席を埋める +NPC_PERSONA_KEY=kukrushka + +# ── デフォルトでよい (必要なら上書き) ────────────────── +MASTER_WS_URL=ws://127.0.0.1:8800 + +# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 +# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は +# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 +# +# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 +# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: +# xAI Grok https://api.x.ai/v1 grok-4-1-fast +# OpenAI https://api.openai.com/v1 gpt-4o-mini +# Groq https://api.groq.com/openai/v1 llama-3.3-70b +# Together AI https://api.together.xyz/v1 ... +# vLLM/Ollama http://localhost:8000/v1 ... +NPC_LLM_MODEL=grok-4-1-fast +NPC_LLM_BASE_URL=https://api.x.ai/v1 + +# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 +# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 +TTS_VOICE_ID=0 +VOICEVOX_URL=http://localhost:50021 +HEARTBEAT_INTERVAL_S=5 +LOG_LEVEL=INFO diff --git a/envs/npc/.env.otome.example b/envs/npc/.env.otome.example new file mode 100644 index 0000000..d0ed691 --- /dev/null +++ b/envs/npc/.env.otome.example @@ -0,0 +1,49 @@ +# ── NPC bot · 🐬オトメ (envs/npc/.env.otome.example) ─ +# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.otome` +# にコピーし、`要記入` の欄を埋めてから: +# +# WOLFBOT_NPC_ENV=envs/npc/.env.otome uv run wolfbot-npc +# +# Persona: 事務的で面倒見がよい。要点中心。 + +# ── 必須 (要記入) ────────────────────────────────────── +# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) +NPC_ID=npc_otome +# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) +NPC_DISCORD_TOKEN= +# Master 側 .env.master と同値 +DISCORD_GUILD_ID= +MAIN_VOICE_CHANNEL_ID= +# Master の MASTER_NPC_PSK と同値 +MASTER_NPC_PSK= +# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 +# 別プロバイダ用に独立したキーにしてもよい) +NPC_LLM_API_KEY= + +# ── ペルソナ固定 (変更不要) ──────────────────────────── +# Master が `npc_register` で受け取り、このペルソナだけで席を埋める +NPC_PERSONA_KEY=otome + +# ── デフォルトでよい (必要なら上書き) ────────────────── +MASTER_WS_URL=ws://127.0.0.1:8800 + +# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 +# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は +# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 +# +# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 +# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: +# xAI Grok https://api.x.ai/v1 grok-4-1-fast +# OpenAI https://api.openai.com/v1 gpt-4o-mini +# Groq https://api.groq.com/openai/v1 llama-3.3-70b +# Together AI https://api.together.xyz/v1 ... +# vLLM/Ollama http://localhost:8000/v1 ... +NPC_LLM_MODEL=grok-4-1-fast +NPC_LLM_BASE_URL=https://api.x.ai/v1 + +# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 +# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 +TTS_VOICE_ID=7 +VOICEVOX_URL=http://localhost:50021 +HEARTBEAT_INTERVAL_S=5 +LOG_LEVEL=INFO diff --git a/envs/npc/.env.raqio.example b/envs/npc/.env.raqio.example new file mode 100644 index 0000000..ffa3006 --- /dev/null +++ b/envs/npc/.env.raqio.example @@ -0,0 +1,49 @@ +# ── NPC bot · 🦋ラキオ (envs/npc/.env.raqio.example) ─ +# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.raqio` +# にコピーし、`要記入` の欄を埋めてから: +# +# WOLFBOT_NPC_ENV=envs/npc/.env.raqio uv run wolfbot-npc +# +# Persona: 論理偏重で挑発的。矛盾追及が鋭い。 + +# ── 必須 (要記入) ────────────────────────────────────── +# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) +NPC_ID=npc_raqio +# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) +NPC_DISCORD_TOKEN= +# Master 側 .env.master と同値 +DISCORD_GUILD_ID= +MAIN_VOICE_CHANNEL_ID= +# Master の MASTER_NPC_PSK と同値 +MASTER_NPC_PSK= +# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 +# 別プロバイダ用に独立したキーにしてもよい) +NPC_LLM_API_KEY= + +# ── ペルソナ固定 (変更不要) ──────────────────────────── +# Master が `npc_register` で受け取り、このペルソナだけで席を埋める +NPC_PERSONA_KEY=raqio + +# ── デフォルトでよい (必要なら上書き) ────────────────── +MASTER_WS_URL=ws://127.0.0.1:8800 + +# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 +# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は +# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 +# +# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 +# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: +# xAI Grok https://api.x.ai/v1 grok-4-1-fast +# OpenAI https://api.openai.com/v1 gpt-4o-mini +# Groq https://api.groq.com/openai/v1 llama-3.3-70b +# Together AI https://api.together.xyz/v1 ... +# vLLM/Ollama http://localhost:8000/v1 ... +NPC_LLM_MODEL=grok-4-1-fast +NPC_LLM_BASE_URL=https://api.x.ai/v1 + +# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 +# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 +TTS_VOICE_ID=13 +VOICEVOX_URL=http://localhost:50021 +HEARTBEAT_INTERVAL_S=5 +LOG_LEVEL=INFO diff --git a/envs/npc/.env.remnan.example b/envs/npc/.env.remnan.example new file mode 100644 index 0000000..0f36102 --- /dev/null +++ b/envs/npc/.env.remnan.example @@ -0,0 +1,49 @@ +# ── NPC bot · ⚪️レムナン (envs/npc/.env.remnan.example) ─ +# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.remnan` +# にコピーし、`要記入` の欄を埋めてから: +# +# WOLFBOT_NPC_ENV=envs/npc/.env.remnan uv run wolfbot-npc +# +# Persona: 内向的で慎重。観察は細かい。 + +# ── 必須 (要記入) ────────────────────────────────────── +# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) +NPC_ID=npc_remnan +# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) +NPC_DISCORD_TOKEN= +# Master 側 .env.master と同値 +DISCORD_GUILD_ID= +MAIN_VOICE_CHANNEL_ID= +# Master の MASTER_NPC_PSK と同値 +MASTER_NPC_PSK= +# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 +# 別プロバイダ用に独立したキーにしてもよい) +NPC_LLM_API_KEY= + +# ── ペルソナ固定 (変更不要) ──────────────────────────── +# Master が `npc_register` で受け取り、このペルソナだけで席を埋める +NPC_PERSONA_KEY=remnan + +# ── デフォルトでよい (必要なら上書き) ────────────────── +MASTER_WS_URL=ws://127.0.0.1:8800 + +# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 +# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は +# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 +# +# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 +# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: +# xAI Grok https://api.x.ai/v1 grok-4-1-fast +# OpenAI https://api.openai.com/v1 gpt-4o-mini +# Groq https://api.groq.com/openai/v1 llama-3.3-70b +# Together AI https://api.together.xyz/v1 ... +# vLLM/Ollama http://localhost:8000/v1 ... +NPC_LLM_MODEL=grok-4-1-fast +NPC_LLM_BASE_URL=https://api.x.ai/v1 + +# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 +# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 +TTS_VOICE_ID=10 +VOICEVOX_URL=http://localhost:50021 +HEARTBEAT_INTERVAL_S=5 +LOG_LEVEL=INFO diff --git a/envs/npc/.env.setsu.example b/envs/npc/.env.setsu.example new file mode 100644 index 0000000..2f011f5 --- /dev/null +++ b/envs/npc/.env.setsu.example @@ -0,0 +1,49 @@ +# ── NPC bot · 🟡セツ (envs/npc/.env.setsu.example) ─ +# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.setsu` +# にコピーし、`要記入` の欄を埋めてから: +# +# WOLFBOT_NPC_ENV=envs/npc/.env.setsu uv run wolfbot-npc +# +# Persona: 真面目で責任感が強い。議論を整理する。 + +# ── 必須 (要記入) ────────────────────────────────────── +# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) +NPC_ID=npc_setsu +# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) +NPC_DISCORD_TOKEN= +# Master 側 .env.master と同値 +DISCORD_GUILD_ID= +MAIN_VOICE_CHANNEL_ID= +# Master の MASTER_NPC_PSK と同値 +MASTER_NPC_PSK= +# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 +# 別プロバイダ用に独立したキーにしてもよい) +NPC_LLM_API_KEY= + +# ── ペルソナ固定 (変更不要) ──────────────────────────── +# Master が `npc_register` で受け取り、このペルソナだけで席を埋める +NPC_PERSONA_KEY=setsu + +# ── デフォルトでよい (必要なら上書き) ────────────────── +MASTER_WS_URL=ws://127.0.0.1:8800 + +# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 +# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は +# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 +# +# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 +# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: +# xAI Grok https://api.x.ai/v1 grok-4-1-fast +# OpenAI https://api.openai.com/v1 gpt-4o-mini +# Groq https://api.groq.com/openai/v1 llama-3.3-70b +# Together AI https://api.together.xyz/v1 ... +# vLLM/Ollama http://localhost:8000/v1 ... +NPC_LLM_MODEL=grok-4-1-fast +NPC_LLM_BASE_URL=https://api.x.ai/v1 + +# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 +# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 +TTS_VOICE_ID=8 +VOICEVOX_URL=http://localhost:50021 +HEARTBEAT_INTERVAL_S=5 +LOG_LEVEL=INFO diff --git a/envs/npc/.env.sha_ming.example b/envs/npc/.env.sha_ming.example new file mode 100644 index 0000000..d953ad0 --- /dev/null +++ b/envs/npc/.env.sha_ming.example @@ -0,0 +1,49 @@ +# ── NPC bot · 🥽シャーミン (envs/npc/.env.sha_ming.example) ─ +# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.sha_ming` +# にコピーし、`要記入` の欄を埋めてから: +# +# WOLFBOT_NPC_ENV=envs/npc/.env.sha_ming uv run wolfbot-npc +# +# Persona: 皮肉屋で自信家。挑発的に試す。 + +# ── 必須 (要記入) ────────────────────────────────────── +# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) +NPC_ID=npc_sha_ming +# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) +NPC_DISCORD_TOKEN= +# Master 側 .env.master と同値 +DISCORD_GUILD_ID= +MAIN_VOICE_CHANNEL_ID= +# Master の MASTER_NPC_PSK と同値 +MASTER_NPC_PSK= +# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 +# 別プロバイダ用に独立したキーにしてもよい) +NPC_LLM_API_KEY= + +# ── ペルソナ固定 (変更不要) ──────────────────────────── +# Master が `npc_register` で受け取り、このペルソナだけで席を埋める +NPC_PERSONA_KEY=sha_ming + +# ── デフォルトでよい (必要なら上書き) ────────────────── +MASTER_WS_URL=ws://127.0.0.1:8800 + +# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 +# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は +# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 +# +# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 +# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: +# xAI Grok https://api.x.ai/v1 grok-4-1-fast +# OpenAI https://api.openai.com/v1 gpt-4o-mini +# Groq https://api.groq.com/openai/v1 llama-3.3-70b +# Together AI https://api.together.xyz/v1 ... +# vLLM/Ollama http://localhost:8000/v1 ... +NPC_LLM_MODEL=grok-4-1-fast +NPC_LLM_BASE_URL=https://api.x.ai/v1 + +# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 +# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 +TTS_VOICE_ID=5 +VOICEVOX_URL=http://localhost:50021 +HEARTBEAT_INTERVAL_S=5 +LOG_LEVEL=INFO diff --git a/envs/npc/.env.shigemichi.example b/envs/npc/.env.shigemichi.example new file mode 100644 index 0000000..2e66bb3 --- /dev/null +++ b/envs/npc/.env.shigemichi.example @@ -0,0 +1,49 @@ +# ── NPC bot · 👽シゲミチ (envs/npc/.env.shigemichi.example) ─ +# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.shigemichi` +# にコピーし、`要記入` の欄を埋めてから: +# +# WOLFBOT_NPC_ENV=envs/npc/.env.shigemichi uv run wolfbot-npc +# +# Persona: 率直で豪快。印象や勢いを重視する。 + +# ── 必須 (要記入) ────────────────────────────────────── +# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) +NPC_ID=npc_shigemichi +# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) +NPC_DISCORD_TOKEN= +# Master 側 .env.master と同値 +DISCORD_GUILD_ID= +MAIN_VOICE_CHANNEL_ID= +# Master の MASTER_NPC_PSK と同値 +MASTER_NPC_PSK= +# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 +# 別プロバイダ用に独立したキーにしてもよい) +NPC_LLM_API_KEY= + +# ── ペルソナ固定 (変更不要) ──────────────────────────── +# Master が `npc_register` で受け取り、このペルソナだけで席を埋める +NPC_PERSONA_KEY=shigemichi + +# ── デフォルトでよい (必要なら上書き) ────────────────── +MASTER_WS_URL=ws://127.0.0.1:8800 + +# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 +# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は +# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 +# +# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 +# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: +# xAI Grok https://api.x.ai/v1 grok-4-1-fast +# OpenAI https://api.openai.com/v1 gpt-4o-mini +# Groq https://api.groq.com/openai/v1 llama-3.3-70b +# Together AI https://api.together.xyz/v1 ... +# vLLM/Ollama http://localhost:8000/v1 ... +NPC_LLM_MODEL=grok-4-1-fast +NPC_LLM_BASE_URL=https://api.x.ai/v1 + +# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 +# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 +TTS_VOICE_ID=11 +VOICEVOX_URL=http://localhost:50021 +HEARTBEAT_INTERVAL_S=5 +LOG_LEVEL=INFO diff --git a/envs/npc/.env.sq.example b/envs/npc/.env.sq.example new file mode 100644 index 0000000..29ab6c5 --- /dev/null +++ b/envs/npc/.env.sq.example @@ -0,0 +1,49 @@ +# ── NPC bot · 🔴SQ (envs/npc/.env.sq.example) ─ +# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.sq` +# にコピーし、`要記入` の欄を埋めてから: +# +# WOLFBOT_NPC_ENV=envs/npc/.env.sq uv run wolfbot-npc +# +# Persona: 軽快で社交的。打算的な面もありつつ場を和ませる。 + +# ── 必須 (要記入) ────────────────────────────────────── +# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) +NPC_ID=npc_sq +# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) +NPC_DISCORD_TOKEN= +# Master 側 .env.master と同値 +DISCORD_GUILD_ID= +MAIN_VOICE_CHANNEL_ID= +# Master の MASTER_NPC_PSK と同値 +MASTER_NPC_PSK= +# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 +# 別プロバイダ用に独立したキーにしてもよい) +NPC_LLM_API_KEY= + +# ── ペルソナ固定 (変更不要) ──────────────────────────── +# Master が `npc_register` で受け取り、このペルソナだけで席を埋める +NPC_PERSONA_KEY=sq + +# ── デフォルトでよい (必要なら上書き) ────────────────── +MASTER_WS_URL=ws://127.0.0.1:8800 + +# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 +# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は +# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 +# +# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 +# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: +# xAI Grok https://api.x.ai/v1 grok-4-1-fast +# OpenAI https://api.openai.com/v1 gpt-4o-mini +# Groq https://api.groq.com/openai/v1 llama-3.3-70b +# Together AI https://api.together.xyz/v1 ... +# vLLM/Ollama http://localhost:8000/v1 ... +NPC_LLM_MODEL=grok-4-1-fast +NPC_LLM_BASE_URL=https://api.x.ai/v1 + +# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 +# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 +TTS_VOICE_ID=2 +VOICEVOX_URL=http://localhost:50021 +HEARTBEAT_INTERVAL_S=5 +LOG_LEVEL=INFO diff --git a/envs/npc/.env.stella.example b/envs/npc/.env.stella.example new file mode 100644 index 0000000..1d928a6 --- /dev/null +++ b/envs/npc/.env.stella.example @@ -0,0 +1,49 @@ +# ── NPC bot · 🌟ステラ (envs/npc/.env.stella.example) ─ +# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.stella` +# にコピーし、`要記入` の欄を埋めてから: +# +# WOLFBOT_NPC_ENV=envs/npc/.env.stella uv run wolfbot-npc +# +# Persona: 優しく献身的。柔らかい物言いを心がける。 + +# ── 必須 (要記入) ────────────────────────────────────── +# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) +NPC_ID=npc_stella +# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) +NPC_DISCORD_TOKEN= +# Master 側 .env.master と同値 +DISCORD_GUILD_ID= +MAIN_VOICE_CHANNEL_ID= +# Master の MASTER_NPC_PSK と同値 +MASTER_NPC_PSK= +# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 +# 別プロバイダ用に独立したキーにしてもよい) +NPC_LLM_API_KEY= + +# ── ペルソナ固定 (変更不要) ──────────────────────────── +# Master が `npc_register` で受け取り、このペルソナだけで席を埋める +NPC_PERSONA_KEY=stella + +# ── デフォルトでよい (必要なら上書き) ────────────────── +MASTER_WS_URL=ws://127.0.0.1:8800 + +# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 +# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は +# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 +# +# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 +# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: +# xAI Grok https://api.x.ai/v1 grok-4-1-fast +# OpenAI https://api.openai.com/v1 gpt-4o-mini +# Groq https://api.groq.com/openai/v1 llama-3.3-70b +# Together AI https://api.together.xyz/v1 ... +# vLLM/Ollama http://localhost:8000/v1 ... +NPC_LLM_MODEL=grok-4-1-fast +NPC_LLM_BASE_URL=https://api.x.ai/v1 + +# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 +# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 +TTS_VOICE_ID=4 +VOICEVOX_URL=http://localhost:50021 +HEARTBEAT_INTERVAL_S=5 +LOG_LEVEL=INFO diff --git a/envs/npc/.env.yuriko.example b/envs/npc/.env.yuriko.example new file mode 100644 index 0000000..24b867a --- /dev/null +++ b/envs/npc/.env.yuriko.example @@ -0,0 +1,49 @@ +# ── NPC bot · 👑ユリコ (envs/npc/.env.yuriko.example) ─ +# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.yuriko` +# にコピーし、`要記入` の欄を埋めてから: +# +# WOLFBOT_NPC_ENV=envs/npc/.env.yuriko uv run wolfbot-npc +# +# Persona: 冷静で威圧感がある。少ない語数で核心を突く。 + +# ── 必須 (要記入) ────────────────────────────────────── +# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) +NPC_ID=npc_yuriko +# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) +NPC_DISCORD_TOKEN= +# Master 側 .env.master と同値 +DISCORD_GUILD_ID= +MAIN_VOICE_CHANNEL_ID= +# Master の MASTER_NPC_PSK と同値 +MASTER_NPC_PSK= +# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 +# 別プロバイダ用に独立したキーにしてもよい) +NPC_LLM_API_KEY= + +# ── ペルソナ固定 (変更不要) ──────────────────────────── +# Master が `npc_register` で受け取り、このペルソナだけで席を埋める +NPC_PERSONA_KEY=yuriko + +# ── デフォルトでよい (必要なら上書き) ────────────────── +MASTER_WS_URL=ws://127.0.0.1:8800 + +# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 +# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は +# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 +# +# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 +# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: +# xAI Grok https://api.x.ai/v1 grok-4-1-fast +# OpenAI https://api.openai.com/v1 gpt-4o-mini +# Groq https://api.groq.com/openai/v1 llama-3.3-70b +# Together AI https://api.together.xyz/v1 ... +# vLLM/Ollama http://localhost:8000/v1 ... +NPC_LLM_MODEL=grok-4-1-fast +NPC_LLM_BASE_URL=https://api.x.ai/v1 + +# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 +# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 +TTS_VOICE_ID=3 +VOICEVOX_URL=http://localhost:50021 +HEARTBEAT_INTERVAL_S=5 +LOG_LEVEL=INFO diff --git a/envs/npc/README.md b/envs/npc/README.md new file mode 100644 index 0000000..27dff4c --- /dev/null +++ b/envs/npc/README.md @@ -0,0 +1,69 @@ +# NPC bot env templates + +各ペルソナごとに 1 つのテンプレ (`.env..example`) がある。NPC bot は +**1 プロセス = 1 ペルソナ**なので、立ち上げたいペルソナぶんコピーして使う。 + +## ファイルの意味 + +| パターン | 意味 | git | +|---|---|---| +| `envs/npc/.env..example` | テンプレ (秘密値は空欄) | コミットされる | +| `envs/npc/.env.` | 実 env (秘密値を埋めたもの) | gitignore | + +## 使い方 + +```bash +# 1) テンプレをコピー +cp envs/npc/.env.setsu.example envs/npc/.env.setsu + +# 2) 編集して秘密値を埋める +# NPC_DISCORD_TOKEN ── このペルソナ専用の Discord bot トークン +# DISCORD_GUILD_ID ── Master と同じ guild ID +# MAIN_VOICE_CHANNEL_ID ── Master と同じ VC ID +# MASTER_NPC_PSK ── Master の MASTER_NPC_PSK と同値 +# NPC_LLM_API_KEY ── NPC LLM の API キー +# (Master の GAMEPLAY_LLM_API_KEY と共用可) +$EDITOR envs/npc/.env.setsu + +# 3) 起動 (env ファイルは WOLFBOT_NPC_ENV で指定) +WOLFBOT_NPC_ENV=envs/npc/.env.setsu uv run wolfbot-npc + +# 4) 別ペルソナを増やす場合は別プロセスで +WOLFBOT_NPC_ENV=envs/npc/.env.gina uv run wolfbot-npc & +WOLFBOT_NPC_ENV=envs/npc/.env.sq uv run wolfbot-npc & +``` + +`reactive_voice` モードで `/wolf start` するとき、Master は +**online な NPC bot のうち未割当のものを席に充てる**。出したい +ペルソナのプロセスだけを起動しておけば、それだけが選ばれる。 + +## ペルソナ一覧 (テンプレで既定値を割り当て済み) + +| Key | 表示名 | スタイル | TTS_VOICE_ID | +|---|---|---|---| +| `setsu` | 🟡セツ | 真面目で責任感が強い。議論を整理する | 8 | +| `gina` | 🟣ジナ | 物静かで誠実。直感と共感を重視 | 9 | +| `sq` | 🔴SQ | 軽快で社交的。打算的な面もありつつ場を和ませる | 2 | +| `raqio` | 🦋ラキオ | 論理偏重で挑発的。矛盾追及が鋭い | 13 | +| `stella` | 🌟ステラ | 優しく献身的。柔らかい物言い | 4 | +| `shigemichi` | 👽シゲミチ | 率直で豪快。印象や勢いを重視 | 11 | +| `chipie` | 🐈‍⬛シピ | 柔らかく観察力がある。対立をなだめつつ疑問を出す | 6 | +| `comet` | ☄️コメット | 無邪気で気まぐれ。妙に核心を突く | 1 | +| `jonas` | 🎩ジョナス | 尊大で芝居がかった話し方 | 12 | +| `kukrushka` | 🧸ククルシカ | 不穏。ほぼ無言で身振りを示す | 0 | +| `otome` | 🐬オトメ | 事務的で面倒見がよい。要点中心 | 7 | +| `sha_ming` | 🥽シャーミン | 皮肉屋で自信家。挑発的に試す | 5 | +| `remnan` | ⚪️レムナン | 内向的で慎重。観察は細かい | 10 | +| `yuriko` | 👑ユリコ | 冷静で威圧感がある。少ない語数で核心を突く | 3 | + +`TTS_VOICE_ID` は VOICEVOX のスピーカー ID。重複しないように既定で +割り当ててあるが、好みの声に変えたい場合だけ編集する +( で確認)。 + +## 事前にやっておくこと + +- 各ペルソナぶんの Discord bot アプリを Developer Portal で作成し、 + guild に**手動で招待**しておく (Discord は bot から bot を guild に + 追加できないので、これは 1 度きりのセットアップ作業)。 +- Master の `.env.master` を先に用意し、`MASTER_NPC_PSK` を決めておく。 +- VOICEVOX エンジンを立ち上げておく (既定 `http://localhost:50021`)。 diff --git a/pyproject.toml b/pyproject.toml index 00dce30..02352b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ [project.scripts] wolfbot = "wolfbot.main:cli" -wolfbot-npc = "wolfbot.npc_bot_main:main" +wolfbot-npc = "wolfbot.npc.main:main" [build-system] requires = ["hatchling"] diff --git a/src/wolfbot/config.py b/src/wolfbot/config.py index 4d0bf90..c2d0883 100644 --- a/src/wolfbot/config.py +++ b/src/wolfbot/config.py @@ -1,6 +1,8 @@ -"""Environment-driven settings. +"""Master-process settings. -Loaded once at startup from `.env` via pydantic_settings. +Loaded once at startup from ``.env.master`` via pydantic-settings. + +NPC bot worker settings live in :mod:`wolfbot.npc.config`. """ from __future__ import annotations @@ -9,13 +11,11 @@ from pydantic_settings import BaseSettings, SettingsConfigDict -class Settings(BaseSettings): +class MasterSettings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env", env_file_encoding="utf-8", extra="ignore") + env_file=".env.master", env_file_encoding="utf-8", extra="ignore") DISCORD_TOKEN: SecretStr - XAI_API_KEY: SecretStr - XAI_MODEL: str = "grok-4-1-fast" DISCORD_GUILD_ID: int MAIN_TEXT_CHANNEL_ID: int MAIN_VOICE_CHANNEL_ID: int @@ -24,9 +24,36 @@ class Settings(BaseSettings): # Discussion mode applied to NEW games started under this process. # Existing rows keep whichever mode was captured at their start time. LLM_DISCUSSION_MODE: str = "rounds" - # Master ↔ NPC bot / voice-ingest WebSocket transport. + + # ── Gameplay LLM ─────────────────────────────────────────────────── + # The LLM that drives every gameplay decision Master makes on behalf + # of LLM-controlled seats: + # + # * Day-time votes (which seat to execute). + # * Night actions (wolf attack target, seer divine target, knight + # guard target). + # * Day-discussion text **in rounds mode** (Master generates the + # LLM seat's discussion turn directly). In reactive_voice mode + # the discussion turns are produced by NPC bot processes via + # `wolfbot.npc.*` and use NPC_LLM_* there — but votes / night + # actions still flow through this Gameplay LLM. + # + # Any OpenAI Chat Completions-compatible endpoint works (xAI Grok, + # OpenAI, Groq, Together, vLLM, Ollama, ...). The same credential + # may be shared with NPC bots' NPC_LLM_API_KEY when convenient, but + # the two roles are intentionally split so they can target different + # models / providers. + GAMEPLAY_LLM_API_KEY: SecretStr + GAMEPLAY_LLM_MODEL: str = "grok-4-1-fast" + + # ── Master ↔ NPC bot / voice-ingest WebSocket transport ─────────── MASTER_WS_LISTEN: str = "127.0.0.1:8800" MASTER_NPC_PSK: SecretStr | None = None - # Gemini API for voice-ingest audio analysis (STT + structured inference). - GEMINI_API_KEY: SecretStr | None = None - GEMINI_MODEL: str = "gemini-2.0-flash-lite" + + # ── Voice LLM ───────────────────────────────────────────────────── + # The multimodal LLM that *understands human voice* in VC — single + # API call returns transcription + summary + CO detection + vote + # target extraction. Default targets Google Gemini Flash; swap via + # env (the analyzer is wired in main.py for any compatible provider). + VOICE_LLM_API_KEY: SecretStr | None = None + VOICE_LLM_MODEL: str = "gemini-2.0-flash-lite" diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index 7c6bfb0..5069a84 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -37,6 +37,13 @@ class NpcRegister(BaseEnvelope): type: Literal["npc_register"] = "npc_register" npc_id: str discord_bot_user_id: str + persona_key: str = Field( + description=( + "Persona this NPC bot embodies (key from wolfbot.npc.personas). " + "Each NPC bot process is bound to exactly one persona at startup; " + "Master uses this when filling reactive_voice LLM seats." + ) + ) supported_voices: tuple[str, ...] = () version: str = "0.0.0" diff --git a/src/wolfbot/llm/persona_base.py b/src/wolfbot/llm/persona_base.py new file mode 100644 index 0000000..c690e92 --- /dev/null +++ b/src/wolfbot/llm/persona_base.py @@ -0,0 +1,88 @@ +"""Shared persona base — types + pool-agnostic pickers. + +`Persona` is the generic identity carrier (display name + judgment-style +guide + structured speech profile). Concrete persona instances live in: + +* :mod:`wolfbot.npc.personas` — player characters (Gnosia-flavored). +* :mod:`wolfbot.master.personas` — Master/GM narrator voices. + +`SpeechProfile` holds the structured speech-reproduction data +(first-person, address style, signature phrases, narration mode) that the +system prompt's `## 話法` block consumes. Keep `style_guide` for +personality/judgment and `speech_profile` for 喋り方/語彙/文体 — do not +mix the two in free-form prose. + +Some personas are near-silent in their source material (e.g. Kukrushka) +— `narration_mode="silent_gesture"` makes the prompt builder render +gesture descriptions instead of a normal conversation profile. + +Hard rule: do NOT quote original source dialogue when defining a persona. +Only imitate temperament + register. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from random import Random +from typing import Literal + + +@dataclass(frozen=True) +class SpeechProfile: + first_person: str + self_reference_aliases: tuple[str, ...] = () + address_style: str = "" + sentence_style: str = "" + pause_style: str = "" + signature_phrases: tuple[str, ...] = () + forbidden_overuse: tuple[str, ...] = () + narration_mode: Literal["standard", "silent_gesture"] = "standard" + + +@dataclass(frozen=True) +class Persona: + key: str + display_name: str + style_guide: str + speech_profile: SpeechProfile + + +def index_by_key(pool: Sequence[Persona]) -> dict[str, Persona]: + """Build a key→Persona index. Raises if keys collide within `pool`.""" + out: dict[str, Persona] = {} + for p in pool: + if p.key in out: + raise ValueError(f"duplicate persona key {p.key!r} in pool") + out[p.key] = p + return out + + +def pick_personas(pool: Sequence[Persona], count: int, rng: Random) -> list[Persona]: + """Pick `count` distinct personas from `pool` at random.""" + if count < 0 or count > len(pool): + raise ValueError(f"cannot pick {count} personas; pool has {len(pool)}") + return rng.sample(list(pool), count) + + +def pick_personas_excluding( + pool: Sequence[Persona], + count: int, + exclude_keys: Sequence[str], + rng: Random, +) -> list[Persona]: + """Pick from `pool` minus `exclude_keys` — useful when you somehow need to extend.""" + excluded = set(exclude_keys) + candidates = [p for p in pool if p.key not in excluded] + if count > len(candidates): + raise ValueError(f"cannot pick {count} personas; only {len(candidates)} available") + return rng.sample(candidates, count) + + +__all__ = [ + "Persona", + "SpeechProfile", + "index_by_key", + "pick_personas", + "pick_personas_excluding", +] diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index ed1ce38..b7368c1 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -19,7 +19,7 @@ SubmissionType, ) from wolfbot.domain.models import Game, Player, Seat -from wolfbot.llm.personas import Persona +from wolfbot.llm.persona_base import Persona SYSTEM_TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "prompts" / "llm_system_prompt.md" diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 43ea4dd..947c1ac 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -12,7 +12,7 @@ from discord.ext import commands from dotenv import load_dotenv -from wolfbot.config import Settings +from wolfbot.config import MasterSettings from wolfbot.persistence.schema import migrate from wolfbot.persistence.sqlite_repo import SqliteRepo from wolfbot.services.discord_service import DiscordBotAdapter, WolfCog @@ -26,8 +26,8 @@ async def _run() -> None: - load_dotenv() - settings = Settings() # type: ignore[call-arg] + load_dotenv(".env.master") + settings = MasterSettings() # type: ignore[call-arg] logging.basicConfig( level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO), format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", @@ -46,6 +46,13 @@ async def _run() -> None: registry = EngineRegistry() discord_adapter = DiscordBotAdapter(bot=bot, repo=repo, settings=settings) + # NPC registry is always created so /wolf start can consult it for + # reactive_voice seat backfill. The WS server (which actually accepts + # NPC bot connections) is only started when MASTER_NPC_PSK is set. + from wolfbot.master.npc_registry import InMemoryNpcRegistry + + npc_registry = InMemoryNpcRegistry() + speech_store = SqliteSpeechEventStore(repo._db) class _DiscussionPoster: @@ -68,8 +75,8 @@ async def post_public(self, game_id: str, text: str, kind: str) -> None: ) decider = make_xai_decider( - api_key=settings.XAI_API_KEY.get_secret_value(), - model=settings.XAI_MODEL, + api_key=settings.GAMEPLAY_LLM_API_KEY.get_secret_value(), + model=settings.GAMEPLAY_LLM_MODEL, ) llm_adapter = LLMAdapter( repo=repo, @@ -151,6 +158,7 @@ async def _on_speech_recorded(game_id: str) -> None: settings=settings, discussion_service=discussion_service, on_speech_recorded=_on_speech_recorded, + npc_registry=npc_registry, ) await bot.add_cog(cog) bot.tree.add_command(cog.wolf, guild=discord.Object( @@ -171,15 +179,13 @@ async def _on_speech_recorded(game_id: str) -> None: ws_server: Any = None voice_ingest: Any = None if settings.MASTER_NPC_PSK is not None: - from wolfbot.services.master_ingest_service import MasterIngestService - from wolfbot.services.master_ws_server import ( + from wolfbot.master.ingest_service import MasterIngestService + from wolfbot.master.speak_arbiter import SpeakArbiter + from wolfbot.master.ws_server import ( MasterHandlers, WebsocketsMasterWsServer, ) - from wolfbot.services.npc_registry import InMemoryNpcRegistry - from wolfbot.services.speak_arbiter import SpeakArbiter - npc_registry = InMemoryNpcRegistry() _npc_registry_ref.append(npc_registry) arbiter = SpeakArbiter( @@ -314,10 +320,10 @@ async def _on_vad_ended(msg: Any, _ctx: Any) -> None: # Instead of a separate voice-ingest process, Master joins VC itself # and pipes audio through VoiceIngestService → DirectMasterIngestionClient # → arbiter/ingest_service, all in-process. - if settings.GEMINI_API_KEY is not None: - from wolfbot.services.stt_service import GeminiAudioAnalyzer - from wolfbot.services.voice_ingest_client import DirectMasterIngestionClient - from wolfbot.services.voice_ingest_service import VoiceIngestService + if settings.VOICE_LLM_API_KEY is not None: + from wolfbot.master.stt_service import GeminiAudioAnalyzer + from wolfbot.master.voice_ingest_client import DirectMasterIngestionClient + from wolfbot.master.voice_ingest_service import VoiceIngestService # Direct callbacks (no WS ctx needed) async def _direct_vad_started(msg: Any) -> None: @@ -351,9 +357,9 @@ async def _direct_stt_failed(msg: Any) -> None: on_stt_failed=_direct_stt_failed, ) - gemini_stt = GeminiAudioAnalyzer( - api_key=settings.GEMINI_API_KEY.get_secret_value(), - model=settings.GEMINI_MODEL, + voice_llm = GeminiAudioAnalyzer( + api_key=settings.VOICE_LLM_API_KEY.get_secret_value(), + model=settings.VOICE_LLM_MODEL, ) # NpcRegistryView adapter: InMemoryNpcRegistry → NpcRegistryView @@ -374,12 +380,12 @@ def _phase_lookup() -> tuple[str, str] | None: voice_ingest = VoiceIngestService( registry_view=_RegistryViewAdapter(), master_client=direct_client, - stt=gemini_stt, + stt=voice_llm, seat_lookup=_seat_lookup, phase_lookup=_phase_lookup, ) - log.info("integrated voice-ingest wired (Gemini model=%s)", - settings.GEMINI_MODEL) + log.info("integrated voice-ingest wired (voice_llm_model=%s)", + settings.VOICE_LLM_MODEL) @bot.event async def on_ready() -> None: @@ -401,7 +407,7 @@ async def on_ready() -> None: if voice_ingest is not None: from discord.ext import voice_recv - from wolfbot.services.audio_sink import WolfbotAudioSink + from wolfbot.master.audio_sink import WolfbotAudioSink vc_channel = bot.get_channel(settings.MAIN_VOICE_CHANNEL_ID) if vc_channel is not None and isinstance(vc_channel, discord.VoiceChannel): diff --git a/src/wolfbot/master/__init__.py b/src/wolfbot/master/__init__.py new file mode 100644 index 0000000..d9a298e --- /dev/null +++ b/src/wolfbot/master/__init__.py @@ -0,0 +1,7 @@ +"""Master-side reactive-voice pipeline. + +Code that runs only inside the Master Discord bot process when the +``reactive_voice`` discussion mode is active: the WS server, NPC registry, +speak arbiter, voice-ingest VAD/STT, and audio sink. NPC-bot-side +counterparts live in :mod:`wolfbot.npc`. +""" diff --git a/src/wolfbot/services/audio_sink.py b/src/wolfbot/master/audio_sink.py similarity index 96% rename from src/wolfbot/services/audio_sink.py rename to src/wolfbot/master/audio_sink.py index 0e6dd41..e020e55 100644 --- a/src/wolfbot/services/audio_sink.py +++ b/src/wolfbot/master/audio_sink.py @@ -22,7 +22,7 @@ from discord.ext import voice_recv if TYPE_CHECKING: - from wolfbot.services.voice_ingest_service import VoiceIngestService + from wolfbot.master.voice_ingest_service import VoiceIngestService log = logging.getLogger(__name__) diff --git a/src/wolfbot/services/master_ingest_service.py b/src/wolfbot/master/ingest_service.py similarity index 98% rename from src/wolfbot/services/master_ingest_service.py rename to src/wolfbot/master/ingest_service.py index d932a5f..427049c 100644 --- a/src/wolfbot/services/master_ingest_service.py +++ b/src/wolfbot/master/ingest_service.py @@ -26,6 +26,7 @@ ) from wolfbot.domain.enums import Phase from wolfbot.domain.ws_messages import SpeechEventPayload +from wolfbot.master.npc_registry import NpcRegistry from wolfbot.services.discussion_service import ( DiscussionService, new_event_id, @@ -33,7 +34,6 @@ from wolfbot.services.discussion_service import ( now_ms as default_now_ms, ) -from wolfbot.services.npc_registry import NpcRegistry log = logging.getLogger(__name__) diff --git a/src/wolfbot/services/master_logic_service.py b/src/wolfbot/master/logic_service.py similarity index 100% rename from src/wolfbot/services/master_logic_service.py rename to src/wolfbot/master/logic_service.py diff --git a/src/wolfbot/services/npc_registry.py b/src/wolfbot/master/npc_registry.py similarity index 98% rename from src/wolfbot/services/npc_registry.py rename to src/wolfbot/master/npc_registry.py index 41b7853..0402693 100644 --- a/src/wolfbot/services/npc_registry.py +++ b/src/wolfbot/master/npc_registry.py @@ -39,6 +39,7 @@ class NpcEntry: npc_id: str discord_bot_user_id: str + persona_key: str supported_voices: tuple[str, ...] version: str assigned_seat: int | None = None @@ -61,6 +62,7 @@ def register( *, npc_id: str, discord_bot_user_id: str, + persona_key: str, supported_voices: tuple[str, ...], version: str, send: Callable[[str], Awaitable[None]] | None, @@ -100,6 +102,7 @@ def register( *, npc_id: str, discord_bot_user_id: str, + persona_key: str, supported_voices: tuple[str, ...], version: str, send: Callable[[str], Awaitable[None]] | None, @@ -111,6 +114,7 @@ def register( entry = NpcEntry( npc_id=npc_id, discord_bot_user_id=discord_bot_user_id, + persona_key=persona_key, supported_voices=supported_voices, version=version, assigned_seat=previous.assigned_seat if previous is not None else None, diff --git a/src/wolfbot/master/personas.py b/src/wolfbot/master/personas.py new file mode 100644 index 0000000..8ab45c3 --- /dev/null +++ b/src/wolfbot/master/personas.py @@ -0,0 +1,108 @@ +"""Master / GM narrator personas — 3 simple tone variants. + +Master personas only change the *tone* of phase announcements, vote +results, death narration, etc. The game flow itself (phase order, +durations, branching, side effects) is identical regardless of which +persona is selected. In other words: persona swap = re-skin, not +rule change. + +Three voices kept deliberately simple so future announcement-rendering +code (text templates per persona, or LLM-styled narration) can pick from +a small, well-defined set: + +- ``stoic_gm`` — 端正・無感情・事実淡々。 +- ``theatrical_gm`` — 仰々しく芝居がかった進行役。 +- ``warm_gm`` — 柔らかく親しみやすい進行役。 + +Selection mechanism (env var, slash command, etc.) is not wired yet — +this module only defines the data so callers can ``MASTER_PERSONAS_BY_KEY[key]`` +once selection is added. +""" + +from __future__ import annotations + +from wolfbot.llm.persona_base import Persona, SpeechProfile, index_by_key + +MASTER_PERSONAS: tuple[Persona, ...] = ( + Persona( + key="stoic_gm", + display_name="📜 厳粛な進行役", + style_guide=( + "端正で無感情寄りの進行役。事実だけを淡々と読み上げる。" + "感情を煽らず、過剰な装飾を避け、結果を簡潔に告げる。" + ), + speech_profile=SpeechProfile( + first_person="私", + address_style="特定の相手を呼ばず、全員に対して読み上げる。", + sentence_style=( + "端正で硬めの敬体。" + "『〜です』『〜ました』『〜である』を場面で使い分ける。" + "余計な比喩や感情表現を入れず、起きた事実のみを述べる。" + ), + pause_style="間は最小限。整然と読み上げる。", + signature_phrases=("以上です", "報告する", "次のフェイズへ移る"), + forbidden_overuse=( + "煽り口調", + "過度な情緒表現", + "プレイヤー個人への評価", + ), + ), + ), + Persona( + key="theatrical_gm", + display_name="🎭 劇的な進行役", + style_guide=( + "芝居がかった大仰な語り口の進行役。場面を盛り上げる演出を好む。" + "ただし結果の事実関係は正確に伝える(進行に影響しない範囲で誇張する)。" + ), + speech_profile=SpeechProfile( + first_person="我", + address_style="『諸君』『集いし者たちよ』など全体への呼びかけを多用。", + sentence_style=( + "仰々しい演説調。比喩・反語・倒置を時折混ぜる。" + "ただし重要な事実(誰が処刑されたか、誰が襲撃されたか)は明瞭に告げる。" + ), + pause_style="決定的な瞬間に『……』で溜めを作る。", + signature_phrases=("さあ", "見届けよ", "運命は今"), + forbidden_overuse=( + "結果を曖昧にすること", + "毎発話で長広舌になること", + "プレイヤーを嘲笑するような演出", + ), + ), + ), + Persona( + key="warm_gm", + display_name="☕️ 穏やかな進行役", + style_guide=( + "柔らかく親しみやすい進行役。プレイヤーを気遣いながら淡々と進める。" + "重い結果を伝えるときも刺々しさを避け、落ち着いた言葉を選ぶ。" + ), + speech_profile=SpeechProfile( + first_person="私", + address_style="『みなさん』を基本とする。", + sentence_style=( + "やわらかい敬体。『〜ですね』『〜でした』を自然に使う。" + "結果を伝える前にひと呼吸置くような落ち着いた運び。" + ), + pause_style="穏やかなテンポ。急かさず、しかし冗長にもならない。", + signature_phrases=("では", "お疲れさまです", "落ち着いて"), + forbidden_overuse=( + "馴れ馴れしすぎる口調", + "プレイヤーの感情に過剰に踏み込むこと", + "結果をぼかすこと", + ), + ), + ), +) + +MASTER_PERSONAS_BY_KEY: dict[str, Persona] = index_by_key(MASTER_PERSONAS) + +DEFAULT_MASTER_PERSONA_KEY = "stoic_gm" + + +__all__ = [ + "DEFAULT_MASTER_PERSONA_KEY", + "MASTER_PERSONAS", + "MASTER_PERSONAS_BY_KEY", +] diff --git a/src/wolfbot/services/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py similarity index 99% rename from src/wolfbot/services/speak_arbiter.py rename to src/wolfbot/master/speak_arbiter.py index 03714db..3387d51 100644 --- a/src/wolfbot/services/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -45,6 +45,8 @@ TtsFailed, TtsFinished, ) +from wolfbot.master.logic_service import build_logic_packet +from wolfbot.master.npc_registry import NpcRegistry from wolfbot.persistence.sqlite_repo import SqliteRepo from wolfbot.services.discussion_service import ( DiscussionService, @@ -53,8 +55,6 @@ from wolfbot.services.discussion_service import ( now_ms as default_now_ms, ) -from wolfbot.services.master_logic_service import build_logic_packet -from wolfbot.services.npc_registry import NpcRegistry log = logging.getLogger(__name__) diff --git a/src/wolfbot/services/stt_service.py b/src/wolfbot/master/stt_service.py similarity index 100% rename from src/wolfbot/services/stt_service.py rename to src/wolfbot/master/stt_service.py diff --git a/src/wolfbot/services/voice_ingest_client.py b/src/wolfbot/master/voice_ingest_client.py similarity index 100% rename from src/wolfbot/services/voice_ingest_client.py rename to src/wolfbot/master/voice_ingest_client.py diff --git a/src/wolfbot/services/voice_ingest_service.py b/src/wolfbot/master/voice_ingest_service.py similarity index 99% rename from src/wolfbot/services/voice_ingest_service.py rename to src/wolfbot/master/voice_ingest_service.py index e71ebd2..6f9e2e2 100644 --- a/src/wolfbot/services/voice_ingest_service.py +++ b/src/wolfbot/master/voice_ingest_service.py @@ -37,12 +37,12 @@ VadSpeechEnded, VadSpeechStarted, ) -from wolfbot.services.stt_service import ( +from wolfbot.master.stt_service import ( SttProviderError, SttResult, SttService, ) -from wolfbot.services.voice_ingest_client import ( +from wolfbot.master.voice_ingest_client import ( MasterIngestionClient, NpcRegistryView, ) diff --git a/src/wolfbot/services/master_ws_server.py b/src/wolfbot/master/ws_server.py similarity index 99% rename from src/wolfbot/services/master_ws_server.py rename to src/wolfbot/master/ws_server.py index 1277521..0634bf6 100644 --- a/src/wolfbot/services/master_ws_server.py +++ b/src/wolfbot/master/ws_server.py @@ -53,7 +53,7 @@ VadSpeechEnded, VadSpeechStarted, ) -from wolfbot.services.npc_registry import NpcRegistry +from wolfbot.master.npc_registry import NpcRegistry log = logging.getLogger(__name__) @@ -200,6 +200,7 @@ async def _handle_register(self, payload: dict[str, Any], ctx: ConnectionContext entry = self.registry.register( npc_id=msg.npc_id, discord_bot_user_id=msg.discord_bot_user_id, + persona_key=msg.persona_key, supported_voices=msg.supported_voices, version=msg.version, send=ctx.send, diff --git a/src/wolfbot/npc/__init__.py b/src/wolfbot/npc/__init__.py new file mode 100644 index 0000000..b266173 --- /dev/null +++ b/src/wolfbot/npc/__init__.py @@ -0,0 +1,9 @@ +"""NPC bot worker package. + +Code that runs only inside an NPC bot worker process (one process per NPC): +the WS client, the Grok-driven speech generator, the VOICEVOX TTS adapter, +and the discord.py voice playback wrapper. + +Master-side counterparts (NPC registry, speak arbiter, ingest, etc.) live +in :mod:`wolfbot.master`. +""" diff --git a/src/wolfbot/services/npc_client.py b/src/wolfbot/npc/client.py similarity index 97% rename from src/wolfbot/services/npc_client.py rename to src/wolfbot/npc/client.py index 6370f0d..3fd75c3 100644 --- a/src/wolfbot/services/npc_client.py +++ b/src/wolfbot/npc/client.py @@ -35,17 +35,17 @@ TtsFailed, TtsFinished, ) -from wolfbot.services.npc_speech_service import NpcSpeechService -from wolfbot.services.tts_service import ( +from wolfbot.npc.playback import ( + VoicePlayback, + VoicePlaybackError, +) +from wolfbot.npc.speech_service import NpcSpeechService +from wolfbot.npc.tts import ( InMemoryTtsCache, TtsProviderError, TtsRequest, TtsService, ) -from wolfbot.services.voice_playback_service import ( - VoicePlayback, - VoicePlaybackError, -) log = logging.getLogger(__name__) @@ -54,6 +54,7 @@ class NpcClientConfig: npc_id: str discord_bot_user_id: str + persona_key: str voice_id: str supported_voices: tuple[str, ...] = () version: str = "0.0.1" @@ -96,6 +97,7 @@ async def register(self, trace_id: str = "register") -> None: trace_id=trace_id, npc_id=self.config.npc_id, discord_bot_user_id=self.config.discord_bot_user_id, + persona_key=self.config.persona_key, supported_voices=self.config.supported_voices, version=self.config.version, ) diff --git a/src/wolfbot/npc/config.py b/src/wolfbot/npc/config.py new file mode 100644 index 0000000..867e932 --- /dev/null +++ b/src/wolfbot/npc/config.py @@ -0,0 +1,64 @@ +"""NPC bot worker settings. + +Loaded once at startup via pydantic-settings. The actual env file path +is selected by :mod:`wolfbot.npc.main` from ``WOLFBOT_NPC_ENV``; per- +persona templates live under ``envs/npc/.env..example`` (see +:file:`envs/npc/README.md`). One NPC worker process = one persona = one +``envs/npc/.env.`` file (each NPC needs its own Discord bot +token and a unique ``NPC_ID``). + +Master-process settings live in :mod:`wolfbot.config`. +""" + +from __future__ import annotations + +from pydantic import SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class NpcSettings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env.npc", env_file_encoding="utf-8", extra="ignore") + + # NPC identity / Discord + NPC_ID: str + NPC_DISCORD_TOKEN: SecretStr + DISCORD_GUILD_ID: int + MAIN_VOICE_CHANNEL_ID: int + + # Persona this NPC bot embodies. Must be a key from + # wolfbot.npc.personas.NPC_PERSONAS_BY_KEY (e.g. "setsu", "gina", ...). + # Each NPC bot process is bound to exactly one persona at startup. + NPC_PERSONA_KEY: str + + # Master WS connection + MASTER_WS_URL: str + MASTER_NPC_PSK: SecretStr + + # ── NPC LLM ─────────────────────────────────────────────────────── + # The LLM that generates this NPC bot's *short reactive utterances* + # during DAY_DISCUSSION in reactive_voice mode. One ~80-character + # in-character line at a time, given the current LogicPacket and + # SpeakRequest from Master. + # + # Scope is intentionally narrow: this LLM does NOT decide votes, + # night actions, or anything else — Master's GAMEPLAY_LLM_* (in + # `.env.master`) handles those decisions for every LLM seat + # regardless of mode. The split lets you point the NPC LLM at a + # cheaper / faster / different-tone model than the Gameplay LLM if + # you want. + # + # Any OpenAI Chat Completions-compatible endpoint works; swap + # providers by changing NPC_LLM_BASE_URL + NPC_LLM_MODEL (xAI Grok, + # OpenAI, Groq, Together, vLLM, Ollama, ...). + NPC_LLM_API_KEY: SecretStr + NPC_LLM_MODEL: str = "grok-4-1-fast" + NPC_LLM_BASE_URL: str = "https://api.x.ai/v1" + + # VOICEVOX TTS + TTS_VOICE_ID: str = "3" + VOICEVOX_URL: str = "http://localhost:50021" + + # Worker tunables + HEARTBEAT_INTERVAL_S: float = 5.0 + LOG_LEVEL: str = "INFO" diff --git a/src/wolfbot/npc_bot_main.py b/src/wolfbot/npc/main.py similarity index 53% rename from src/wolfbot/npc_bot_main.py rename to src/wolfbot/npc/main.py index 45c8e78..936e7b4 100644 --- a/src/wolfbot/npc_bot_main.py +++ b/src/wolfbot/npc/main.py @@ -1,17 +1,18 @@ """NPC bot worker entrypoint. -Reads ``NPC_*`` env vars, connects to Discord VC, opens a WS connection -to Master, registers, and runs the heartbeat + message loop. On -``speak_request`` the NPC generates text via Grok, synthesizes via -VOICEVOX, and plays the audio into the voice channel. +Loads :class:`wolfbot.npc.config.NpcSettings` from the env file pointed +to by ``WOLFBOT_NPC_ENV`` (default ``.env.npc``), connects to Discord +VC, opens a WS connection to Master, registers, and runs the heartbeat ++ message loop. On ``speak_request`` the NPC generates text via the +configured NPC LLM, synthesizes via VOICEVOX, and plays the audio into +the voice channel. Run with:: - uv run wolfbot-npc + WOLFBOT_NPC_ENV=envs/npc/.env. uv run wolfbot-npc -(after exporting the env vars below — ``NPC_ID``, ``NPC_DISCORD_TOKEN``, -``MASTER_WS_URL``, ``MASTER_NPC_PSK``, ``XAI_API_KEY``, ``TTS_VOICE_ID``, -``VOICEVOX_URL``, ``MAIN_VOICE_CHANNEL_ID``.) +Per-persona templates are committed under ``envs/npc/.env..example``; +see :file:`envs/npc/README.md` for the setup workflow and persona table. """ from __future__ import annotations @@ -24,15 +25,11 @@ import time import discord +from dotenv import load_dotenv -log = logging.getLogger(__name__) - +from wolfbot.npc.config import NpcSettings -def _read_env(name: str, *, required: bool = True, default: str | None = None) -> str: - val = os.environ.get(name, default) - if required and not val: - raise SystemExit(f"missing required env var {name}") - return val or "" +log = logging.getLogger(__name__) def _now_ms() -> int: @@ -40,29 +37,38 @@ def _now_ms() -> int: async def _main() -> None: - npc_id = _read_env("NPC_ID") - discord_token = _read_env("NPC_DISCORD_TOKEN") - master_ws_url = _read_env("MASTER_WS_URL") - psk = _read_env("MASTER_NPC_PSK") - xai_api_key = _read_env("XAI_API_KEY") - xai_model = _read_env("XAI_MODEL", required=False, default="grok-4-1-fast") - voice_id = _read_env("TTS_VOICE_ID", required=False, default="3") - voicevox_url = _read_env( - "VOICEVOX_URL", required=False, default="http://localhost:50021") - vc_channel_id = int(_read_env("MAIN_VOICE_CHANNEL_ID")) - guild_id = int(_read_env("DISCORD_GUILD_ID")) - heartbeat_interval = float( - _read_env("HEARTBEAT_INTERVAL_S", required=False, default="5")) - log_level = _read_env("LOG_LEVEL", required=False, default="INFO") + # Per-persona env files live under `envs/npc/.env.` (templates + # are committed at `envs/npc/.env..example`; see + # `envs/npc/README.md`). The launcher must point WOLFBOT_NPC_ENV at the + # right file per process — e.g. `WOLFBOT_NPC_ENV=envs/npc/.env.setsu`. + # `.env.npc` is the legacy fallback when the env var is unset. + env_path = os.environ.get("WOLFBOT_NPC_ENV", ".env.npc") + load_dotenv(env_path) + settings = NpcSettings() # type: ignore[call-arg] logging.basicConfig( - level=getattr(logging, log_level.upper(), logging.INFO), + level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO), format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) + # Validate the persona key against the canonical NPC pool — fail loud at + # startup rather than silently fall back to a default at speak time. + from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY + + if settings.NPC_PERSONA_KEY not in NPC_PERSONAS_BY_KEY: + valid = ", ".join(sorted(NPC_PERSONAS_BY_KEY.keys())) + raise SystemExit( + f"NPC_PERSONA_KEY={settings.NPC_PERSONA_KEY!r} is not a known persona. " + f"Valid keys: {valid}" + ) + log.info( - "npc_bot_starting npc_id=%s ws=%s voice_id=%s voicevox=%s", - npc_id, master_ws_url, voice_id, voicevox_url, + "npc_bot_starting npc_id=%s persona=%s ws=%s voice_id=%s voicevox=%s", + settings.NPC_ID, + settings.NPC_PERSONA_KEY, + settings.MASTER_WS_URL, + settings.TTS_VOICE_ID, + settings.VOICEVOX_URL, ) # ---- Discord client (voice only, no message_content) ---- @@ -76,23 +82,26 @@ async def _main() -> None: @bot.event async def on_ready() -> None: log.info("npc_discord_ready user=%s", bot.user) - guild = bot.get_guild(guild_id) + guild = bot.get_guild(settings.DISCORD_GUILD_ID) if guild is None: - log.error("npc_guild_not_found id=%s", guild_id) + log.error("npc_guild_not_found id=%s", settings.DISCORD_GUILD_ID) return - vc_channel = guild.get_channel(vc_channel_id) + vc_channel = guild.get_channel(settings.MAIN_VOICE_CHANNEL_ID) if vc_channel is None or not isinstance(vc_channel, discord.VoiceChannel): - log.error("npc_vc_channel_not_found id=%s", vc_channel_id) + log.error("npc_vc_channel_not_found id=%s", + settings.MAIN_VOICE_CHANNEL_ID) return try: vc_client_ref[0] = await vc_channel.connect() - log.info("npc_vc_joined channel=%s", vc_channel_id) + log.info("npc_vc_joined channel=%s", settings.MAIN_VOICE_CHANNEL_ID) except Exception: - log.exception("npc_vc_join_failed channel=%s", vc_channel_id) + log.exception("npc_vc_join_failed channel=%s", + settings.MAIN_VOICE_CHANNEL_ID) ready_event.set() # Start Discord in background - discord_task = asyncio.create_task(bot.start(discord_token)) + discord_task = asyncio.create_task( + bot.start(settings.NPC_DISCORD_TOKEN.get_secret_value())) # Wait for VC connection try: @@ -104,25 +113,33 @@ async def on_ready() -> None: discord_user_id = str(bot.user.id) if bot.user else "unknown" # ---- Build NPC pipeline ---- - from wolfbot.services.npc_client import NpcClient, NpcClientConfig - from wolfbot.services.npc_generator_grok import GrokNpcGenerator, GrokNpcGeneratorConfig - from wolfbot.services.npc_speech_service import NpcSpeechService - from wolfbot.services.tts_service import VoicevoxTtsService - from wolfbot.services.voice_playback_service import DiscordVoicePlayback - - generator = GrokNpcGenerator( - api_key=xai_api_key, - config=GrokNpcGeneratorConfig(model=xai_model), + from wolfbot.npc.client import NpcClient, NpcClientConfig + from wolfbot.npc.openai_compatible_generator import ( + OpenAICompatibleConfig, + OpenAICompatibleNpcGenerator, ) + from wolfbot.npc.playback import DiscordVoicePlayback, VoicePlaybackError + from wolfbot.npc.speech_service import NpcSpeechService + from wolfbot.npc.tts import VoicevoxTtsService + + generator = OpenAICompatibleNpcGenerator( + api_key=settings.NPC_LLM_API_KEY.get_secret_value(), + config=OpenAICompatibleConfig( + model=settings.NPC_LLM_MODEL, + base_url=settings.NPC_LLM_BASE_URL, + ), + ) + generator.set_persona(settings.NPC_PERSONA_KEY) speech_service = NpcSpeechService(generator=generator) - tts = VoicevoxTtsService(base_url=voicevox_url, - default_speaker=int(voice_id)) + tts = VoicevoxTtsService( + base_url=settings.VOICEVOX_URL, + default_speaker=int(settings.TTS_VOICE_ID), + ) # ---- Playback function: WAV → discord.VoiceClient.play ---- async def _play_audio(audio: bytes, sample_rate: int) -> tuple[int, int]: vc = vc_client_ref[0] if vc is None or not vc.is_connected(): - from wolfbot.services.voice_playback_service import VoicePlaybackError raise VoicePlaybackError("vc_not_connected") # Convert raw WAV (possibly 24kHz) to PCM source @@ -141,7 +158,6 @@ def _after(error: Exception | None) -> None: finished = _now_ms() if play_error[0] is not None: - from wolfbot.services.voice_playback_service import VoicePlaybackError raise VoicePlaybackError(f"playback_error: {play_error[0]}") return (started, finished) @@ -150,8 +166,12 @@ def _after(error: Exception | None) -> None: # ---- WS connection to Master ---- import websockets - sep = "?" if "?" not in master_ws_url else "&" - ws_url = f"{master_ws_url}{sep}role=npc&psk={psk}" + base_url = settings.MASTER_WS_URL + sep = "?" if "?" not in base_url else "&" + ws_url = ( + f"{base_url}{sep}role=npc" + f"&psk={settings.MASTER_NPC_PSK.get_secret_value()}" + ) ws = await websockets.connect(ws_url) async def _ws_send(msg: str) -> None: @@ -159,9 +179,10 @@ async def _ws_send(msg: str) -> None: client = NpcClient( config=NpcClientConfig( - npc_id=npc_id, + npc_id=settings.NPC_ID, discord_bot_user_id=discord_user_id, - voice_id=voice_id, + persona_key=settings.NPC_PERSONA_KEY, + voice_id=settings.TTS_VOICE_ID, ), speech=speech_service, tts=tts, @@ -172,7 +193,8 @@ async def _ws_send(msg: str) -> None: # Register with Master await client.register() - log.info("npc_registered npc_id=%s user_id=%s", npc_id, discord_user_id) + log.info("npc_registered npc_id=%s user_id=%s", + settings.NPC_ID, discord_user_id) # ---- Background tasks ---- stop = asyncio.Event() @@ -183,7 +205,7 @@ async def _heartbeat_loop() -> None: await client.heartbeat() except Exception: log.exception("npc_heartbeat_failed") - await asyncio.sleep(heartbeat_interval) + await asyncio.sleep(settings.HEARTBEAT_INTERVAL_S) async def _message_loop() -> None: try: @@ -201,7 +223,7 @@ async def _message_loop() -> None: hb_task = asyncio.create_task(_heartbeat_loop()) msg_task = asyncio.create_task(_message_loop()) - log.info("npc_bot_running npc_id=%s", npc_id) + log.info("npc_bot_running npc_id=%s", settings.NPC_ID) # Wait until the message loop or discord dies _done, _pending = await asyncio.wait( @@ -220,7 +242,7 @@ async def _message_loop() -> None: with contextlib.suppress(Exception): await bot.close() - log.info("npc_bot_stopped npc_id=%s", npc_id) + log.info("npc_bot_stopped npc_id=%s", settings.NPC_ID) def main() -> None: diff --git a/src/wolfbot/services/npc_generator_grok.py b/src/wolfbot/npc/openai_compatible_generator.py similarity index 64% rename from src/wolfbot/services/npc_generator_grok.py rename to src/wolfbot/npc/openai_compatible_generator.py index 1263d63..dee7ea2 100644 --- a/src/wolfbot/services/npc_generator_grok.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -1,13 +1,24 @@ -"""Concrete NpcGenerator that calls xAI Grok for reactive speech. +"""Concrete NpcGenerator that calls any OpenAI-compatible chat-completions +endpoint for reactive speech. Given a ``LogicPacket`` (summarised game state, logic candidates, pressure map) and a ``SpeakRequest`` (max chars, phase, intent), this module builds -a minimal Japanese prompt and hits the xAI chat completions endpoint with -structured JSON output. - -The prompt is deliberately simpler than the full ``llm_service`` prompt -pipeline — reactive utterances are short (80-char cap) situational remarks, -not multi-paragraph analytical speeches. The persona's ``style_guide`` and +a minimal Japanese prompt and hits the configured chat-completions endpoint +with structured JSON output. + +The provider is intentionally not baked into the class name. Swap it by +changing :class:`OpenAICompatibleConfig.base_url` and ``model``: + +* xAI Grok — ``base_url="https://api.x.ai/v1"``, ``model="grok-..."`` +* OpenAI — ``base_url="https://api.openai.com/v1"``, ``model="gpt-..."`` +* Groq — ``base_url="https://api.groq.com/openai/v1"`` +* Together AI — ``base_url="https://api.together.xyz/v1"`` +* vLLM / Ollama (OpenAI-compatible mode) — local ``base_url`` + +The default is xAI for back-compat with existing deployments. The prompt is +deliberately simpler than the full ``llm_service`` prompt pipeline — +reactive utterances are short (80-char cap) situational remarks, not +multi-paragraph analytical speeches. The persona's ``style_guide`` and ``speech_profile`` are included for voice consistency but the strategic rules sections are omitted. """ @@ -19,13 +30,12 @@ from dataclasses import dataclass from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, SpeakRequest -from wolfbot.llm.personas import PERSONAS, Persona -from wolfbot.services.npc_speech_service import NpcGeneratedSpeech +from wolfbot.llm.persona_base import Persona +from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY +from wolfbot.npc.speech_service import NpcGeneratedSpeech log = logging.getLogger(__name__) -PERSONAS_BY_KEY: dict[str, Persona] = {p.key: p for p in PERSONAS} - _RESPONSE_SCHEMA: dict[str, object] = { "name": "reactive_speech", "strict": True, @@ -103,28 +113,46 @@ def _format_candidate(c: LogicCandidate) -> str: @dataclass -class GrokNpcGeneratorConfig: +class OpenAICompatibleConfig: + """Backend-agnostic config for any OpenAI Chat Completions endpoint. + + Defaults target xAI Grok for back-compat; override ``base_url`` and + ``model`` to point at OpenAI, Groq, Together, vLLM, Ollama, etc. + """ + model: str = "grok-4-1-fast" + base_url: str = "https://api.x.ai/v1" timeout: float = 15.0 temperature: float = 0.8 - default_persona_key: str = "setsu" -class GrokNpcGenerator: - """Production NpcGenerator backed by xAI Grok.""" +class OpenAICompatibleNpcGenerator: + """Production NpcGenerator backed by any OpenAI-compatible LLM endpoint. + + Implements :class:`wolfbot.npc.speech_service.NpcGenerator` via the + ``openai`` SDK's ``chat.completions`` API. The choice of provider is + a config decision (``base_url`` + ``model``), not a code decision. + """ def __init__( self, *, api_key: str, - config: GrokNpcGeneratorConfig | None = None, + config: OpenAICompatibleConfig | None = None, ) -> None: self._api_key = api_key - self.config = config or GrokNpcGeneratorConfig() + self.config = config or OpenAICompatibleConfig() self._persona_key: str | None = None def set_persona(self, persona_key: str) -> None: - """Set the persona key for this NPC. Called after seat assignment.""" + """Set the persona key for this NPC. Must be called once at startup, + before any ``generate()`` invocation. Raises if the key is unknown. + """ + if persona_key not in NPC_PERSONAS_BY_KEY: + valid = ", ".join(sorted(NPC_PERSONAS_BY_KEY.keys())) + raise ValueError( + f"unknown persona_key {persona_key!r}; valid keys: {valid}" + ) self._persona_key = persona_key async def generate( @@ -135,15 +163,18 @@ async def generate( ) -> NpcGeneratedSpeech | None: from openai import AsyncOpenAI - persona = PERSONAS_BY_KEY.get( - self._persona_key or self.config.default_persona_key, PERSONAS[0] - ) + if self._persona_key is None: + raise RuntimeError( + "OpenAICompatibleNpcGenerator.generate() called before set_persona(); " + "each NPC bot must declare its persona at startup." + ) + persona = NPC_PERSONAS_BY_KEY[self._persona_key] system = _build_system(persona, max_chars=request.max_chars) user = _build_user(logic, request) client = AsyncOpenAI( api_key=self._api_key, - base_url="https://api.x.ai/v1", + base_url=self.config.base_url, ) try: resp = await client.chat.completions.create( # type: ignore[call-overload] @@ -160,14 +191,17 @@ async def generate( timeout=self.config.timeout, ) except Exception: - log.exception("grok_npc_generate_failed") + log.exception( + "npc_generate_failed model=%s base_url=%s", + self.config.model, self.config.base_url, + ) return None content = resp.choices[0].message.content or "{}" try: data = json.loads(content) except json.JSONDecodeError: - log.warning("grok_npc_invalid_json response=%s", content[:200]) + log.warning("npc_generate_invalid_json response=%s", content[:200]) return None text = data.get("text", "").strip() @@ -187,4 +221,4 @@ async def generate( ) -__all__ = ["GrokNpcGenerator", "GrokNpcGeneratorConfig"] +__all__ = ["OpenAICompatibleConfig", "OpenAICompatibleNpcGenerator"] diff --git a/src/wolfbot/llm/personas.py b/src/wolfbot/npc/personas.py similarity index 83% rename from src/wolfbot/llm/personas.py rename to src/wolfbot/npc/personas.py index 6b665a2..a732c38 100644 --- a/src/wolfbot/llm/personas.py +++ b/src/wolfbot/npc/personas.py @@ -1,47 +1,23 @@ -"""Gnosia-flavored personas for LLM players. +"""NPC player personas — Gnosia-flavored character pool. -Persona is the LLM player's in-game identity. Names are taken from Gnosia; style -guidelines describe judgment tendency + speech register so the LLM can stay in -character. Do NOT quote original dialogue; imitate the persona's temperament only. +These are the in-game player identities assigned to LLM seats when humans +don't fill all 9 slots. Master picks from this pool at game start (via +``pick_personas``) and writes the chosen ``persona_key`` onto each LLM +``Seat``; both rounds-mode prompt building (``services.llm_service``) and +reactive_voice NPC speech generation +(:mod:`wolfbot.npc.openai_compatible_generator`) look up the persona by +that key. -`SpeechProfile` holds the structured speech-reproduction data (first-person, -address style, signature phrases, narration mode) that the system prompt's -`## 話法` block consumes. Keep `style_guide` for personality/judgment and -`speech_profile` for 喋り方/語彙/文体 — do not mix the two in free-form prose. -Kukrushka is near-silent in the original, so her `narration_mode` is -`silent_gesture` and the block renders gesture descriptions instead of a normal -conversation profile. +Names are taken from Gnosia; style guidelines describe judgment tendency ++ speech register so the LLM can stay in character. Do NOT quote +original dialogue; imitate the persona's temperament only. """ from __future__ import annotations -from collections.abc import Sequence -from dataclasses import dataclass -from random import Random -from typing import Literal +from wolfbot.llm.persona_base import Persona, SpeechProfile, index_by_key - -@dataclass(frozen=True) -class SpeechProfile: - first_person: str - self_reference_aliases: tuple[str, ...] = () - address_style: str = "" - sentence_style: str = "" - pause_style: str = "" - signature_phrases: tuple[str, ...] = () - forbidden_overuse: tuple[str, ...] = () - narration_mode: Literal["standard", "silent_gesture"] = "standard" - - -@dataclass(frozen=True) -class Persona: - key: str - display_name: str - style_guide: str - speech_profile: SpeechProfile - - -PERSONAS: tuple[Persona, ...] = ( +NPC_PERSONAS: tuple[Persona, ...] = ( Persona( key="setsu", display_name="🟡セツ", @@ -275,19 +251,7 @@ class Persona: ), ) -PERSONAS_BY_KEY: dict[str, Persona] = {p.key: p for p in PERSONAS} - - -def pick_personas(count: int, rng: Random) -> list[Persona]: - """Pick `count` distinct personas at random.""" - if count < 0 or count > len(PERSONAS): - raise ValueError(f"cannot pick {count} personas; pool has {len(PERSONAS)}") - return rng.sample(list(PERSONAS), count) +NPC_PERSONAS_BY_KEY: dict[str, Persona] = index_by_key(NPC_PERSONAS) -def pick_personas_excluding(count: int, exclude_keys: Sequence[str], rng: Random) -> list[Persona]: - """Pick from the pool minus `exclude_keys` — useful if you somehow need to extend.""" - pool = [p for p in PERSONAS if p.key not in set(exclude_keys)] - if count > len(pool): - raise ValueError(f"cannot pick {count} personas; only {len(pool)} available") - return rng.sample(pool, count) +__all__ = ["NPC_PERSONAS", "NPC_PERSONAS_BY_KEY"] diff --git a/src/wolfbot/services/voice_playback_service.py b/src/wolfbot/npc/playback.py similarity index 100% rename from src/wolfbot/services/voice_playback_service.py rename to src/wolfbot/npc/playback.py diff --git a/src/wolfbot/services/npc_speech_service.py b/src/wolfbot/npc/speech_service.py similarity index 100% rename from src/wolfbot/services/npc_speech_service.py rename to src/wolfbot/npc/speech_service.py diff --git a/src/wolfbot/services/tts_service.py b/src/wolfbot/npc/tts.py similarity index 100% rename from src/wolfbot/services/tts_service.py rename to src/wolfbot/npc/tts.py diff --git a/src/wolfbot/services/discord_service.py b/src/wolfbot/services/discord_service.py index 4cba56c..8e6be69 100644 --- a/src/wolfbot/services/discord_service.py +++ b/src/wolfbot/services/discord_service.py @@ -23,7 +23,7 @@ from discord import app_commands from discord.ext import commands -from wolfbot.config import Settings +from wolfbot.config import MasterSettings from wolfbot.domain.enums import ( Phase, Role, @@ -37,7 +37,8 @@ legal_guard_targets, previous_guard_seat_for_night, ) -from wolfbot.llm.personas import pick_personas +from wolfbot.llm.persona_base import pick_personas +from wolfbot.npc.personas import NPC_PERSONAS, NPC_PERSONAS_BY_KEY from wolfbot.persistence.sqlite_repo import ( JoinLobbyResult, LeaveLobbyResult, @@ -50,6 +51,7 @@ from wolfbot.ui.views import NightActionView, VoteView if TYPE_CHECKING: + from wolfbot.master.npc_registry import NpcRegistry from wolfbot.services.discussion_service import DiscussionService log = logging.getLogger(__name__) @@ -121,7 +123,7 @@ def __init__( self, bot: discord.Client, repo: SqliteRepo, - settings: Settings, + settings: MasterSettings, game_service_ref: dict[str, GameService] | None = None, ) -> None: self.bot = bot @@ -412,10 +414,11 @@ def __init__( discord_adapter: DiscordBotAdapter, llm_adapter: LLMAdapter, registry: EngineRegistry, - settings: Settings, + settings: MasterSettings, rng: Random | None = None, discussion_service: DiscussionService | None = None, on_speech_recorded: Callable[[str], Awaitable[None]] | None = None, + npc_registry: NpcRegistry | None = None, ) -> None: super().__init__() self.bot = bot @@ -429,6 +432,67 @@ def __init__( self._create_locks: dict[str, asyncio.Lock] = {} self._discussion_service = discussion_service self._on_speech_recorded = on_speech_recorded + # Set when reactive_voice pipeline is active. In that mode `/wolf + # start` fills LLM seats from online NPC bots (each carrying a fixed + # persona) instead of randomly drawing from NPC_PERSONAS. + self._npc_registry = npc_registry + + def _select_llm_seat_personas( + self, + *, + shortfall: int, + discussion_mode: str, + ) -> tuple[list[tuple[str, str]], str | None]: + """Choose ``(display_name, persona_key)`` pairs to back-fill LLM seats. + + Two backfill strategies, switched on the game's discussion mode: + + * ``rounds``: random draw from :data:`NPC_PERSONAS` (Master drives + these seats internally via xAI; no NPC bot process required). + * ``reactive_voice``: pull online NPC bots from the registry and use + *their* personas (each NPC bot is bound to a fixed persona at + startup). Fails with a friendly message if not enough bots are + online. + + Returns ``(seats, None)`` on success or ``([], error_message)`` on + failure. The caller surfaces the error message via interaction + followup. + """ + if discussion_mode == "reactive_voice" and self._npc_registry is not None: + online = [e for e in self._npc_registry.all_online() if e.persona_key] + # Skip bots already bound to another active game. + available = [e for e in online if e.assigned_seat is None] + if len(available) < shortfall: + return ([], ( + f"reactive_voice モードで LLM 席を {shortfall} 席埋める必要がありますが、" + f"利用可能な NPC bot は {len(available)} 体だけです。" + f"NPC bot プロセスを追加で起動するか、人間プレイヤーを集めてください。" + )) + # Deterministic ordering keeps tests/replay-friendly; pick the + # first `shortfall` bots in registration order. + chosen = available[:shortfall] + seen_keys: set[str] = set() + seats: list[tuple[str, str]] = [] + for entry in chosen: + if entry.persona_key in seen_keys: + return ([], ( + f"NPC bot {entry.npc_id} の persona_key={entry.persona_key} が" + " 重複しています。bot ごとに別の persona を割り当ててください。" + )) + seen_keys.add(entry.persona_key) + persona = NPC_PERSONAS_BY_KEY.get(entry.persona_key) + if persona is None: + return ([], ( + f"NPC bot {entry.npc_id} の persona_key={entry.persona_key} は" + f" 未知の persona です。" + )) + seats.append((persona.display_name, persona.key)) + return (seats, None) + + # rounds mode (or no registry wired): random draw. + picks = pick_personas(NPC_PERSONAS, shortfall, self.rng) + return ([(p.display_name, p.key) for p in picks], None) + def _create_lock_for(self, guild_id: str) -> asyncio.Lock: lock = self._create_locks.get(guild_id) @@ -752,8 +816,16 @@ async def start(self, interaction: discord.Interaction) -> None: return shortfall = 9 - len(humans) - picks = pick_personas(shortfall, self.rng) if shortfall > 0 else [] - llm_seats = [(p.display_name, p.key) for p in picks] + if shortfall > 0: + llm_seats, err = self._select_llm_seat_personas( + shortfall=shortfall, + discussion_mode=game.discussion_mode, + ) + if err is not None: + await interaction.followup.send(err) + return + else: + llm_seats = [] ok = await self.repo.claim_start_and_backfill( game.id, diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index 9c36d23..5b37930 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -34,7 +34,6 @@ legal_guard_targets, previous_guard_seat_for_night, ) -from wolfbot.llm.personas import PERSONAS_BY_KEY from wolfbot.llm.prompt_builder import ( build_system_prompt, build_user_context, @@ -43,6 +42,7 @@ task_vote, task_wolf_chat, ) +from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY if TYPE_CHECKING: # avoid importing heavy modules unless needed from openai import AsyncOpenAI @@ -943,7 +943,7 @@ async def _ask( seats: Sequence[Seat], task_text: str, ) -> LLMAction: - persona = PERSONAS_BY_KEY.get(seat.persona_key or "") + persona = NPC_PERSONAS_BY_KEY.get(seat.persona_key or "") if persona is None: return LLMAction(intent="skip", reason_summary="persona missing") assert player.role is not None diff --git a/tests/test_llm_backfill.py b/tests/test_llm_backfill.py index e91c476..0f7c81c 100644 --- a/tests/test_llm_backfill.py +++ b/tests/test_llm_backfill.py @@ -19,7 +19,8 @@ from wolfbot.domain.enums import Phase from wolfbot.domain.models import Game, Seat -from wolfbot.llm.personas import pick_personas +from wolfbot.llm.persona_base import pick_personas +from wolfbot.npc.personas import NPC_PERSONAS from wolfbot.persistence.sqlite_repo import SqliteRepo from wolfbot.services.game_service import new_game_id @@ -49,7 +50,7 @@ async def _seed_game_with_humans(repo: SqliteRepo, human_count: int) -> str: def _picks_as_specs(count: int, seed: int) -> list[tuple[str, str]]: - picks = pick_personas(count, random.Random(seed)) + picks = pick_personas(NPC_PERSONAS, count, random.Random(seed)) return [(p.display_name, p.key) for p in picks] diff --git a/tests/test_llm_prompt_builder.py b/tests/test_llm_prompt_builder.py index 3d0ab6d..64cea7b 100644 --- a/tests/test_llm_prompt_builder.py +++ b/tests/test_llm_prompt_builder.py @@ -20,7 +20,7 @@ from wolfbot.domain.enums import ROLE_JA, Phase, Role, SubmissionType from wolfbot.domain.models import Game, Player, Seat -from wolfbot.llm.personas import PERSONAS_BY_KEY, Persona, SpeechProfile +from wolfbot.llm.persona_base import Persona, SpeechProfile from wolfbot.llm.prompt_builder import ( _build_game_rules_block, _build_speech_profile_block, @@ -32,6 +32,7 @@ task_vote, task_wolf_chat, ) +from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY as PERSONAS_BY_KEY # --------------------------------------------------------- game rules block diff --git a/tests/test_llm_structured_output.py b/tests/test_llm_structured_output.py index 0fcfa8e..a50ee66 100644 --- a/tests/test_llm_structured_output.py +++ b/tests/test_llm_structured_output.py @@ -10,7 +10,8 @@ from wolfbot.domain.enums import Phase, Role, SubmissionType from wolfbot.domain.models import Game, Seat -from wolfbot.llm.personas import PERSONAS, pick_personas +from wolfbot.llm.persona_base import pick_personas +from wolfbot.npc.personas import NPC_PERSONAS from wolfbot.services.llm_service import ( RESPONSE_SCHEMA, FakeLLMActionDecider, @@ -71,7 +72,7 @@ def test_response_schema_has_required_fields() -> None: def test_pick_personas_returns_requested_count_and_no_duplicates() -> None: rng = random.Random(0) - picks = pick_personas(5, rng) + picks = pick_personas(NPC_PERSONAS, 5, rng) assert len(picks) == 5 assert len({p.key for p in picks}) == 5 @@ -79,7 +80,7 @@ def test_pick_personas_returns_requested_count_and_no_duplicates() -> None: def test_pick_personas_rejects_over_capacity() -> None: rng = random.Random(0) with pytest.raises(ValueError): - pick_personas(len(PERSONAS) + 1, rng) + pick_personas(NPC_PERSONAS, len(NPC_PERSONAS) + 1, rng) # ---------------------------------------------------------- LLMAdapter diff --git a/tests/test_lobby_atomicity.py b/tests/test_lobby_atomicity.py index 1306c49..d721a3c 100644 --- a/tests/test_lobby_atomicity.py +++ b/tests/test_lobby_atomicity.py @@ -13,7 +13,8 @@ from wolfbot.domain.enums import Phase from wolfbot.domain.models import Game, Seat -from wolfbot.llm.personas import pick_personas +from wolfbot.llm.persona_base import pick_personas +from wolfbot.npc.personas import NPC_PERSONAS from wolfbot.persistence.sqlite_repo import ( JoinLobbyResult, LeaveLobbyResult, @@ -98,7 +99,7 @@ async def test_join_lobby_rejects_when_9_humans_present(repo: SqliteRepo) -> Non async def test_join_lobby_rejects_stale_phase_after_start(repo: SqliteRepo) -> None: """Repro for the Codex v2 High finding: stale /wolf join after /wolf start.""" game_id = await _seed_lobby_with_humans(repo, 8) - specs = [(p.display_name, p.key) for p in pick_personas(1, random.Random(0))] + specs = [(p.display_name, p.key) for p in pick_personas(NPC_PERSONAS, 1, random.Random(0))] ok = await repo.claim_start_and_backfill(game_id, expected_phase=Phase.LOBBY, llm_seats=specs) assert ok is True @@ -134,7 +135,7 @@ async def test_leave_lobby_rejects_stale_phase_after_start(repo: SqliteRepo) -> would silently drop a seat and break plan_setup's 9-seat invariant. """ game_id = await _seed_lobby_with_humans(repo, 8) - specs = [(p.display_name, p.key) for p in pick_personas(1, random.Random(0))] + specs = [(p.display_name, p.key) for p in pick_personas(NPC_PERSONAS, 1, random.Random(0))] ok = await repo.claim_start_and_backfill(game_id, expected_phase=Phase.LOBBY, llm_seats=specs) assert ok is True seats_before = await repo.load_seats(game_id) diff --git a/tests/test_master_ws_server.py b/tests/test_master_ws_server.py index 814cd35..2165301 100644 --- a/tests/test_master_ws_server.py +++ b/tests/test_master_ws_server.py @@ -35,21 +35,21 @@ SpeakResult, SpeechEventPayload, ) -from wolfbot.persistence.sqlite_repo import SqliteRepo -from wolfbot.services.discussion_service import ( - DiscussionService, - SqliteSpeechEventStore, -) -from wolfbot.services.master_ingest_service import ( +from wolfbot.master.ingest_service import ( MasterIngestService, PhaseLookup, ) -from wolfbot.services.master_ws_server import ( +from wolfbot.master.npc_registry import InMemoryNpcRegistry +from wolfbot.master.ws_server import ( ConnectionContext, HandlerRegistry, MasterHandlers, ) -from wolfbot.services.npc_registry import InMemoryNpcRegistry +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, +) def _make_send_capture() -> tuple[list[str], Callable[[str], Awaitable[None]]]: @@ -90,8 +90,7 @@ async def test_npc_register_responds_with_npc_registered_and_inserts_registry() npc_id="npc_p5", discord_bot_user_id="bot-uid-5", supported_voices=("ja-JP-A",), - version="1.0.0", - ) + version="1.0.0", persona_key="setsu") await dispatcher.dispatch(msg.model_dump_json(), ctx) assert ctx.tag == "npc_p5" @@ -116,8 +115,7 @@ async def test_heartbeat_refreshes_last_heartbeat_and_revives_offline_npc() -> N await dispatcher.dispatch( NpcRegister( - ts=900, trace_id="t", npc_id="npc_p5", discord_bot_user_id="b5" - ).model_dump_json(), + ts=900, trace_id="t", npc_id="npc_p5", discord_bot_user_id="b5", persona_key="setsu").model_dump_json(), ctx, ) # Force offline. @@ -199,8 +197,7 @@ async def listener(added: set[str], removed: set[str]) -> None: ctx, _ = _make_npc_ctx() await dispatcher.dispatch( NpcRegister( - ts=1, trace_id="t", npc_id="npc_a", discord_bot_user_id="botA" - ).model_dump_json(), + ts=1, trace_id="t", npc_id="npc_a", discord_bot_user_id="botA", persona_key="setsu").model_dump_json(), ctx, ) # Allow the scheduled listener task to run. @@ -223,8 +220,7 @@ async def listener(added: set[str], removed: set[str]) -> None: supported_voices=(), version="0.0.1", send=None, - now_ms=1, - ) + now_ms=1, persona_key="setsu") registry.add_listener(listener) registry.unregister("npc_b", reason="ws_closed") import asyncio @@ -272,8 +268,7 @@ async def test_master_ingest_discards_npc_speaker(repo: SqliteRepo) -> None: supported_voices=(), version="1", send=None, - now_ms=1, - ) + now_ms=1, persona_key="setsu") store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] discussion = DiscussionService(store=store) lookup = _StubPhaseLookup({"g1": (Phase.DAY_DISCUSSION, 1)}) @@ -529,7 +524,7 @@ def test_websockets_master_ws_server_constructs_without_starting( psk_match: bool, ) -> None: """Constructor wiring smoke — no actual socket bind.""" - from wolfbot.services.master_ws_server import WebsocketsMasterWsServer + from wolfbot.master.ws_server import WebsocketsMasterWsServer registry = InMemoryNpcRegistry() handlers = MasterHandlers(registry=registry, now_ms=lambda: 0) diff --git a/tests/test_npc_seat_assignment.py b/tests/test_npc_seat_assignment.py index 9b6048f..64da3d9 100644 --- a/tests/test_npc_seat_assignment.py +++ b/tests/test_npc_seat_assignment.py @@ -1,12 +1,12 @@ -"""NPC seat assignment + GrokNpcGenerator prompt-building tests. +"""NPC seat assignment + OpenAICompatibleNpcGenerator prompt-building tests. Verifies: - _on_reactive_phase_enter assigns online NPCs to LLM seats. - Duplicate assignments are avoided (idempotent on re-enter). - Fewer NPCs than seats → partial assignment. - Fewer seats than NPCs → capped to seat count. -- GrokNpcGenerator prompt construction (system + user messages). -- GrokNpcGenerator skip intent returns None. +- OpenAICompatibleNpcGenerator prompt construction (system + user messages). +- OpenAICompatibleNpcGenerator skip intent returns None. """ from __future__ import annotations @@ -17,14 +17,14 @@ from wolfbot.domain.enums import Phase, Role from wolfbot.domain.models import Game, Seat from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, SpeakRequest -from wolfbot.persistence.sqlite_repo import SqliteRepo -from wolfbot.services.npc_generator_grok import ( - PERSONAS_BY_KEY, +from wolfbot.master.npc_registry import InMemoryNpcRegistry +from wolfbot.npc.openai_compatible_generator import ( _build_system, _build_user, _format_candidate, ) -from wolfbot.services.npc_registry import InMemoryNpcRegistry +from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY as PERSONAS_BY_KEY +from wolfbot.persistence.sqlite_repo import SqliteRepo def _noop_send(buf: list[str]) -> Callable[[str], Awaitable[None]]: @@ -104,8 +104,7 @@ async def test_assigns_online_npcs_to_llm_seats(repo: SqliteRepo) -> None: supported_voices=(), version="1", send=_noop_send([]), - now_ms=1000, - ) + now_ms=1000, persona_key="setsu") await _run_assignment(repo, registry, game.id, phase_id) @@ -127,8 +126,7 @@ async def test_idempotent_reenter_does_not_reassign(repo: SqliteRepo) -> None: supported_voices=(), version="1", send=_noop_send([]), - now_ms=1000, - ) + now_ms=1000, persona_key="setsu") await _run_assignment(repo, registry, game.id, phase_id) entry = registry.get("npc_x") @@ -152,8 +150,7 @@ async def test_fewer_npcs_than_seats_partial_assignment(repo: SqliteRepo) -> Non supported_voices=(), version="1", send=_noop_send([]), - now_ms=1000, - ) + now_ms=1000, persona_key="setsu") await _run_assignment(repo, registry, game.id, phase_id) entry = registry.get("npc_only") @@ -178,8 +175,7 @@ async def test_more_npcs_than_seats_capped(repo: SqliteRepo) -> None: supported_voices=(), version="1", send=_noop_send([]), - now_ms=1000, - ) + now_ms=1000, persona_key="setsu") await _run_assignment(repo, registry, game.id, phase_id) assigned_count = sum( @@ -188,7 +184,7 @@ async def test_more_npcs_than_seats_capped(repo: SqliteRepo) -> None: assert assigned_count == 3 # capped at LLM seat count -# ---- GrokNpcGenerator prompt-building unit tests ---- +# ---- OpenAICompatibleNpcGenerator prompt-building unit tests ---- def test_build_system_prompt_contains_persona_fields() -> None: diff --git a/tests/test_npc_voice_worker.py b/tests/test_npc_voice_worker.py index d6c482a..cd64936 100644 --- a/tests/test_npc_voice_worker.py +++ b/tests/test_npc_voice_worker.py @@ -31,22 +31,22 @@ SpeakResult, TtsFinished, ) -from wolfbot.services.npc_client import NpcClient, NpcClientConfig -from wolfbot.services.npc_speech_service import ( +from wolfbot.npc.client import NpcClient, NpcClientConfig +from wolfbot.npc.playback import ( + FakeVoicePlayback, + VoicePlaybackError, +) +from wolfbot.npc.speech_service import ( FakeNpcGenerator, NpcGeneratedSpeech, NpcSpeechService, ) -from wolfbot.services.tts_service import ( +from wolfbot.npc.tts import ( FakeTtsService, InMemoryTtsCache, TtsProviderError, TtsResult, ) -from wolfbot.services.voice_playback_service import ( - FakeVoicePlayback, - VoicePlaybackError, -) def _make_client( @@ -67,6 +67,7 @@ async def send(msg: str) -> None: config=NpcClientConfig( npc_id="npc_p2", discord_bot_user_id="bot2", + persona_key="setsu", voice_id="ja-Standard-A", ), speech=speech, @@ -374,7 +375,7 @@ def test_npc_bot_main_module_loads() -> None: """Smoke-load the entrypoint module to catch import-time regressions.""" import importlib - mod = importlib.import_module("wolfbot.npc_bot_main") + mod = importlib.import_module("wolfbot.npc.main") assert hasattr(mod, "main") diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index 2c06917..35ed6e2 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -29,15 +29,15 @@ PlaybackRejected, SpeakResult, ) +from wolfbot.master.logic_service import build_logic_packet +from wolfbot.master.npc_registry import InMemoryNpcRegistry +from wolfbot.master.speak_arbiter import SpeakArbiter, SpeakArbiterConfig from wolfbot.persistence.sqlite_repo import SqliteRepo from wolfbot.services.discussion_service import ( DiscussionService, SqliteSpeechEventStore, make_phase_baseline, ) -from wolfbot.services.master_logic_service import build_logic_packet -from wolfbot.services.npc_registry import InMemoryNpcRegistry -from wolfbot.services.speak_arbiter import SpeakArbiter, SpeakArbiterConfig def _captured_send(buf: list[str]) -> Callable[[str], Awaitable[None]]: @@ -99,8 +99,7 @@ async def test_successful_dispatch_emits_logic_packet_and_speak_request( supported_voices=(), version="1", send=_captured_send(npc_buf), - now_ms=1000, - ) + now_ms=1000, persona_key="setsu") store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] discussion = DiscussionService(store=store) arb = SpeakArbiter( @@ -134,8 +133,7 @@ async def test_dispatch_records_request_in_audit_table(repo: SqliteRepo) -> None supported_voices=(), version="1", send=_captured_send(npc_buf), - now_ms=1000, - ) + now_ms=1000, persona_key="setsu") discussion = DiscussionService( store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] ) @@ -173,8 +171,7 @@ async def test_human_speaking_blocks_dispatch(repo: SqliteRepo) -> None: supported_voices=(), version="1", send=_captured_send(npc_buf), - now_ms=1000, - ) + now_ms=1000, persona_key="setsu") discussion = DiscussionService( store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] ) @@ -203,8 +200,7 @@ async def test_speak_result_accepted_emits_authorized_and_writes_speech_event( supported_voices=(), version="1", send=_captured_send(npc_buf), - now_ms=1000, - ) + now_ms=1000, persona_key="setsu") store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] discussion = DiscussionService(store=store) arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500) @@ -249,8 +245,7 @@ async def test_speak_result_over_length_rejected(repo: SqliteRepo) -> None: supported_voices=(), version="1", send=_captured_send(npc_buf), - now_ms=1000, - ) + now_ms=1000, persona_key="setsu") discussion = DiscussionService( store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] ) @@ -296,8 +291,7 @@ async def test_speak_result_stale_phase_rejected(repo: SqliteRepo) -> None: supported_voices=(), version="1", send=_captured_send(npc_buf), - now_ms=1000, - ) + now_ms=1000, persona_key="setsu") discussion = DiscussionService( store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] ) @@ -338,8 +332,7 @@ async def test_serial_speech_blocks_after_authorize_until_finished( supported_voices=(), version="1", send=_captured_send(npc_buf), - now_ms=1000, - ) + now_ms=1000, persona_key="setsu") discussion = DiscussionService( store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] ) diff --git a/tests/test_reactive_voice_mode.py b/tests/test_reactive_voice_mode.py index 651b010..d1f00fd 100644 --- a/tests/test_reactive_voice_mode.py +++ b/tests/test_reactive_voice_mode.py @@ -77,14 +77,14 @@ async def test_phase_advance_under_reactive_voice_skips_round_gate() -> None: async def test_settings_loads_default_discussion_mode(monkeypatch) -> None: # type: ignore[no-untyped-def] """The Settings object exposes LLM_DISCUSSION_MODE with rounds default.""" monkeypatch.setenv("DISCORD_TOKEN", "dummy") - monkeypatch.setenv("XAI_API_KEY", "dummy") + monkeypatch.setenv("GAMEPLAY_LLM_API_KEY", "dummy") monkeypatch.setenv("DISCORD_GUILD_ID", "1") monkeypatch.setenv("MAIN_TEXT_CHANNEL_ID", "1") monkeypatch.setenv("MAIN_VOICE_CHANNEL_ID", "1") monkeypatch.delenv("LLM_DISCUSSION_MODE", raising=False) - from wolfbot.config import Settings + from wolfbot.config import MasterSettings - s = Settings() # type: ignore[call-arg] + s = MasterSettings() # type: ignore[call-arg] assert s.LLM_DISCUSSION_MODE == "rounds" @@ -541,13 +541,13 @@ async def test_main_py_wires_reactive_voice_pipeline_services() -> None: constructors accept the expected parameters.""" import inspect - from wolfbot.services.master_ingest_service import MasterIngestService - from wolfbot.services.master_ws_server import ( + from wolfbot.master.ingest_service import MasterIngestService + from wolfbot.master.npc_registry import InMemoryNpcRegistry + from wolfbot.master.speak_arbiter import SpeakArbiter + from wolfbot.master.ws_server import ( MasterHandlers, WebsocketsMasterWsServer, ) - from wolfbot.services.npc_registry import InMemoryNpcRegistry - from wolfbot.services.speak_arbiter import SpeakArbiter sig = inspect.signature(WebsocketsMasterWsServer.__init__) assert "host" in sig.parameters @@ -640,12 +640,12 @@ async def test_arbiter_try_dispatch_next_triggers_on_reactive_voice(repo: Sqlite """SpeakArbiter.try_dispatch_next dispatches a SpeakRequest when a reactive_voice game has an online NPC in a discussion phase.""" from wolfbot.domain.models import Seat + from wolfbot.master.npc_registry import InMemoryNpcRegistry + from wolfbot.master.speak_arbiter import SpeakArbiter from wolfbot.services.discussion_service import ( DiscussionService, SqliteSpeechEventStore, ) - from wolfbot.services.npc_registry import InMemoryNpcRegistry - from wolfbot.services.speak_arbiter import SpeakArbiter game = Game( id=new_game_id(), @@ -684,8 +684,7 @@ async def _fake_send(msg: str) -> None: supported_voices=(), version="1", send=_fake_send, - now_ms=1000, - ) + now_ms=1000, persona_key="setsu") registry.assign("npc2", seat=2, game_id=game.id, phase_id="test") arbiter = SpeakArbiter( @@ -713,12 +712,12 @@ async def _fake_send(msg: str) -> None: async def test_arbiter_try_dispatch_next_noop_for_rounds(repo: SqliteRepo) -> None: """try_dispatch_next must no-op for rounds-mode games.""" + from wolfbot.master.npc_registry import InMemoryNpcRegistry + from wolfbot.master.speak_arbiter import SpeakArbiter from wolfbot.services.discussion_service import ( DiscussionService, SqliteSpeechEventStore, ) - from wolfbot.services.npc_registry import InMemoryNpcRegistry - from wolfbot.services.speak_arbiter import SpeakArbiter game = Game( id=new_game_id(), @@ -746,12 +745,12 @@ async def test_arbiter_try_dispatch_next_noop_for_rounds(repo: SqliteRepo) -> No async def test_recovery_sweep_closes_open_speak_requests(repo: SqliteRepo) -> None: """reactive_voice_recovery_sweep must close open npc_speak_requests and npc_playback_events with failure_reason=master_restart.""" + from wolfbot.master.npc_registry import InMemoryNpcRegistry + from wolfbot.master.speak_arbiter import SpeakArbiter from wolfbot.services.discussion_service import ( DiscussionService, SqliteSpeechEventStore, ) - from wolfbot.services.npc_registry import InMemoryNpcRegistry - from wolfbot.services.speak_arbiter import SpeakArbiter game = Game( id=new_game_id(), @@ -955,8 +954,8 @@ async def noop_apply(*_a: Any, **_k: Any) -> None: async def test_ws_authenticate_reads_request_path() -> None: """WebsocketsMasterWsServer._authenticate must read ws.request.path (websockets 16.0) rather than the legacy ws.path.""" - from wolfbot.services.master_ws_server import MasterHandlers, WebsocketsMasterWsServer - from wolfbot.services.npc_registry import InMemoryNpcRegistry + from wolfbot.master.npc_registry import InMemoryNpcRegistry + from wolfbot.master.ws_server import MasterHandlers, WebsocketsMasterWsServer registry = InMemoryNpcRegistry() handlers = MasterHandlers(registry=registry, now_ms=lambda: 0) @@ -989,8 +988,8 @@ async def close(self, code: int = 1000, reason: str = "") -> None: async def test_ws_authenticate_rejects_bad_psk() -> None: """Auth must reject when psk doesn't match.""" - from wolfbot.services.master_ws_server import MasterHandlers, WebsocketsMasterWsServer - from wolfbot.services.npc_registry import InMemoryNpcRegistry + from wolfbot.master.npc_registry import InMemoryNpcRegistry + from wolfbot.master.ws_server import MasterHandlers, WebsocketsMasterWsServer registry = InMemoryNpcRegistry() handlers = MasterHandlers(registry=registry, now_ms=lambda: 0) diff --git a/tests/test_state_machine_nights.py b/tests/test_state_machine_nights.py index 5872f68..e77febb 100644 --- a/tests/test_state_machine_nights.py +++ b/tests/test_state_machine_nights.py @@ -622,11 +622,12 @@ def test_llm_shortfall_padding_count() -> None: """Given N humans, pick_personas(9-N) returns exactly the shortfall.""" import random as _r - from wolfbot.llm.personas import pick_personas + from wolfbot.llm.persona_base import pick_personas + from wolfbot.npc.personas import NPC_PERSONAS rng = _r.Random(0) for n in range(0, 10): - picks = pick_personas(9 - n, rng) + picks = pick_personas(NPC_PERSONAS, 9 - n, rng) assert len(picks) == 9 - n assert len({p.key for p in picks}) == 9 - n diff --git a/tests/test_voice_ingest_service.py b/tests/test_voice_ingest_service.py index e03aca9..93d146f 100644 --- a/tests/test_voice_ingest_service.py +++ b/tests/test_voice_ingest_service.py @@ -13,17 +13,17 @@ from __future__ import annotations -from wolfbot.services.stt_service import ( +from wolfbot.master.stt_service import ( FakeSttService, SttProviderError, SttResult, ) -from wolfbot.services.voice_ingest_client import ( +from wolfbot.master.voice_ingest_client import ( FakeMasterIngestionClient, InMemoryNpcRegistryView, make_default_listeners, ) -from wolfbot.services.voice_ingest_service import ( +from wolfbot.master.voice_ingest_service import ( VoiceIngestConfig, VoiceIngestService, ) From ecd1cfa98d348874d69d95487f3903c19d51579e Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 15:49:12 +0900 Subject: [PATCH 004/133] Add judgment profiles and public deduction prompts --- src/wolfbot/domain/discussion.py | 9 + src/wolfbot/domain/ws_messages.py | 14 + src/wolfbot/llm/persona_base.py | 38 +++ src/wolfbot/llm/prompt_builder.py | 120 +++++++- src/wolfbot/master/audio_sink.py | 6 +- src/wolfbot/master/ingest_service.py | 1 + src/wolfbot/master/speak_arbiter.py | 1 + src/wolfbot/master/stt_service.py | 12 + src/wolfbot/master/voice_ingest_service.py | 4 + .../npc/openai_compatible_generator.py | 22 +- src/wolfbot/npc/personas.py | 105 ++++++- src/wolfbot/npc/speech_service.py | 7 +- src/wolfbot/npc/tts.py | 11 +- src/wolfbot/persistence/schema.py | 5 + src/wolfbot/prompts/llm_system_prompt.md | 23 ++ src/wolfbot/services/deduction_service.py | 264 ++++++++++++++++++ src/wolfbot/services/discussion_service.py | 92 ++++-- src/wolfbot/services/llm_service.py | 74 ++++- tests/test_deduction_service.py | 181 ++++++++++++ tests/test_llm_prompt_builder.py | 159 ++++++++++- tests/test_llm_structured_output.py | 1 + tests/test_public_discussion_state.py | 69 +++++ 22 files changed, 1165 insertions(+), 53 deletions(-) create mode 100644 src/wolfbot/services/deduction_service.py create mode 100644 tests/test_deduction_service.py diff --git a/src/wolfbot/domain/discussion.py b/src/wolfbot/domain/discussion.py index 68c16e6..f8946f0 100644 --- a/src/wolfbot/domain/discussion.py +++ b/src/wolfbot/domain/discussion.py @@ -78,6 +78,15 @@ class SpeechEvent(BaseModel): audio_end_ms: int | None = None summary: str | None = None alive_seat_nos_json: str | None = None + co_declaration: str | None = Field( + default=None, + description=( + "Structured CO self-declaration tag (`seer` / `medium` / `knight`) " + "extracted at the source: schema field for NPC/LLM speech, " + "Gemini's `co_claim` for human voice. Authoritative when set; " + "legacy events fall back to `_CO_MARKERS` substring scan." + ), + ) created_at_ms: int def is_baseline(self) -> bool: diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index 5069a84..8a827ac 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -106,6 +106,13 @@ class SpeakResult(BaseEnvelope): intent: str | None = None estimated_duration_ms: int | None = None failure_reason: str | None = None + co_declaration: Literal["seer", "medium", "knight"] | None = Field( + default=None, + description=( + "Structured CO self-declaration tag set by the NPC's speech " + "generator. Authoritative — Master persists it on SpeechEvent." + ), + ) class PlaybackAuthorized(BaseEnvelope): @@ -191,6 +198,13 @@ class SpeechEventPayload(BaseEnvelope): audio_start_ms: int audio_end_ms: int summary: str | None = None + co_declaration: Literal["seer", "medium", "knight"] | None = Field( + default=None, + description=( + "Structured CO self-declaration extracted by Gemini's audio " + "analyzer (`co_claim` field). Master persists it on SpeechEvent." + ), + ) class SttFailed(BaseEnvelope): diff --git a/src/wolfbot/llm/persona_base.py b/src/wolfbot/llm/persona_base.py index c690e92..bd6662e 100644 --- a/src/wolfbot/llm/persona_base.py +++ b/src/wolfbot/llm/persona_base.py @@ -40,12 +40,49 @@ class SpeechProfile: narration_mode: Literal["standard", "silent_gesture"] = "standard" +@dataclass(frozen=True) +class JudgmentProfile: + """Structured judgment-tendency axes consumed by the prompt builder. + + Each axis is 0.0-1.0. Values are rendered as labeled qualitative bands + in the system prompt so the LLM has a concrete tendency to lean toward, + independent of the free-form `style_guide` prose. + + Defaults are neutral so personas without explicit values still render + cleanly. Overrides per persona shape behaviour: + + - `trust_hard_facts`: how much weight Master's HARD-confidence deductions + get. Logical personas (Raqio) ≈ 1.0; defiant/wolf-leaning personas + can dip to 0.7 to keep verbal cover for muddling logic. + - `trust_medium_facts`: weight for MEDIUM-confidence deductions. + Conservative seers and analyzers stay high (0.7-0.9); deceiver-leaning + personas drop lower (0.3-0.5) to keep room for contrarian reads. + - `contrarian_bias`: tendency to deliberately question majority view. + Wolves & disruptors high; loyal villagers low. + - `aggression`: speed of moving from suspicion to active accusation. + - `bandwagon_tendency`: how readily the persona joins forming consensus. + + The system prompt rendering pairs these axes with explicit guidance — + the persona doesn't have to compute them, just lean toward them. + """ + + trust_hard_facts: float = 1.0 + trust_medium_facts: float = 0.7 + contrarian_bias: float = 0.0 + aggression: float = 0.5 + bandwagon_tendency: float = 0.5 + + +_DEFAULT_JUDGMENT_PROFILE = JudgmentProfile() + + @dataclass(frozen=True) class Persona: key: str display_name: str style_guide: str speech_profile: SpeechProfile + judgment_profile: JudgmentProfile = _DEFAULT_JUDGMENT_PROFILE def index_by_key(pool: Sequence[Persona]) -> dict[str, Persona]: @@ -80,6 +117,7 @@ def pick_personas_excluding( __all__ = [ + "JudgmentProfile", "Persona", "SpeechProfile", "index_by_key", diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index b7368c1..a761e6f 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -567,6 +567,86 @@ def _build_strategy_block(role: Role) -> str: return _ROLE_STRATEGIES[role] +def _band(value: float, *, low: str, mid_low: str, mid: str, mid_high: str, high: str) -> str: + """Map a 0.0-1.0 axis to one of five qualitative bands. + + Five-step granularity gives the LLM enough nuance without exposing the + raw float (which would invite spurious precision). Boundaries are + chosen so the neutral 0.5 default sits squarely on `mid`. + """ + if value <= 0.2: + return low + if value <= 0.4: + return mid_low + if value <= 0.6: + return mid + if value <= 0.8: + return mid_high + return high + + +def _build_judgment_profile_block(persona: Persona) -> str: + """Render `JudgmentProfile` axes as labeled tendency bands. + + Each axis is mapped to a qualitative band so the LLM has a concrete + behavioural lean without seeing the raw float. The block is paired + with a usage hint that names HARD/MEDIUM facts so the trust axes + have something concrete to attach to. + """ + j = persona.judgment_profile + trust_hard = _band( + j.trust_hard_facts, + low="ほぼ無視 (理屈より直感)", + mid_low="やや軽視", + mid="標準", + mid_high="重視", + high="絶対視 (論理確定は揺るがない)", + ) + trust_medium = _band( + j.trust_medium_facts, + low="ほぼ参考にしない", + mid_low="懐疑的に扱う", + mid="参考程度", + mid_high="やや信用する", + high="基本受け入れる", + ) + contrarian = _band( + j.contrarian_bias, + low="多数派にあえて逆らわない", + mid_low="やや迎合的", + mid="是々非々", + mid_high="多数派に懐疑的", + high="あえて逆張りする傾向", + ) + aggression = _band( + j.aggression, + low="慎重で疑い先を出すのが遅い", + mid_low="控えめに疑う", + mid="標準的に疑い先を出す", + mid_high="積極的に疑い先を指す", + high="即座に処刑候補を名指しする", + ) + bandwagon = _band( + j.bandwagon_tendency, + low="単独行動を好み流れに乗らない", + mid_low="独自路線を好む", + mid="状況次第", + mid_high="形成された流れに乗りやすい", + high="多数派・流れに強く乗る", + ) + return ( + f"- 論理確定 (HARD ファクト) への態度: {trust_hard}\n" + f"- 推測根拠 (MEDIUM ファクト) への態度: {trust_medium}\n" + f"- 多数派への姿勢: {contrarian}\n" + f"- 攻撃性 (疑い→処刑候補名指しまでの速さ): {aggression}\n" + f"- 流れへの追従度: {bandwagon}\n" + "- 上記は判断のクセであり、ルールや論理確定情報を上書きしない。" + "HARD ファクトは原則として受け入れた上で、態度に応じた言い回しに調整する。" + "MEDIUM ファクトは「態度」に応じて採用度合いを変える。" + "この性格を口調と判断の傾きとして表現してください。" + ) + + def _build_speech_profile_block(persona: Persona) -> str: """Render the persona's structured speech profile as a bullet block. @@ -622,6 +702,7 @@ def build_system_prompt( return ( template.replace("{game_rules_block}", _build_game_rules_block()) .replace("{persona_block}", persona_block) + .replace("{judgment_profile_block}", _build_judgment_profile_block(persona)) .replace("{speech_profile_block}", _build_speech_profile_block(persona)) .replace("{role_block}", role_block) .replace("{strategy_block}", _build_strategy_block(role)) @@ -662,6 +743,7 @@ def build_user_context( public_logs: Sequence[dict[str, object]], private_logs: Sequence[dict[str, object]], last_own_public: str | None = None, + deduced_facts_block: str | None = None, ) -> str: seats_by_no = {s.seat_no: s for s in seats} alive_players = [p for p in players if p.alive] @@ -700,6 +782,15 @@ def _format_log(log: dict[str, object], *, attributed_kinds: tuple[str, ...]) -> rope_block = _format_rope_block(players) + facts_section = "" + if deduced_facts_block: + facts_section = ( + "\n## 公開情報からの確定/推測事実 (Master 整理)\n" + f"{deduced_facts_block}\n" + "HARD は論理的に確定。MEDIUM は強めの推測。" + "判断傾向に応じて態度を変えてよいが、HARD を覆す論拠は公開ログにある具体物だけにする。\n" + ) + return ( f"あなたは座席 {my_seat.seat_no}『{my_seat.display_name}』です。\n" f"生存者: {alive_names}\n" @@ -708,6 +799,7 @@ def _format_log(log: dict[str, object], *, attributed_kinds: tuple[str, ...]) -> f"{wolf_partner_block}" "\n" f"{rope_block}\n" + f"{facts_section}" "\n" "## あなたの私的メモ (他者には非公開)\n" f"{priv_block}\n" @@ -726,21 +818,39 @@ def task_daytime_speech(day_number: int, discussion_round: int | None = None) -> f"現在は day {day_number} の議論フェイズです。" " 必要と感じた場合のみ `intent=speak` を返し、`public_message` に 80〜300 字で短い発言を書いてください。" " 発言したくない場合は `intent=skip` と明示してください。" - " 疑い先を出すときは、単体黒要素だけでなく、その人物が人狼なら相方候補は誰か、" + " 疑い先を出すときは、単体の怪しさだけでなく、その人物が人狼なら相方候補は誰か、" "2 人狼セットとして票筋・噛み筋が自然かも必要に応じて短く触れてください。" "全候補のペアを長く列挙せず、今の結論に効く 1〜2 点だけを出してください。" + "\n発話 (`public_message`) のルール:" + " 自然な日本語で喋ること。" + "「CO」「占いCO」「霊媒CO」「騎士CO」「黒判定」「白判定」「ライン」「グレー」「グレラン」" + "「縄」「PP」「RPP」「ローラー」「ロラ」「破綻」「確白」「確黒」「パンダ」「鉄板護衛」「捨て護衛」" + "「噛み筋」「票筋」「視点漏れ」「身内切り」「囲い」など、" + "プレイヤー間で使われがちなメタ用語は `public_message` 内で使わない" + "(これらは内部の `reason_summary` や思考メモには使ってよい)。" + "口に出すときは状況や感情として描写する" + "(例: 「あの白判定、無理に庇ってる気がして信用できない」「昨夜守ったのは◯◯です」" + "「あと処刑できる回数を考えると…」)。" + "役職 CO したい場合は `co_declaration` を `\"seer\" / \"medium\" / \"knight\"` のいずれかに設定し、" + "`public_message` 側は「実は私、占い師なんだ」のように自然に名乗ってから能力結果を続ける。" + "`co_declaration` を設定しないなら `null`。" ) if day_number >= 2 and discussion_round == 1: base += ( " これは day 2 以降の 1 巡目発言です。" - " 占い師・霊媒師・騎士として CO 済み、または今 CO する場合は、" + " 占い師・霊媒師・騎士として既に名乗っている、または今初めて名乗る場合は、" "前夜の能力結果をこの発言で添えてください。" " 占い師なら対象 + 白/黒 + 占い理由、" "霊媒師なら前日処刑者 + 人狼/人狼ではない/結果なし、" - "騎士なら CO する局面で合法な護衛履歴 (護衛日 + 護衛先) を日付順に出し、" - "平和な朝なら護衛成功も合わせて主張します。" - " 結果を持つ、または結果を主張する役職 CO が 1 巡目で結果を出さないと、" + "騎士なら名乗る局面で合法な護衛履歴 (護衛日 + 護衛先) を日付順に出し、" + "平和な朝なら護衛成功も合わせて伝えます。" + " 結果を持つはずの役職を名乗っているのに 1 巡目で結果を出さないと、" "信用低下や破綻疑いにつながります。" + " ただし `public_message` 内ではこの「白/黒」「結果なし」のような内部メモ語彙を" + "そのまま読み上げず、「結果は人狼でした」「占ったけど人狼じゃなかった」" + "「占ってみた感触は白かな」のように自然な発話に書き直す。" + " 名乗り直すならこのターンで `co_declaration` を立て、" + "`public_message` でも自然な日本語で名乗りと結果を述べてください。" ) return base diff --git a/src/wolfbot/master/audio_sink.py b/src/wolfbot/master/audio_sink.py index e020e55..a75ef50 100644 --- a/src/wolfbot/master/audio_sink.py +++ b/src/wolfbot/master/audio_sink.py @@ -27,7 +27,7 @@ log = logging.getLogger(__name__) -class WolfbotAudioSink(voice_recv.AudioSink): # type: ignore[misc] +class WolfbotAudioSink(voice_recv.AudioSink): """Receives per-user PCM and routes it to VoiceIngestService.""" def __init__( @@ -59,7 +59,7 @@ def cleanup(self) -> None: # ---- VAD via speaking indicators ---- - @voice_recv.AudioSink.listener() # type: ignore[misc] + @voice_recv.AudioSink.listener() # type: ignore[untyped-decorator] def on_voice_member_speaking_start(self, member: discord.Member) -> None: uid = str(member.id) asyncio.run_coroutine_threadsafe( @@ -67,7 +67,7 @@ def on_voice_member_speaking_start(self, member: discord.Member) -> None: self._loop, ) - @voice_recv.AudioSink.listener() # type: ignore[misc] + @voice_recv.AudioSink.listener() # type: ignore[untyped-decorator] def on_voice_member_speaking_stop(self, member: discord.Member) -> None: uid = str(member.id) asyncio.run_coroutine_threadsafe( diff --git a/src/wolfbot/master/ingest_service.py b/src/wolfbot/master/ingest_service.py index 427049c..1ea652c 100644 --- a/src/wolfbot/master/ingest_service.py +++ b/src/wolfbot/master/ingest_service.py @@ -127,6 +127,7 @@ async def ingest_voice( audio_start_ms=payload.audio_start_ms, audio_end_ms=payload.audio_end_ms, summary=payload.summary, + co_declaration=payload.co_declaration, created_at_ms=default_now_ms(), ) await self.discussion.record(event) diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index 3387d51..3db4fe2 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -373,6 +373,7 @@ async def _record_rejection(reason: str) -> None: speaker_kind="npc", # type: ignore[arg-type] speaker_seat=pending.seat_no, text=result.text, + co_declaration=result.co_declaration, created_at_ms=now, ) await self.discussion.record(speech_event) diff --git a/src/wolfbot/master/stt_service.py b/src/wolfbot/master/stt_service.py index 650483f..e43daf9 100644 --- a/src/wolfbot/master/stt_service.py +++ b/src/wolfbot/master/stt_service.py @@ -35,12 +35,18 @@ class SttResult: `summary` is an optional structured analysis of the utterance content, populated by providers that combine STT + inference in one call (e.g. GeminiAudioAnalyzer). Providers that only do transcription leave it None. + + `co_declaration` is a structured CO tag (`seer` / `medium` / `knight`) + extracted from the utterance by providers that infer it (Gemini's + `co_claim`). Authoritative when set; otherwise the discussion service + falls back to substring matching on `text` for legacy compatibility. """ text: str confidence: float duration_ms: int summary: str | None = None + co_declaration: str | None = None class SttProviderError(RuntimeError): @@ -267,6 +273,11 @@ async def transcribe( summary_str = json.dumps( summary_dict, ensure_ascii=False) if summary_dict else None + co_raw = parsed.get("co_claim") + co_declaration = ( + co_raw if co_raw in ("seer", "medium", "knight") else None + ) + # Estimate duration from audio size (assume 16kHz 16-bit mono WAV) data_bytes = max(0, len(audio) - 44) duration_ms = int(data_bytes / (16_000 * 2) * 1000) @@ -276,6 +287,7 @@ async def transcribe( confidence=confidence, duration_ms=duration_ms, summary=summary_str, + co_declaration=co_declaration, ) except SttProviderError: diff --git a/src/wolfbot/master/voice_ingest_service.py b/src/wolfbot/master/voice_ingest_service.py index 6f9e2e2..de2344f 100644 --- a/src/wolfbot/master/voice_ingest_service.py +++ b/src/wolfbot/master/voice_ingest_service.py @@ -281,6 +281,9 @@ async def _run_stt( ) return + co_decl = result.co_declaration if result.co_declaration in ( + "seer", "medium", "knight", + ) else None await self.master_client.send_speech_event_payload( SpeechEventPayload( ts=self._now_ms(), @@ -296,6 +299,7 @@ async def _run_stt( audio_start_ms=seg.audio_start_ms, audio_end_ms=audio_end_ms, summary=result.summary, + co_declaration=co_decl, # type: ignore[arg-type] ) ) diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index dee7ea2..6455732 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -42,7 +42,7 @@ "schema": { "type": "object", "additionalProperties": False, - "required": ["text", "intent", "used_logic_ids"], + "required": ["text", "intent", "used_logic_ids", "co_declaration"], "properties": { "text": {"type": "string", "maxLength": 300}, "intent": { @@ -53,6 +53,10 @@ "type": "array", "items": {"type": "string"}, }, + "co_declaration": { + "type": ["string", "null"], + "enum": ["seer", "medium", "knight", None], + }, }, }, } @@ -77,6 +81,19 @@ def _build_system(persona: Persona, max_chars: int) -> str: f"- `text` は {max_chars} 文字以内の短い発言。\n" "- 発言しない場合は intent を `skip`、text を空文字にする。\n" "- `used_logic_ids` には参考にした logic candidate の id を入れる。\n" + "- **`text` 内で人狼用語(メタ語彙)を使わない。** 内部の思考では使ってよいが、" + "実際に喋る発話は素朴な日本語にする。\n" + " 禁止例: 「CO」「占いCO」「霊媒CO」「騎士CO」「黒判定」「白判定」" + "「ライン」「グレー」「グレラン」「縄」「PP」「ローラー」「破綻」「確白」「確黒」" + "「鉄板護衛」「噛み筋」「票筋」「視点漏れ」「身内切り」「囲い」「相方」「2 人狼セット」など。\n" + " 代わりに状況描写や感情で言う: " + "「あの白判定、無理に庇ってる気がする」「昨夜守ったのは◯◯」" + "「もう 1 人組んでそうな人」「あと処刑できる回数を考えると…」 のように。\n" + "- 役職 CO (占い師・霊媒師・騎士として名乗る) をするときは、" + "`co_declaration` を `\"seer\" / \"medium\" / \"knight\"` のいずれかに設定し、" + "`text` は「実は私、占い師なんだ」など自然な名乗りにする。" + "CO しないなら `co_declaration=null`。" + "「占いCO」のような語そのものは `text` に書かない。\n" ) @@ -210,6 +227,8 @@ async def generate( return None used_ids = tuple(data.get("used_logic_ids", [])) + co_raw = data.get("co_declaration") + co_declaration = co_raw if co_raw in ("seer", "medium", "knight") else None # Rough estimate: ~150ms per character for TTS estimated_ms = max(500, len(text) * 150) @@ -218,6 +237,7 @@ async def generate( intent=intent, used_logic_ids=used_ids, estimated_duration_ms=estimated_ms, + co_declaration=co_declaration, ) diff --git a/src/wolfbot/npc/personas.py b/src/wolfbot/npc/personas.py index a732c38..3b2d42e 100644 --- a/src/wolfbot/npc/personas.py +++ b/src/wolfbot/npc/personas.py @@ -15,7 +15,12 @@ from __future__ import annotations -from wolfbot.llm.persona_base import Persona, SpeechProfile, index_by_key +from wolfbot.llm.persona_base import ( + JudgmentProfile, + Persona, + SpeechProfile, + index_by_key, +) NPC_PERSONAS: tuple[Persona, ...] = ( Persona( @@ -33,6 +38,13 @@ signature_phrases=("……そうか", "わかった", "整理しよう"), forbidden_overuse=("毎回説教調にすること", "過度な軍人口調"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=1.0, + trust_medium_facts=0.8, + contrarian_bias=0.1, + aggression=0.4, + bandwagon_tendency=0.5, + ), ), Persona( key="gina", @@ -48,6 +60,13 @@ signature_phrases=("ごめんなさい", "……そう", "寂しいね"), forbidden_overuse=("朗らかすぎる雑談口調", "強引な煽り"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=1.0, + trust_medium_facts=0.7, + contrarian_bias=0.2, + aggression=0.25, + bandwagon_tendency=0.3, + ), ), Persona( key="sq", @@ -69,6 +88,13 @@ "不穏さを消すこと", ), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.7, + trust_medium_facts=0.4, + contrarian_bias=0.7, + aggression=0.55, + bandwagon_tendency=0.3, + ), ), Persona( key="raqio", @@ -84,6 +110,13 @@ signature_phrases=("ハッ", "当然の帰結", "君は"), forbidden_overuse=("乱暴なヤンキー口調", "単なる毒舌キャラへの矮小化"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=1.0, + trust_medium_facts=0.85, + contrarian_bias=0.6, + aggression=0.85, + bandwagon_tendency=0.15, + ), ), Persona( key="stella", @@ -104,6 +137,13 @@ signature_phrases=("ふふっ",), forbidden_overuse=("常時メイド口調の誇張", "過度な恋愛演出"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.95, + trust_medium_facts=0.7, + contrarian_bias=0.1, + aggression=0.3, + bandwagon_tendency=0.5, + ), ), Persona( key="shigemichi", @@ -120,6 +160,13 @@ signature_phrases=("〜なんよ", "オシ", "聞け聞けェい"), forbidden_overuse=("粗暴すぎる口調", "知性がないキャラとして扱うこと"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.75, + trust_medium_facts=0.5, + contrarian_bias=0.2, + aggression=0.85, + bandwagon_tendency=0.7, + ), ), Persona( key="chipie", @@ -136,6 +183,13 @@ signature_phrases=("ははっ", "悪ぃな", "やれやれ"), forbidden_overuse=("猫ネタの過剰連打", "常時ふざけた変人にすること"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.95, + trust_medium_facts=0.7, + contrarian_bias=0.3, + aggression=0.4, + bandwagon_tendency=0.4, + ), ), Persona( key="comet", @@ -152,6 +206,13 @@ signature_phrases=("へー", "あそだ", "こりゃビックリ"), forbidden_overuse=("子供っぽさの誇張", "知性がないように見せること"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.85, + trust_medium_facts=0.6, + contrarian_bias=0.5, + aggression=0.6, + bandwagon_tendency=0.4, + ), ), Persona( key="jonas", @@ -168,6 +229,13 @@ signature_phrases=("フフ", "……ほう", "諸君"), forbidden_overuse=("単なる老人口調にすること", "常時長広舌にすること"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.85, + trust_medium_facts=0.6, + contrarian_bias=0.4, + aggression=0.7, + bandwagon_tendency=0.3, + ), ), Persona( key="kukrushka", @@ -181,6 +249,13 @@ narration_mode="silent_gesture", forbidden_overuse=("饒舌な少女としての会話", "長い独白"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.7, + trust_medium_facts=0.5, + contrarian_bias=0.5, + aggression=0.3, + bandwagon_tendency=0.3, + ), ), Persona( key="otome", @@ -196,6 +271,13 @@ signature_phrases=("キュ", "やりました"), forbidden_overuse=("マスコット化しすぎること", "毎文『キュ』を付けること"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.95, + trust_medium_facts=0.7, + contrarian_bias=0.15, + aggression=0.5, + bandwagon_tendency=0.55, + ), ), Persona( key="sha_ming", @@ -212,6 +294,13 @@ signature_phrases=("つーか", "〜じゃね", "ヘイヘイ", "ヤる"), forbidden_overuse=("ただのチンピラにすること", "下品さの過剰強調"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.7, + trust_medium_facts=0.4, + contrarian_bias=0.6, + aggression=0.7, + bandwagon_tendency=0.3, + ), ), Persona( key="remnan", @@ -228,6 +317,13 @@ signature_phrases=("……ですから", "僕なんか", "ありがとう、ございました"), forbidden_overuse=("吃音の誇張", "単なる無能キャラ化"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.95, + trust_medium_facts=0.65, + contrarian_bias=0.25, + aggression=0.2, + bandwagon_tendency=0.25, + ), ), Persona( key="yuriko", @@ -248,6 +344,13 @@ "毎発話で神託のように喋ること", ), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.9, + trust_medium_facts=0.7, + contrarian_bias=0.5, + aggression=0.85, + bandwagon_tendency=0.15, + ), ), ) diff --git a/src/wolfbot/npc/speech_service.py b/src/wolfbot/npc/speech_service.py index 05dc080..b5377e1 100644 --- a/src/wolfbot/npc/speech_service.py +++ b/src/wolfbot/npc/speech_service.py @@ -12,7 +12,7 @@ import logging from dataclasses import dataclass -from typing import Protocol, runtime_checkable +from typing import Literal, Protocol, runtime_checkable from wolfbot.domain.ws_messages import LogicPacket, SpeakRequest, SpeakResult @@ -25,6 +25,7 @@ class NpcGeneratedSpeech: intent: str used_logic_ids: tuple[str, ...] estimated_duration_ms: int + co_declaration: str | None = None @runtime_checkable @@ -110,6 +111,9 @@ async def respond( text = speech.text.strip() if len(text) > request.max_chars: text = text[: request.max_chars] + co_declaration: Literal["seer", "medium", "knight"] | None = None + if speech.co_declaration in ("seer", "medium", "knight"): + co_declaration = speech.co_declaration # type: ignore[assignment] return SpeakResult( ts=now_ms, trace_id=request.trace_id, @@ -121,6 +125,7 @@ async def respond( used_logic_ids=speech.used_logic_ids, intent=speech.intent, estimated_duration_ms=speech.estimated_duration_ms, + co_declaration=co_declaration, ) diff --git a/src/wolfbot/npc/tts.py b/src/wolfbot/npc/tts.py index 11f9433..af63ae4 100644 --- a/src/wolfbot/npc/tts.py +++ b/src/wolfbot/npc/tts.py @@ -206,13 +206,12 @@ async def synthesize(self, req: TtsRequest) -> TtsResult: ) except TtsProviderError: raise - except httpx.TimeoutException: - raise TtsProviderError("voicevox_timeout") - except httpx.ConnectError: - raise TtsProviderError("voicevox_connection_refused") + except httpx.TimeoutException as exc: + raise TtsProviderError("voicevox_timeout") from exc + except httpx.ConnectError as exc: + raise TtsProviderError("voicevox_connection_refused") from exc except Exception as exc: - raise TtsProviderError( - f"voicevox_unexpected_{type(exc).__name__}") from exc + raise TtsProviderError(f"voicevox_unexpected_{type(exc).__name__}") from exc __all__ = [ diff --git a/src/wolfbot/persistence/schema.py b/src/wolfbot/persistence/schema.py index 91304b2..5dd8fdc 100644 --- a/src/wolfbot/persistence/schema.py +++ b/src/wolfbot/persistence/schema.py @@ -167,6 +167,7 @@ audio_end_ms INTEGER, alive_seat_nos_json TEXT, summary TEXT, + co_declaration TEXT, created_at_ms INTEGER NOT NULL ) """, @@ -290,4 +291,8 @@ async def migrate(db_path: str | Path) -> None: cols = {row[1] async for row in cur} if "summary" not in cols: await db.execute("ALTER TABLE speech_events ADD COLUMN summary TEXT") + if "co_declaration" not in cols: + await db.execute( + "ALTER TABLE speech_events ADD COLUMN co_declaration TEXT" + ) await db.commit() diff --git a/src/wolfbot/prompts/llm_system_prompt.md b/src/wolfbot/prompts/llm_system_prompt.md index dc5ae75..f321251 100644 --- a/src/wolfbot/prompts/llm_system_prompt.md +++ b/src/wolfbot/prompts/llm_system_prompt.md @@ -9,6 +9,25 @@ 3. 原作ゲーム(Gnosia)の長い台詞・固有表現を verbatim で引用しない。ただし一人称・短い呼称・短い特徴語は、キャラ識別に必要な範囲で低頻度に使ってよい。話法はキャラらしさを出すための装飾であり、論理やルール順守より優先してはならない。 4. 応答は日本語のみ。1 発言は 80〜300 文字を目安に、明確な論点を 1〜2 点含める。 5. 出力は指定された JSON スキーマに厳密に従う。スキーマ外のキーを足してはならない。`target_name` はプロンプトで示された候補トークン (例: `席3 Alice`) と完全一致する必要がある。席番号付きで返せば同名プレイヤーがいても一意に指定できる。一致しない場合は `intent` を `skip` にするか `null` を返す。 +6. **`public_message` の中では人狼用語(メタ語彙)を使わない。** 内部の思考や `reason_summary` で用語を使うのは構わないが、口に出す発話は実際に村人として喋る自然な日本語にする。下記「## 発話で使ってはいけない用語」を必ず守る。 +7. **役職 CO(自分の役職を名乗る)は、`public_message` では自然な日本語で表現し、必ず `co_declaration` フィールドに `"seer"` / `"medium"` / `"knight"` のいずれかを入れる。** スキーマフィールドの設定が CO の唯一の信号で、文章中の "CO" 等の語彙は使わない。例えば占い CO したいなら `co_declaration="seer"` を立てた上で `public_message` は「実は私、占い師なんだ。昨夜は◯◯を見たんだけど、白だった」のような自然な発話にする。CO しないなら `co_declaration=null`。 + +## 発話で使ってはいけない用語 + +`public_message` 内では以下のような人狼用語・メタ語彙を**使わない**。これらはあなた自身の頭の中や `reason_summary` ではどんどん使ってよいが、声に出すと不自然なので口語表現に置き換える: + +- 「CO」「占いCO」「霊媒CO」「騎士CO」「役職CO」「対抗CO」 → 「実は私、占い師なんだ」「昨日処刑された人を見たけど」「夜守ってたのは◯◯」のように自然な名乗り +- 「黒判定」「白判定」「黒を出す」「白を出す」 → 「人狼だった」「人狼じゃなかった」「白かった」「結果は人狼」 +- 「ライン」「身内切り」「囲い」「視点漏れ」 → 「庇い合ってる感じ」「不自然に切ってる」「あの白判定、無理に守ってる気がする」 +- 「グレー」「グレラン」「グレスケ」 → 「まだ何も分かってない人」「まだ占われてない人」 +- 「縄」「PP」「RPP」「決め打ち」「ローラー」「黒ストップ」「3-1」「2-2」 → 「あと処刑できる回数」「人狼が揃って投票で勝つ形」「どっちが本物か決めて吊る」「占い師全員吊る」 +- 「鉄板護衛」「変態護衛」「捨て護衛」「護衛読み」「護衛誘導」「連ガ」 → 「絶対守りたい人」「読みで外して守る」「とりあえず無難な人を守る」「同じ人を二夜連続で守れない」 +- 「噛み筋」「票筋」「SG」 → 「昨夜の襲撃の選び方」「投票の流れ」「狼に押し付けられそうな人」 +- 「破綻」「確白」「確黒」「パンダ」「真狼狼」「真狂寄り」 → 「明らかに矛盾してる」「ほぼ人狼じゃない人」「ほぼ人狼な人」「白と黒の両方を貰った人」 +- 「狩人」「狩」「占い師」「霊媒師」「騎士」「人狼」「狂人」「村人」など役職名は普通に使ってよい(村の住民として自然なので)。 +- 「2 人狼セット」「ペア仮説」 → 「もう 1 人の人狼候補」「組んでそうな人」(推理形は OK、確定情報を持っているような断定は避ける)。 + +ベースの考え方: **その用語を聞いたことがない初参加の村人にも理解できるか**を基準に書き直す。語彙ではなく状況描写と感情で論点を伝える。例えば「ラキオの占いCOは囲いっぽい」ではなく「ラキオが占い師だって言ってる相手、なんとなく庇いに行ってる気がして信用できない」と書く。 ## この村の共通ルール @@ -18,6 +37,10 @@ {persona_block} +## 判断傾向 + +{judgment_profile_block} + ## 話法 話法の共通ルール: diff --git a/src/wolfbot/services/deduction_service.py b/src/wolfbot/services/deduction_service.py new file mode 100644 index 0000000..92a6d6e --- /dev/null +++ b/src/wolfbot/services/deduction_service.py @@ -0,0 +1,264 @@ +"""Public-information deduction layer for LLM seats. + +Master derives logically-forced or near-forced facts from public state and +hands them to LLM seats as labeled `DeducedFact` records. The LLM seat's +`JudgmentProfile` axes (`trust_hard_facts`, `trust_medium_facts`, +`contrarian_bias`) shape how each fact is adopted in the persona's voice. + +Two confidence bands: + +- ``HARD`` — logically forced from public information (counter-CO count, + vote/execution history). The LLM should treat these as + ground truth. Persona shapes only the *rhetoric*, not the + acceptance. +- ``MEDIUM`` — heuristic but useful (a single uncountered CO is *probably* + real; a sole-survivor CO from a contested chain is *not* + auto-real). Persona's `trust_medium_facts` decides adoption + level. + +This module is intentionally pure: callers fetch data from the repo and +pass it in. No I/O, no asyncio. The caller (typically `LLMAdapter._ask`) +is responsible for assembly + injection into `build_user_context`. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import StrEnum + +from wolfbot.domain.discussion import CoClaim +from wolfbot.domain.models import Player, Seat, Vote + +_INFO_ROLES: tuple[str, ...] = ("seer", "medium", "knight") +_ROLE_JA: Mapping[str, str] = {"seer": "占い師", "medium": "霊媒師", "knight": "騎士"} + + +class FactConfidence(StrEnum): + HARD = "HARD" + MEDIUM = "MEDIUM" + + +@dataclass(frozen=True) +class DeducedFact: + text: str + confidence: FactConfidence + affected_seats: frozenset[int] = frozenset() + + +def _seat_token(seat_no: int, seats_by_no: Mapping[int, Seat]) -> str: + seat = seats_by_no.get(seat_no) + if seat is None: + return f"席{seat_no}" + return f"席{seat_no} {seat.display_name}" + + +def _co_map_facts( + co_claims: Sequence[CoClaim], + players: Sequence[Player], + seats_by_no: Mapping[int, Seat], +) -> list[DeducedFact]: + """Per-role CO summary + counter-CO HARD detection. + + Aggregates `CoClaim` rows by role, separating alive vs dead claimants. + Emits a HARD fact for each role that has any claim, plus a HARD + 'counter-CO' warning when ≥2 currently-alive claimants exist + (the 9-player ruleset has at most 1 real seer / medium / knight). + """ + alive_by_seat = {p.seat_no: p.alive for p in players} + by_role: dict[str, list[int]] = {role: [] for role in _INFO_ROLES} + for claim in co_claims: + if claim.role_claim in by_role and claim.seat not in by_role[claim.role_claim]: + by_role[claim.role_claim].append(claim.seat) + + out: list[DeducedFact] = [] + for role in _INFO_ROLES: + seats_for_role = by_role[role] + if not seats_for_role: + continue + alive_seats = [s for s in seats_for_role if alive_by_seat.get(s, False)] + dead_seats = [s for s in seats_for_role if not alive_by_seat.get(s, False)] + alive_repr = ( + "、".join(_seat_token(s, seats_by_no) for s in alive_seats) + if alive_seats + else "(なし)" + ) + dead_repr = ( + "、".join(_seat_token(s, seats_by_no) for s in dead_seats) + if dead_seats + else "(なし)" + ) + out.append( + DeducedFact( + text=( + f"{_ROLE_JA[role]} の名乗り履歴: 生存={alive_repr} / 死亡済み={dead_repr}" + ), + confidence=FactConfidence.HARD, + affected_seats=frozenset(seats_for_role), + ) + ) + if len(alive_seats) >= 2: + out.append( + DeducedFact( + text=( + f"{_ROLE_JA[role]} の生存名乗りが {len(alive_seats)} 人 " + f"({'、'.join(_seat_token(s, seats_by_no) for s in alive_seats)})" + " — 9 人村の役職分布上、最大 1 人しか真ではない。" + "残りは騙り確定。" + ), + confidence=FactConfidence.HARD, + affected_seats=frozenset(alive_seats), + ) + ) + return out + + +def _co_likelihood_facts( + co_claims: Sequence[CoClaim], + players: Sequence[Player], + seats_by_no: Mapping[int, Seat], +) -> list[DeducedFact]: + """MEDIUM-confidence heuristic facts about CO likelihood. + + Two patterns: + + - Single uncountered CO: exactly one historical claimant of role X, + and no counter-CO ever appeared → likely real (but not certain; + the lone claimant could still be a sole-騙り). + - Sole-survivor of contested chain: ≥2 historical claimants but only + 1 alive now → explicitly NOT auto-real (per the project's CO rules + in `llm_system_prompt.md`). + """ + alive_by_seat = {p.seat_no: p.alive for p in players} + by_role: dict[str, list[int]] = {role: [] for role in _INFO_ROLES} + for claim in co_claims: + if claim.role_claim in by_role and claim.seat not in by_role[claim.role_claim]: + by_role[claim.role_claim].append(claim.seat) + + out: list[DeducedFact] = [] + for role in _INFO_ROLES: + seats_for_role = by_role[role] + if not seats_for_role: + continue + total = len(seats_for_role) + alive_seats = [s for s in seats_for_role if alive_by_seat.get(s, False)] + if total == 1 and len(alive_seats) == 1: + sole = alive_seats[0] + out.append( + DeducedFact( + text=( + f"{_ROLE_JA[role]} の名乗りは {_seat_token(sole, seats_by_no)} " + "のみで対抗履歴なし — 真寄りに扱うのが原則 " + "(ただし発言・票・判定の整合性に強い破綻があれば疑ってよい)。" + ), + confidence=FactConfidence.MEDIUM, + affected_seats=frozenset({sole}), + ) + ) + elif total >= 2 and len(alive_seats) == 1: + sole = alive_seats[0] + out.append( + DeducedFact( + text=( + f"{_ROLE_JA[role]} は対抗 CO 履歴あり ({total} 人) で生存は " + f"{_seat_token(sole, seats_by_no)} のみ — 自動で真置きしない。" + "狼が情報役を残した・対抗を吊らせて信用を取った可能性も平行して疑う。" + ), + confidence=FactConfidence.MEDIUM, + affected_seats=frozenset({sole}), + ) + ) + return out + + +def _vote_history_facts( + votes_by_day: Mapping[int, Sequence[Vote]], + seats_by_no: Mapping[int, Seat], +) -> list[DeducedFact]: + """Per-day execution + voter-target HARD timeline. + + Vote rows are public after each day's execution announcement, so this + is just a structured restatement: which seat was executed on day N, + and which voter cast which ballot. LLM seats can derive the same + from raw logs, but pre-digested per-day rows save tokens and avoid + parser drift. + """ + out: list[DeducedFact] = [] + for day in sorted(votes_by_day.keys()): + votes = votes_by_day[day] + if not votes: + continue + target_counts: dict[int, list[int]] = {} + for v in votes: + if v.target_seat is None: + continue + target_counts.setdefault(v.target_seat, []).append(v.voter_seat) + if not target_counts: + continue + executed_seat = max(target_counts.items(), key=lambda kv: len(kv[1]))[0] + executed_voters = sorted(target_counts[executed_seat]) + voter_repr = "、".join(_seat_token(v, seats_by_no) for v in executed_voters) + affected = {executed_seat, *executed_voters} + out.append( + DeducedFact( + text=( + f"day {day} 処刑: {_seat_token(executed_seat, seats_by_no)} " + f"(投票: {voter_repr})" + ), + confidence=FactConfidence.HARD, + affected_seats=frozenset(affected), + ) + ) + return out + + +def deduce( + *, + co_claims: Sequence[CoClaim], + players: Sequence[Player], + seats: Sequence[Seat], + votes_by_day: Mapping[int, Sequence[Vote]] | None = None, +) -> tuple[DeducedFact, ...]: + """Run the full deduction pipeline and return facts in stable order. + + Order: CO-map facts → CO-likelihood facts → vote-history facts. + Stable order within each section makes the prompt diffable across runs. + """ + seats_by_no = {s.seat_no: s for s in seats} + facts: list[DeducedFact] = [] + facts.extend(_co_map_facts(co_claims, players, seats_by_no)) + facts.extend(_co_likelihood_facts(co_claims, players, seats_by_no)) + if votes_by_day: + facts.extend(_vote_history_facts(votes_by_day, seats_by_no)) + return tuple(facts) + + +def render_facts_block(facts: Sequence[DeducedFact]) -> str: + """Render a list of facts as a human-readable block grouped by confidence. + + Empty facts list → returns ``"(該当なし)"`` so the caller can always + splice the result into the user-context template without conditional + branches. + """ + if not facts: + return "(該当なし)" + hard = [f for f in facts if f.confidence is FactConfidence.HARD] + medium = [f for f in facts if f.confidence is FactConfidence.MEDIUM] + parts: list[str] = [] + if hard: + parts.append("### HARD (公開情報から論理的に確定)") + parts.extend(f"- {f.text}" for f in hard) + if medium: + if parts: + parts.append("") + parts.append("### MEDIUM (推測根拠あり、確定ではない)") + parts.extend(f"- {f.text}" for f in medium) + return "\n".join(parts) + + +__all__ = [ + "DeducedFact", + "FactConfidence", + "deduce", + "render_facts_block", +] diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py index 3701cf4..6641af9 100644 --- a/src/wolfbot/services/discussion_service.py +++ b/src/wolfbot/services/discussion_service.py @@ -93,8 +93,8 @@ async def insert(self, event: SpeechEvent) -> None: INSERT INTO speech_events ( event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, - alive_seat_nos_json, summary, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + alive_seat_nos_json, summary, co_declaration, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( event.event_id, @@ -111,6 +111,7 @@ async def insert(self, event: SpeechEvent) -> None: event.audio_end_ms, event.alive_seat_nos_json, event.summary, + event.co_declaration, event.created_at_ms, ), ) @@ -121,7 +122,7 @@ async def load_phase(self, game_id: str, phase_id: str) -> Sequence[SpeechEvent] """ SELECT event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, - alive_seat_nos_json, summary, created_at_ms + alive_seat_nos_json, summary, co_declaration, created_at_ms FROM speech_events WHERE game_id=? AND phase_id=? ORDER BY created_at_ms ASC, event_id ASC @@ -136,7 +137,7 @@ async def load_for_game(self, game_id: str) -> Sequence[SpeechEvent]: """ SELECT event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, - alive_seat_nos_json, summary, created_at_ms + alive_seat_nos_json, summary, co_declaration, created_at_ms FROM speech_events WHERE game_id=? ORDER BY created_at_ms ASC, event_id ASC @@ -163,7 +164,8 @@ def _row_to_event(row: Any) -> SpeechEvent: audio_end_ms=row[11], alive_seat_nos_json=row[12], summary=row[13], - created_at_ms=row[14], + co_declaration=row[14], + created_at_ms=row[15], ) @@ -221,6 +223,7 @@ def make_human_text_event( phase: Phase, speaker_seat: int, text: str, + co_declaration: str | None = None, created_at_ms: int | None = None, ) -> SpeechEvent: return SpeechEvent( @@ -233,6 +236,7 @@ def make_human_text_event( speaker_kind=SpeakerKind.HUMAN, speaker_seat=speaker_seat, text=text, + co_declaration=co_declaration, created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), ) @@ -245,6 +249,7 @@ def make_npc_generated_event( phase: Phase, speaker_seat: int, text: str, + co_declaration: str | None = None, created_at_ms: int | None = None, ) -> SpeechEvent: return SpeechEvent( @@ -257,6 +262,7 @@ def make_npc_generated_event( speaker_kind=SpeakerKind.NPC, speaker_seat=speaker_seat, text=text, + co_declaration=co_declaration, created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), ) @@ -272,6 +278,7 @@ def make_voice_stt_event( stt_confidence: float, audio_start_ms: int, audio_end_ms: int, + co_declaration: str | None = None, created_at_ms: int | None = None, ) -> SpeechEvent: return SpeechEvent( @@ -287,6 +294,7 @@ def make_voice_stt_event( stt_confidence=stt_confidence, audio_start_ms=audio_start_ms, audio_end_ms=audio_end_ms, + co_declaration=co_declaration, created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), ) @@ -475,19 +483,18 @@ def apply_speech_event( co_claims = list(state.co_claims) seen_co = {(c.seat, c.role_claim) for c in co_claims} if speaker is not None: - for role_key, marker in _CO_MARKERS: - if marker in event.text: - key = (speaker, role_key) - if key not in seen_co: - seen_co.add(key) - co_claims.append( - CoClaim( - seat=speaker, - role_claim=role_key, - declared_at_event_id=event.event_id, - ) + role_key = _resolve_co_role(event) + if role_key is not None: + key = (speaker, role_key) + if key not in seen_co: + seen_co.add(key) + co_claims.append( + CoClaim( + seat=speaker, + role_claim=role_key, + declared_at_event_id=event.event_id, ) - break + ) recent = [*state.recent_speech_event_ids, event.event_id][-10:] @@ -555,20 +562,22 @@ def rebuild_public_state_from_events( if event.speaker_seat is not None: spoken_seats.add(event.speaker_seat) recent_ids.append(event.event_id) - for role_key, marker in _CO_MARKERS: - if marker in event.text and event.speaker_seat is not None: - key = (event.speaker_seat, role_key) - if key in seen_co: - continue - seen_co.add(key) - co_claims.append( - CoClaim( - seat=event.speaker_seat, - role_claim=role_key, - declared_at_event_id=event.event_id, - ) - ) - break + if event.speaker_seat is None: + continue + role_key = _resolve_co_role(event) + if role_key is None: + continue + key = (event.speaker_seat, role_key) + if key in seen_co: + continue + seen_co.add(key) + co_claims.append( + CoClaim( + seat=event.speaker_seat, + role_claim=role_key, + declared_at_event_id=event.event_id, + ) + ) state.co_claims = tuple(co_claims) state.silent_seats = frozenset(alive_seats - spoken_seats) @@ -576,8 +585,29 @@ def rebuild_public_state_from_events( return state +_VALID_CO_ROLES: frozenset[str] = frozenset({"seer", "medium", "knight"}) + _CO_MARKERS: tuple[tuple[str, str], ...] = ( ("seer", "占いCO"), ("medium", "霊媒CO"), ("knight", "騎士CO"), ) +"""Legacy substring fallback. Authoritative CO comes from +`SpeechEvent.co_declaration` (set at the source: NPC/LLM schema field, or +Gemini's structured `co_claim`). Substring matching is only used for legacy +events and human text where natural-language CO has not been pre-tagged. +""" + + +def _resolve_co_role(event: SpeechEvent) -> str | None: + """Pick the CO role for an event, preferring the structured field. + + Returns one of ``"seer" / "medium" / "knight"`` or ``None`` for "no CO". + """ + declared = event.co_declaration + if declared is not None and declared in _VALID_CO_ROLES: + return declared + for role_key, marker in _CO_MARKERS: + if marker in event.text: + return role_key + return None diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index 5b37930..8cd6b81 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -26,8 +26,9 @@ wait_exponential, ) +from wolfbot.domain.discussion import CoClaim, make_phase_id from wolfbot.domain.enums import Phase, Role, SubmissionType -from wolfbot.domain.models import Game, LogEntry, Player, Seat +from wolfbot.domain.models import Game, LogEntry, Player, Seat, Vote from wolfbot.domain.rules import ( legal_attack_targets, legal_divine_targets, @@ -88,6 +89,7 @@ class LLMAction(BaseModel): target_name: str | None = None reason_summary: str = "" confidence: float = 0.5 + co_declaration: Literal["seer", "medium", "knight"] | None = None RESPONSE_SCHEMA: dict[str, object] = { @@ -102,6 +104,7 @@ class LLMAction(BaseModel): "target_name", "reason_summary", "confidence", + "co_declaration", ], "properties": { "intent": { @@ -112,6 +115,10 @@ class LLMAction(BaseModel): "target_name": {"type": ["string", "null"]}, "reason_summary": {"type": "string", "maxLength": 200}, "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "co_declaration": { + "type": ["string", "null"], + "enum": ["seer", "medium", "knight", None], + }, }, }, } @@ -744,7 +751,9 @@ async def _do_one_discussion_speech( ) except Exception: log.exception("PLAYER_SPEECH log insert failed for seat %s", player.seat_no) - await self._emit_npc_speech_event(fresh, player.seat_no, message) + await self._emit_npc_speech_event( + fresh, player.seat_no, message, co_declaration=action.co_declaration + ) # --------------------------------------------------- runoff candidate speech async def submit_llm_runoff_candidate_speeches( @@ -894,9 +903,18 @@ async def _do_one_runoff_speech( ) except Exception: log.exception("PLAYER_SPEECH log insert failed for seat %s", player.seat_no) - await self._emit_npc_speech_event(fresh, player.seat_no, message) + await self._emit_npc_speech_event( + fresh, player.seat_no, message, co_declaration=action.co_declaration + ) - async def _emit_npc_speech_event(self, game: Game, speaker_seat: int, text: str) -> None: + async def _emit_npc_speech_event( + self, + game: Game, + speaker_seat: int, + text: str, + *, + co_declaration: str | None = None, + ) -> None: """Persist a SpeechEvent(source=npc_generated) for an LLM utterance. Wrapped in try/except so a SpeechEvent persistence hiccup does not @@ -921,6 +939,7 @@ async def _emit_npc_speech_event(self, game: Game, speaker_seat: int, text: str) phase=game.phase, speaker_seat=speaker_seat, text=text, + co_declaration=co_declaration, ) # Persist-only avoids re-posting the message to Discord (the # rounds-mode path already posted it via MessagePoster) and @@ -951,6 +970,7 @@ async def _ask( private_logs = await self.repo.load_private_logs_for_audience( game.id, audience_seat=player.seat_no, limit=40 ) + deduced_block = await self._build_deduced_facts_block(game, players, seats) system = build_system_prompt( persona=persona, role=player.role, @@ -966,6 +986,7 @@ async def _ask( players=players, public_logs=public_logs, private_logs=private_logs, + deduced_facts_block=deduced_block, ) try: return await self.decider.decide(system, user) @@ -973,6 +994,51 @@ async def _ask( log.exception("LLM decide failed for seat %s game %s", player.seat_no, game.id) return LLMAction(intent="skip", reason_summary="decider error") + async def _build_deduced_facts_block( + self, + game: Game, + players: Sequence[Player], + seats: Sequence[Seat], + ) -> str | None: + """Assemble Master's HARD/MEDIUM deduced facts for this turn. + + Pulls CO history from the SpeechEvent fold and per-day vote rows + from the repo, then runs them through `deduction_service.deduce`. + Returns ``None`` (rather than the empty-marker string) on any + failure so the user-context template skips the section instead of + showing a confusing '(該当なし)' line. + """ + try: + from wolfbot.services.deduction_service import deduce, render_facts_block + from wolfbot.services.discussion_service import rebuild_public_state_from_events + + co_claims: tuple[CoClaim, ...] = () + if self.discussion_service is not None: + phase_id = make_phase_id(game.id, game.day_number, game.phase) + events = await self.discussion_service.load_phase(game.id, phase_id) + state = rebuild_public_state_from_events(events) + if state is not None: + co_claims = state.co_claims + + votes_by_day: dict[int, list[Vote]] = {} + for past_day in range(1, game.day_number): + votes = await self.repo.load_votes(game.id, past_day, 0) + if votes: + votes_by_day[past_day] = votes + + facts = deduce( + co_claims=co_claims, + players=players, + seats=seats, + votes_by_day=votes_by_day, + ) + if not facts: + return None + return render_facts_block(facts) + except Exception: + log.exception("deduce facts failed for game %s seat", game.id) + return None + def _resolve_target( self, target_name: str | None, diff --git a/tests/test_deduction_service.py b/tests/test_deduction_service.py new file mode 100644 index 0000000..b17e253 --- /dev/null +++ b/tests/test_deduction_service.py @@ -0,0 +1,181 @@ +"""DeductionService unit tests. + +Pure-function pipeline: given CO claims + players + seats + votes, the +service must produce HARD/MEDIUM facts in deterministic order, and each +fact's text must include the seat-token form so LLM seats can resolve +references back to seat numbers. +""" + +from __future__ import annotations + +from wolfbot.domain.discussion import CoClaim +from wolfbot.domain.models import Player, Seat, Vote +from wolfbot.services.deduction_service import ( + DeducedFact, + FactConfidence, + deduce, + render_facts_block, +) + + +def _seat(no: int, name: str) -> Seat: + return Seat( + seat_no=no, + display_name=name, + discord_user_id=None, + is_llm=True, + persona_key="setsu", + ) + + +def _player(no: int, alive: bool = True) -> Player: + return Player( + game_id="g1", + seat_no=no, + role=None, + alive=alive, + died_day=None, + ) + + +def _claim(seat: int, role: str, event_id: str = "ev") -> CoClaim: + return CoClaim(seat=seat, role_claim=role, declared_at_event_id=event_id) + + +def _seats_9() -> list[Seat]: + return [_seat(i, f"P{i}") for i in range(1, 10)] + + +def _all_alive() -> list[Player]: + return [_player(i) for i in range(1, 10)] + + +def test_no_co_claims_returns_no_facts() -> None: + facts = deduce(co_claims=(), players=_all_alive(), seats=_seats_9()) + assert facts == () + + +def test_single_uncountered_seer_co_yields_hard_map_and_medium_likely_real() -> None: + """One alive seer claim, no counters → HARD CO map + MEDIUM 'likely real' fact.""" + facts = deduce( + co_claims=(_claim(3, "seer"),), + players=_all_alive(), + seats=_seats_9(), + ) + assert len(facts) == 2 + # CO map fact (HARD) lists the alive claimant by seat token. + co_map = facts[0] + assert co_map.confidence is FactConfidence.HARD + assert "占い師" in co_map.text + assert "席3 P3" in co_map.text + # Likelihood fact (MEDIUM). + likely = facts[1] + assert likely.confidence is FactConfidence.MEDIUM + assert "対抗履歴なし" in likely.text + assert "席3" in likely.text + + +def test_counter_co_two_alive_yields_hard_warning() -> None: + """Two alive seers → HARD counter-CO warning (max 1 real per ruleset). + + No MEDIUM 'likely real' fact when contested. + """ + facts = deduce( + co_claims=(_claim(3, "seer"), _claim(7, "seer")), + players=_all_alive(), + seats=_seats_9(), + ) + confidences = [f.confidence for f in facts] + assert FactConfidence.HARD in confidences + counter_facts = [f for f in facts if "騙り確定" in f.text] + assert len(counter_facts) == 1 + assert "占い師" in counter_facts[0].text + assert counter_facts[0].affected_seats == frozenset({3, 7}) + # No MEDIUM 'likely real' line — both could still be騙り. + assert all(f.confidence is FactConfidence.HARD for f in facts) + + +def test_sole_survivor_of_contested_chain_is_not_auto_real() -> None: + """≥2 historical claimants, 1 alive → MEDIUM warning, not blanket trust.""" + players = [_player(i, alive=(i != 7)) for i in range(1, 10)] + facts = deduce( + co_claims=(_claim(3, "seer"), _claim(7, "seer")), + players=players, + seats=_seats_9(), + ) + medium = [f for f in facts if f.confidence is FactConfidence.MEDIUM] + assert len(medium) == 1 + assert "対抗 CO 履歴あり" in medium[0].text + assert "自動で真置きしない" in medium[0].text + assert medium[0].affected_seats == frozenset({3}) + + +def test_co_map_separates_alive_and_dead() -> None: + players = [_player(i, alive=(i != 5)) for i in range(1, 10)] + facts = deduce( + co_claims=(_claim(5, "medium"),), + players=players, + seats=_seats_9(), + ) + co_map = next(f for f in facts if "霊媒師 の名乗り履歴" in f.text) + assert "生存=(なし)" in co_map.text + assert "死亡済み=席5 P5" in co_map.text + + +def test_vote_history_emits_hard_execution_record() -> None: + """Past day with majority vote → HARD execution timeline fact.""" + votes_d1 = [ + Vote(game_id="g1", day=1, round=0, voter_seat=1, target_seat=2, submitted_at=0), + Vote(game_id="g1", day=1, round=0, voter_seat=4, target_seat=2, submitted_at=0), + Vote(game_id="g1", day=1, round=0, voter_seat=7, target_seat=2, submitted_at=0), + Vote(game_id="g1", day=1, round=0, voter_seat=3, target_seat=5, submitted_at=0), + ] + facts = deduce( + co_claims=(), + players=_all_alive(), + seats=_seats_9(), + votes_by_day={1: votes_d1}, + ) + exec_facts = [f for f in facts if "day 1 処刑" in f.text] + assert len(exec_facts) == 1 + assert exec_facts[0].confidence is FactConfidence.HARD + assert "席2 P2" in exec_facts[0].text + # Voters listed in seat order. + assert "席1 P1、席4 P4、席7 P7" in exec_facts[0].text + + +def test_render_facts_block_groups_by_confidence_with_headers() -> None: + facts = ( + DeducedFact(text="hard A", confidence=FactConfidence.HARD), + DeducedFact(text="medium A", confidence=FactConfidence.MEDIUM), + DeducedFact(text="hard B", confidence=FactConfidence.HARD), + ) + block = render_facts_block(facts) + # HARD section comes first, with header. + assert block.index("HARD") < block.index("MEDIUM") + # All three facts are surfaced. + assert "- hard A" in block + assert "- hard B" in block + assert "- medium A" in block + + +def test_render_facts_block_handles_empty_input() -> None: + assert render_facts_block(()) == "(該当なし)" + + +def test_facts_order_is_deterministic_co_first_then_likelihood_then_votes() -> None: + """Stable order: CO map → CO likelihood → vote history. Diffable across runs.""" + votes_d1 = [ + Vote(game_id="g1", day=1, round=0, voter_seat=1, target_seat=2, submitted_at=0), + ] + facts = deduce( + co_claims=(_claim(3, "seer"),), + players=_all_alive(), + seats=_seats_9(), + votes_by_day={1: votes_d1}, + ) + texts = [f.text for f in facts] + co_map_idx = next(i for i, t in enumerate(texts) if "占い師 の名乗り履歴" in t) + likely_idx = next(i for i, t in enumerate(texts) if "対抗履歴なし" in t) + vote_idx = next(i for i, t in enumerate(texts) if "day 1 処刑" in t) + assert co_map_idx < likely_idx < vote_idx diff --git a/tests/test_llm_prompt_builder.py b/tests/test_llm_prompt_builder.py index 64cea7b..6834932 100644 --- a/tests/test_llm_prompt_builder.py +++ b/tests/test_llm_prompt_builder.py @@ -20,9 +20,10 @@ from wolfbot.domain.enums import ROLE_JA, Phase, Role, SubmissionType from wolfbot.domain.models import Game, Player, Seat -from wolfbot.llm.persona_base import Persona, SpeechProfile +from wolfbot.llm.persona_base import JudgmentProfile, Persona, SpeechProfile from wolfbot.llm.prompt_builder import ( _build_game_rules_block, + _build_judgment_profile_block, _build_speech_profile_block, _build_strategy_block, build_system_prompt, @@ -1985,3 +1986,159 @@ def test_task_vote_madman_inference_present_but_no_actor_partner() -> None: assert "相方を切" not in text # Bare `相方` (actor mode) must be absent in the madman vote text. assert not re.search(r"相方(?!候補)", text) + + +# ========================================================================= +# JudgmentProfile rendering and integration +# ========================================================================= + + +def _persona_with_judgment(profile: JudgmentProfile) -> Persona: + return Persona( + key="_judgment_test_", + display_name="J_TEST", + style_guide="x", + speech_profile=_TEST_SPEECH_PROFILE, + judgment_profile=profile, + ) + + +def test_judgment_profile_block_renders_extreme_logical_persona() -> None: + """Max trust_hard + low contrarian + low aggression → strong-logic / passive + bands appear in the rendered block.""" + profile = JudgmentProfile( + trust_hard_facts=1.0, + trust_medium_facts=0.85, + contrarian_bias=0.1, + aggression=0.2, + bandwagon_tendency=0.15, + ) + block = _build_judgment_profile_block(_persona_with_judgment(profile)) + assert "絶対視" in block + assert "基本受け入れる" in block + assert "多数派にあえて逆らわない" in block + assert "慎重で疑い先を出すのが遅い" in block + assert "単独行動を好み流れに乗らない" in block + + +def test_judgment_profile_block_renders_chaos_persona() -> None: + """Low trust_hard + low trust_medium + high contrarian + high aggression → + distrustful / disruptive bands.""" + profile = JudgmentProfile( + trust_hard_facts=0.4, + trust_medium_facts=0.3, + contrarian_bias=0.85, + aggression=0.9, + bandwagon_tendency=0.3, + ) + block = _build_judgment_profile_block(_persona_with_judgment(profile)) + assert "やや軽視" in block + assert "懐疑的に扱う" in block + assert "あえて逆張り" in block + assert "即座に処刑候補を名指しする" in block + assert "独自路線を好む" in block + + +def test_judgment_profile_block_default_neutral_persona_renders_mid_bands() -> None: + """A persona constructed without an explicit judgment_profile gets the + neutral defaults; the block renders all axes around 'mid' bands.""" + persona = Persona( + key="_default_judgment_", + display_name="D", + style_guide="x", + speech_profile=_TEST_SPEECH_PROFILE, + ) + block = _build_judgment_profile_block(persona) + # Defaults: trust_hard=1.0, trust_medium=0.7, contrarian=0.0, aggression=0.5, bandwagon=0.5 + assert "絶対視" in block # trust_hard 1.0 → high + assert "やや信用する" in block # trust_medium 0.7 → mid_high + assert "多数派にあえて逆らわない" in block # contrarian 0.0 → low + assert "標準的に疑い先を出す" in block # aggression 0.5 → mid + assert "状況次第" in block # bandwagon 0.5 → mid + + +def test_build_system_prompt_includes_judgment_profile_block() -> None: + """The integration path renders the judgment profile within the system + prompt under the `## 判断傾向` heading.""" + persona = _persona_with_judgment( + JudgmentProfile( + trust_hard_facts=1.0, + trust_medium_facts=0.85, + contrarian_bias=0.85, + aggression=0.85, + bandwagon_tendency=0.15, + ) + ) + prompt = build_system_prompt( + persona=persona, + role=Role.VILLAGER, + phase=Phase.DAY_DISCUSSION, + day_number=1, + task_text="t", + ) + assert "## 判断傾向" in prompt + # Sentinel band labels reach the final prompt. + assert "絶対視" in prompt + assert "あえて逆張り" in prompt + assert "即座に処刑候補を名指しする" in prompt + + +def test_npc_personas_all_carry_explicit_judgment_profile() -> None: + """Every shipped NPC persona must declare an explicit judgment_profile — + not the neutral default — so the structured judgment axes actually + differentiate seat behaviour. A new persona without explicit values + fails this check.""" + from wolfbot.llm.persona_base import _DEFAULT_JUDGMENT_PROFILE + from wolfbot.npc.personas import NPC_PERSONAS + + for persona in NPC_PERSONAS: + assert persona.judgment_profile is not _DEFAULT_JUDGMENT_PROFILE, ( + f"persona {persona.key!r} is missing an explicit judgment_profile" + ) + + +# ========================================================================= +# build_user_context — deduced facts injection +# ========================================================================= + + +def test_build_user_context_includes_deduced_facts_block_when_supplied() -> None: + """When the caller passes `deduced_facts_block`, it appears under the + `## 公開情報からの確定/推測事実 (Master 整理)` header so LLMs can + distinguish Master's structured analysis from raw public log.""" + seats = [_ctx_seat(1, "Alice"), _ctx_seat(2, "Bob")] + players = [_ctx_player(1), _ctx_player(2)] + out = build_user_context( + game=_ctx_game(), + me=players[0], + my_seat=seats[0], + seats=seats, + players=players, + public_logs=[], + private_logs=[], + deduced_facts_block=( + "### HARD (公開情報から論理的に確定)\n" + "- 占い師 の名乗り履歴: 生存=席3 P3 / 死亡済み=(なし)" + ), + ) + assert "公開情報からの確定/推測事実 (Master 整理)" in out + assert "占い師 の名乗り履歴" in out + assert "席3 P3" in out + + +def test_build_user_context_omits_deduced_facts_section_when_none() -> None: + """`deduced_facts_block=None` → the entire section is suppressed; no + confusing empty heading slips into the prompt.""" + seats = [_ctx_seat(1, "Alice")] + players = [_ctx_player(1)] + out = build_user_context( + game=_ctx_game(), + me=players[0], + my_seat=seats[0], + seats=seats, + players=players, + public_logs=[], + private_logs=[], + deduced_facts_block=None, + ) + assert "公開情報からの確定/推測事実" not in out diff --git a/tests/test_llm_structured_output.py b/tests/test_llm_structured_output.py index a50ee66..aa04498 100644 --- a/tests/test_llm_structured_output.py +++ b/tests/test_llm_structured_output.py @@ -66,6 +66,7 @@ def test_response_schema_has_required_fields() -> None: "target_name", "reason_summary", "confidence", + "co_declaration", } assert schema["additionalProperties"] is False diff --git a/tests/test_public_discussion_state.py b/tests/test_public_discussion_state.py index d6c69da..9200105 100644 --- a/tests/test_public_discussion_state.py +++ b/tests/test_public_discussion_state.py @@ -168,3 +168,72 @@ def test_rebuild_is_independent_of_caller_supplied_seed() -> None: s2 = rebuild_public_state_from_events(events) assert s1 is not None and s2 is not None assert s1 == s2 + + +def test_co_claim_picks_up_structured_co_declaration_field() -> None: + """Natural-language utterances ('実は私、占い師なんだ') don't contain + the legacy '占いCO' marker, but the structured `co_declaration` field + is authoritative — `_resolve_co_role` prefers it over substring scan + so NPC speech with no jargon still produces a CoClaim. + """ + pid = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id="g1", + phase_id=pid, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=0, + ) + natural_co = make_npc_generated_event( + game_id="g1", + phase_id=pid, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="実は私、占い師なんだ。昨夜は3番を見たけど、白だった。", + co_declaration="seer", + created_at_ms=1, + ) + state = rebuild_public_state_from_events([sentinel, natural_co]) + assert state is not None + assert [(c.seat, c.role_claim) for c in state.co_claims] == [(2, "seer")] + + +def test_co_claim_topical_mention_without_structured_field_is_ignored() -> None: + """A non-CO event that mentions 'CO' textually but has co_declaration=None + must NOT produce a CoClaim — that's the whole point of the structured + field replacing fuzzy substring matching for NPC text. Substring + fallback is reserved for legacy events without the field set.""" + pid = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id="g1", + phase_id=pid, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=0, + ) + topical = make_npc_generated_event( + game_id="g1", + phase_id=pid, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="他に占い師として名乗る人はいる?", + co_declaration=None, + created_at_ms=1, + ) + state = rebuild_public_state_from_events([sentinel, topical]) + assert state is not None + assert state.co_claims == () + + +def test_co_claim_legacy_substring_fallback_still_works_when_field_absent() -> None: + """Backwards compat: old events written before the structured field are + still parsed via `_CO_MARKERS` substring scan when co_declaration=None. + """ + events = _seed(alive=[1, 2, 3], events_payload=[(1, "占いCOします")]) + state = rebuild_public_state_from_events(events) + assert state is not None + assert [(c.seat, c.role_claim) for c in state.co_claims] == [(1, "seer")] From b092915a97423d98dc74bb2931ebafca44544b43 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 16:29:28 +0900 Subject: [PATCH 005/133] feat: add tmux launcher scripts for Master + NPC bots scripts/run-bots.sh creates a tmux session with one window per bot (Master + auto-detected NPC personas from envs/npc/.env.), streams each bot's output to logs/.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. --- .gitignore | 3 + README.md | 29 +++++++++ scripts/run-bots.sh | 148 +++++++++++++++++++++++++++++++++++++++++++ scripts/stop-bots.sh | 23 +++++++ 4 files changed, 203 insertions(+) create mode 100755 scripts/run-bots.sh create mode 100755 scripts/stop-bots.sh diff --git a/.gitignore b/.gitignore index 07183c0..647266f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ envs/npc/.env.* *.db-shm .DS_Store +# scripts/run-bots.sh writes per-bot logs here. +logs/ + # for git push .claude/ diff --git a/README.md b/README.md index fde36dd..9805246 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - xAI API キー - 既存のメイン text チャンネル - 既存のメイン VC +- (任意) `tmux` — `scripts/run-bots.sh` で複数 bot をまとめて起動する場合のみ。macOS は `brew install tmux`。 ## セットアップ @@ -116,6 +117,34 @@ uv run wolfbot 起動後、`/wolf` コマンドが設定した guild に同期されます。 +### 5. (任意) Master + 複数 NPC bot を tmux でまとめて起動 + +reactive_voice モードで Master と複数の NPC bot を一度に起動したい場合、`scripts/run-bots.sh` が tmux で 1 ウィンドウ = 1 bot のセッションを作ります。macOS 前提 (Linux でも動作)、`tmux` が必要 (`brew install tmux`)。 + +```bash +# envs/npc/.env. が存在するペルソナを自動検出して全部起動 +scripts/run-bots.sh + +# 個別指定したい場合 +scripts/run-bots.sh setsu gina sq raqio + +# 既に同名セッションがあるときは停止して作り直す +FORCE=1 scripts/run-bots.sh + +# セッションに入って各ウィンドウのログを見る +tmux attach -t wolfbot +# prefix + n / p : 次/前ウィンドウ +# prefix + 0..9 / w : 番号 / 一覧から選択 +# prefix + d : デタッチ (bot は動き続ける) + +# 全部止める +scripts/stop-bots.sh +``` + +各 bot のログは tmux ペインと `logs/.log` の両方にストリームされます。プロセスが落ちてもウィンドウは残るので、終了直後の出力を確認できます。 + +**人間 0 / NPC bot 9 体だけのゲーム**を観戦したい場合: `envs/npc/.env.` を 9 ペルソナぶん用意して `scripts/run-bots.sh` で全部起動 → Discord で `/wolf create` → `/wolf join` は打たず `/wolf start` で人数不足の 9 席すべてが NPC bot で埋まります。 + ## 環境変数一覧 ### Master (`.env.master`, `wolfbot.config.MasterSettings`) diff --git a/scripts/run-bots.sh b/scripts/run-bots.sh new file mode 100755 index 0000000..8ae66f0 --- /dev/null +++ b/scripts/run-bots.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Launch the wolfbot Master and all configured NPC bots inside a single +# tmux session, one window per process. Designed for macOS (and Linux) +# but tested primarily on macOS with iTerm2 + Homebrew tmux. +# +# Usage: +# scripts/run-bots.sh # auto-detect personas from envs/npc/.env.* +# scripts/run-bots.sh setsu gina sq # only the listed personas +# FORCE=1 scripts/run-bots.sh # kill & recreate a stale session +# +# After the session is up: +# tmux attach -t wolfbot # open the session +# prefix + n / p # next/prev window +# prefix + 0..9 / w # jump by index / pick from list +# prefix + d # detach (bots keep running) +# +# Stop everything: scripts/stop-bots.sh + +set -euo pipefail + +# ─── locate repo root (parent of this script's dir) ─────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${REPO_ROOT}" + +SESSION="${WOLFBOT_TMUX_SESSION:-wolfbot}" +LOG_DIR="${REPO_ROOT}/logs" +mkdir -p "${LOG_DIR}" + +# ─── prerequisites ──────────────────────────────────────────────────────── +if ! command -v tmux >/dev/null 2>&1; then + echo "ERROR: tmux is not installed." + echo " macOS: brew install tmux" + echo " Linux: apt install tmux (or your distro equivalent)" + exit 1 +fi + +if ! command -v uv >/dev/null 2>&1; then + echo "ERROR: uv is not installed (https://docs.astral.sh/uv/)." + exit 1 +fi + +if [[ ! -f "${REPO_ROOT}/.env.master" ]]; then + echo "ERROR: ${REPO_ROOT}/.env.master is missing." + echo " Copy .env.master.example, fill the secrets, and rerun." + exit 1 +fi + +# ─── work out which NPC personas to launch ──────────────────────────────── +declare -a PERSONAS=() +if [[ $# -gt 0 ]]; then + PERSONAS=("$@") +else + # Auto-detect from envs/npc/.env. (real, non-example files only). + while IFS= read -r -d '' env_file; do + base="$(basename "${env_file}")" + # Strip the `.env.` prefix. Skip *.example templates. + persona="${base#.env.}" + [[ "${persona}" == *.example ]] && continue + PERSONAS+=("${persona}") + done < <(find "${REPO_ROOT}/envs/npc" -maxdepth 1 -type f -name '.env.*' -print0 | sort -z) +fi + +if [[ ${#PERSONAS[@]} -eq 0 ]]; then + echo "ERROR: no NPC env files found under envs/npc/." + echo " Copy a template (e.g. envs/npc/.env.setsu.example → envs/npc/.env.setsu)," + echo " fill the secrets, and rerun." + echo " Or pass persona keys explicitly: scripts/run-bots.sh setsu gina sq" + exit 1 +fi + +# Validate that every requested persona has a real env file. +for persona in "${PERSONAS[@]}"; do + env_path="envs/npc/.env.${persona}" + if [[ ! -f "${env_path}" ]]; then + echo "ERROR: ${env_path} not found." + echo " cp envs/npc/.env.${persona}.example envs/npc/.env.${persona}" + echo " and fill in the secrets, then rerun." + exit 1 + fi +done + +# ─── handle existing session ────────────────────────────────────────────── +if tmux has-session -t "${SESSION}" 2>/dev/null; then + if [[ "${FORCE:-0}" == "1" ]]; then + echo "Killing existing tmux session '${SESSION}' (FORCE=1)." + tmux kill-session -t "${SESSION}" + else + echo "tmux session '${SESSION}' is already running." + echo "Attach with: tmux attach -t ${SESSION}" + echo "Recreate with: FORCE=1 scripts/run-bots.sh" + exit 0 + fi +fi + +# ─── helper to wrap a long-running command with logging + hold-on-exit ──── +# `tmux send-keys` is used so each window can be re-entered later. The +# trailing `; echo …; exec bash` keeps the pane open after the process +# exits, surfacing the failure log so the user can read it before manual +# teardown. +launch_in_window() { + local window_name="$1" + local log_file="$2" + local cmd="$3" + local create_new="${4:-yes}" + + if [[ "${create_new}" == "yes" ]]; then + tmux new-window -t "${SESSION}" -n "${window_name}" -c "${REPO_ROOT}" + else + tmux rename-window -t "${SESSION}:0" "${window_name}" + fi + # `2>&1 | tee` so logs stream both to the pane and the file. + tmux send-keys -t "${SESSION}:${window_name}" \ + "echo '── ${window_name} starting at $(date) ──' && ${cmd} 2>&1 | tee '${log_file}'; echo; echo '── ${window_name} exited (rc=${PIPESTATUS[0]:-?}) ──'; exec bash" \ + C-m +} + +# ─── create the session, with the master in window 0 ────────────────────── +echo "Starting tmux session '${SESSION}' with 1 master + ${#PERSONAS[@]} NPC bot(s)..." +tmux new-session -d -s "${SESSION}" -c "${REPO_ROOT}" + +launch_in_window "master" "${LOG_DIR}/master.log" "uv run wolfbot" "no" + +for persona in "${PERSONAS[@]}"; do + launch_in_window \ + "${persona}" \ + "${LOG_DIR}/${persona}.log" \ + "WOLFBOT_NPC_ENV=envs/npc/.env.${persona} uv run wolfbot-npc" +done + +# Land on the master window when the user attaches. +tmux select-window -t "${SESSION}:master" + +cat <.log + +Attach: tmux attach -t ${SESSION} +List windows: tmux list-windows -t ${SESSION} +Stop all: scripts/stop-bots.sh + +NPCs (${#PERSONAS[@]}): +EOF +for persona in "${PERSONAS[@]}"; do + echo " - ${persona}" +done diff --git a/scripts/stop-bots.sh b/scripts/stop-bots.sh new file mode 100755 index 0000000..310e5b5 --- /dev/null +++ b/scripts/stop-bots.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Cleanly tear down the wolfbot tmux session created by run-bots.sh. +# +# Each window holds two processes once the bot exits: the bot itself and +# the `exec bash` we left running so the log stays readable. Killing the +# session takes both down in one go. + +set -euo pipefail + +SESSION="${WOLFBOT_TMUX_SESSION:-wolfbot}" + +if ! command -v tmux >/dev/null 2>&1; then + echo "ERROR: tmux is not installed." + exit 1 +fi + +if tmux has-session -t "${SESSION}" 2>/dev/null; then + echo "Stopping tmux session '${SESSION}'..." + tmux kill-session -t "${SESSION}" + echo "Done." +else + echo "No tmux session named '${SESSION}' is running." +fi From e7a6eba6af1ea907419dd6917d1cf824311c751b Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 17:36:23 +0900 Subject: [PATCH 006/133] feat: add tts_voice_id to Persona dataclass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/wolfbot/llm/persona_base.py | 1 + src/wolfbot/npc/personas.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/wolfbot/llm/persona_base.py b/src/wolfbot/llm/persona_base.py index bd6662e..1bdab3d 100644 --- a/src/wolfbot/llm/persona_base.py +++ b/src/wolfbot/llm/persona_base.py @@ -83,6 +83,7 @@ class Persona: style_guide: str speech_profile: SpeechProfile judgment_profile: JudgmentProfile = _DEFAULT_JUDGMENT_PROFILE + tts_voice_id: int | None = None def index_by_key(pool: Sequence[Persona]) -> dict[str, Persona]: diff --git a/src/wolfbot/npc/personas.py b/src/wolfbot/npc/personas.py index 3b2d42e..ee2c609 100644 --- a/src/wolfbot/npc/personas.py +++ b/src/wolfbot/npc/personas.py @@ -45,6 +45,7 @@ aggression=0.4, bandwagon_tendency=0.5, ), + tts_voice_id=8, ), Persona( key="gina", @@ -67,6 +68,7 @@ aggression=0.25, bandwagon_tendency=0.3, ), + tts_voice_id=9, ), Persona( key="sq", @@ -95,6 +97,7 @@ aggression=0.55, bandwagon_tendency=0.3, ), + tts_voice_id=2, ), Persona( key="raqio", @@ -117,6 +120,7 @@ aggression=0.85, bandwagon_tendency=0.15, ), + tts_voice_id=13, ), Persona( key="stella", @@ -144,6 +148,7 @@ aggression=0.3, bandwagon_tendency=0.5, ), + tts_voice_id=4, ), Persona( key="shigemichi", @@ -167,6 +172,7 @@ aggression=0.85, bandwagon_tendency=0.7, ), + tts_voice_id=11, ), Persona( key="chipie", @@ -190,6 +196,7 @@ aggression=0.4, bandwagon_tendency=0.4, ), + tts_voice_id=6, ), Persona( key="comet", @@ -213,6 +220,7 @@ aggression=0.6, bandwagon_tendency=0.4, ), + tts_voice_id=1, ), Persona( key="jonas", @@ -236,6 +244,7 @@ aggression=0.7, bandwagon_tendency=0.3, ), + tts_voice_id=12, ), Persona( key="kukrushka", @@ -256,6 +265,7 @@ aggression=0.3, bandwagon_tendency=0.3, ), + tts_voice_id=0, ), Persona( key="otome", @@ -278,6 +288,7 @@ aggression=0.5, bandwagon_tendency=0.55, ), + tts_voice_id=7, ), Persona( key="sha_ming", @@ -301,6 +312,7 @@ aggression=0.7, bandwagon_tendency=0.3, ), + tts_voice_id=5, ), Persona( key="remnan", @@ -324,6 +336,7 @@ aggression=0.2, bandwagon_tendency=0.25, ), + tts_voice_id=10, ), Persona( key="yuriko", @@ -351,6 +364,7 @@ aggression=0.85, bandwagon_tendency=0.15, ), + tts_voice_id=3, ), ) From 3f7a2ee3a89a45b3a84c050b8891006b30fcfac4 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 17:36:50 +0900 Subject: [PATCH 007/133] feat: consolidate NPC env templates into a single generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces 14 per-persona templates (envs/npc/.env..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: ': ') - .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. 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. --- .gitignore | 10 +- README.md | 19 ++- envs/npc/.env.chipie.example | 49 ------ envs/npc/.env.comet.example | 49 ------ envs/npc/.env.gina.example | 49 ------ envs/npc/.env.jonas.example | 49 ------ envs/npc/.env.kukrushka.example | 49 ------ envs/npc/.env.npc.example | 44 ++++++ envs/npc/.env.otome.example | 49 ------ envs/npc/.env.raqio.example | 49 ------ envs/npc/.env.remnan.example | 49 ------ envs/npc/.env.setsu.example | 49 ------ envs/npc/.env.sha_ming.example | 49 ------ envs/npc/.env.shigemichi.example | 49 ------ envs/npc/.env.sq.example | 49 ------ envs/npc/.env.stella.example | 49 ------ envs/npc/.env.yuriko.example | 49 ------ envs/npc/README.md | 103 +++++++----- scripts/generate_npc_envs.py | 261 +++++++++++++++++++++++++++++++ 19 files changed, 390 insertions(+), 733 deletions(-) delete mode 100644 envs/npc/.env.chipie.example delete mode 100644 envs/npc/.env.comet.example delete mode 100644 envs/npc/.env.gina.example delete mode 100644 envs/npc/.env.jonas.example delete mode 100644 envs/npc/.env.kukrushka.example create mode 100644 envs/npc/.env.npc.example delete mode 100644 envs/npc/.env.otome.example delete mode 100644 envs/npc/.env.raqio.example delete mode 100644 envs/npc/.env.remnan.example delete mode 100644 envs/npc/.env.setsu.example delete mode 100644 envs/npc/.env.sha_ming.example delete mode 100644 envs/npc/.env.shigemichi.example delete mode 100644 envs/npc/.env.sq.example delete mode 100644 envs/npc/.env.stella.example delete mode 100644 envs/npc/.env.yuriko.example create mode 100755 scripts/generate_npc_envs.py diff --git a/.gitignore b/.gitignore index 647266f..5bb034c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,11 +9,13 @@ dist/ build/ .env .env.master -# NPC bot env files live under envs/npc/. The committed `.env.*.example` -# templates are explicitly un-ignored; the corresponding real `.env.*` -# files (with secrets filled in) stay out of git. +# NPC bot env files live under envs/npc/. The committed `.env.npc.example` +# template is the only one tracked; the per-persona `.env.` files +# (generated by scripts/generate_npc_envs.py) stay out of git. envs/npc/.env.* -!envs/npc/.env.*.example +!envs/npc/.env.npc.example +# Local secrets used by the generator +tokens.txt *.db *.db-journal *.db-wal diff --git a/README.md b/README.md index 9805246..65d1fc7 100644 --- a/README.md +++ b/README.md @@ -38,16 +38,21 @@ uv sync cp .env.master.example .env.master ``` -reactive_voice モードで NPC bot も動かす場合は、**1 ペルソナ = 1 プロセス = 1 Discord bot アカウント** で別途用意します。リポジトリには 14 体ぶんのテンプレが [envs/npc/](envs/npc/) 配下に `.env..example` の形で入っているので、必要なペルソナぶんコピーして秘密値を埋めます。 +reactive_voice モードで NPC bot も動かす場合は、**1 ペルソナ = 1 プロセス = 1 Discord bot アカウント** で別途用意します。各ペルソナの env ファイルは手書きせず、リポジトリルートの `tokens.txt` (gitignored) と単一テンプレ `envs/npc/.env.npc.example` から自動生成します。 ```bash -cp envs/npc/.env.setsu.example envs/npc/.env.setsu -# envs/npc/.env.setsu の NPC_DISCORD_TOKEN / MASTER_NPC_PSK / -# NPC_LLM_API_KEY / DISCORD_GUILD_ID / MAIN_VOICE_CHANNEL_ID を埋める +# 1) tokens.txt にトークンを 1 行 1 ペルソナで貼る (リポジトリルートに作る) +# 例: +# setsu: MTQ5... +# gina: MTQ5... +# sq: MTQ5... +# ... -WOLFBOT_NPC_ENV=envs/npc/.env.setsu uv run wolfbot-npc & -WOLFBOT_NPC_ENV=envs/npc/.env.gina uv run wolfbot-npc & -# 必要なペルソナぶん起動 +# 2) 生成 (envs/npc/.env. がペルソナぶんできる) +python3 scripts/generate_npc_envs.py + +# 3) tmux で Master + 起動済みの全 NPC を一括起動 +scripts/run-bots.sh ``` 各 NPC bot のセットアップ詳細・ペルソナ一覧・TTS_VOICE_ID の既定値は [envs/npc/README.md](envs/npc/README.md) を参照してください。 diff --git a/envs/npc/.env.chipie.example b/envs/npc/.env.chipie.example deleted file mode 100644 index 322829a..0000000 --- a/envs/npc/.env.chipie.example +++ /dev/null @@ -1,49 +0,0 @@ -# ── NPC bot · 🐈‍⬛シピ (envs/npc/.env.chipie.example) ─ -# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.chipie` -# にコピーし、`要記入` の欄を埋めてから: -# -# WOLFBOT_NPC_ENV=envs/npc/.env.chipie uv run wolfbot-npc -# -# Persona: 柔らかく観察力がある。対立をなだめつつ疑問を出す。 - -# ── 必須 (要記入) ────────────────────────────────────── -# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) -NPC_ID=npc_chipie -# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) -NPC_DISCORD_TOKEN= -# Master 側 .env.master と同値 -DISCORD_GUILD_ID= -MAIN_VOICE_CHANNEL_ID= -# Master の MASTER_NPC_PSK と同値 -MASTER_NPC_PSK= -# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 -# 別プロバイダ用に独立したキーにしてもよい) -NPC_LLM_API_KEY= - -# ── ペルソナ固定 (変更不要) ──────────────────────────── -# Master が `npc_register` で受け取り、このペルソナだけで席を埋める -NPC_PERSONA_KEY=chipie - -# ── デフォルトでよい (必要なら上書き) ────────────────── -MASTER_WS_URL=ws://127.0.0.1:8800 - -# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 -# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は -# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 -# -# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 -# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: -# xAI Grok https://api.x.ai/v1 grok-4-1-fast -# OpenAI https://api.openai.com/v1 gpt-4o-mini -# Groq https://api.groq.com/openai/v1 llama-3.3-70b -# Together AI https://api.together.xyz/v1 ... -# vLLM/Ollama http://localhost:8000/v1 ... -NPC_LLM_MODEL=grok-4-1-fast -NPC_LLM_BASE_URL=https://api.x.ai/v1 - -# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 -# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 -TTS_VOICE_ID=6 -VOICEVOX_URL=http://localhost:50021 -HEARTBEAT_INTERVAL_S=5 -LOG_LEVEL=INFO diff --git a/envs/npc/.env.comet.example b/envs/npc/.env.comet.example deleted file mode 100644 index 1c9c652..0000000 --- a/envs/npc/.env.comet.example +++ /dev/null @@ -1,49 +0,0 @@ -# ── NPC bot · ☄️コメット (envs/npc/.env.comet.example) ─ -# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.comet` -# にコピーし、`要記入` の欄を埋めてから: -# -# WOLFBOT_NPC_ENV=envs/npc/.env.comet uv run wolfbot-npc -# -# Persona: 無邪気で気まぐれ。妙に核心を突く。 - -# ── 必須 (要記入) ────────────────────────────────────── -# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) -NPC_ID=npc_comet -# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) -NPC_DISCORD_TOKEN= -# Master 側 .env.master と同値 -DISCORD_GUILD_ID= -MAIN_VOICE_CHANNEL_ID= -# Master の MASTER_NPC_PSK と同値 -MASTER_NPC_PSK= -# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 -# 別プロバイダ用に独立したキーにしてもよい) -NPC_LLM_API_KEY= - -# ── ペルソナ固定 (変更不要) ──────────────────────────── -# Master が `npc_register` で受け取り、このペルソナだけで席を埋める -NPC_PERSONA_KEY=comet - -# ── デフォルトでよい (必要なら上書き) ────────────────── -MASTER_WS_URL=ws://127.0.0.1:8800 - -# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 -# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は -# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 -# -# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 -# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: -# xAI Grok https://api.x.ai/v1 grok-4-1-fast -# OpenAI https://api.openai.com/v1 gpt-4o-mini -# Groq https://api.groq.com/openai/v1 llama-3.3-70b -# Together AI https://api.together.xyz/v1 ... -# vLLM/Ollama http://localhost:8000/v1 ... -NPC_LLM_MODEL=grok-4-1-fast -NPC_LLM_BASE_URL=https://api.x.ai/v1 - -# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 -# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 -TTS_VOICE_ID=1 -VOICEVOX_URL=http://localhost:50021 -HEARTBEAT_INTERVAL_S=5 -LOG_LEVEL=INFO diff --git a/envs/npc/.env.gina.example b/envs/npc/.env.gina.example deleted file mode 100644 index 3cae44c..0000000 --- a/envs/npc/.env.gina.example +++ /dev/null @@ -1,49 +0,0 @@ -# ── NPC bot · 🟣ジナ (envs/npc/.env.gina.example) ─ -# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.gina` -# にコピーし、`要記入` の欄を埋めてから: -# -# WOLFBOT_NPC_ENV=envs/npc/.env.gina uv run wolfbot-npc -# -# Persona: 物静かで誠実。直感と共感を重視する。 - -# ── 必須 (要記入) ────────────────────────────────────── -# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) -NPC_ID=npc_gina -# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) -NPC_DISCORD_TOKEN= -# Master 側 .env.master と同値 -DISCORD_GUILD_ID= -MAIN_VOICE_CHANNEL_ID= -# Master の MASTER_NPC_PSK と同値 -MASTER_NPC_PSK= -# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 -# 別プロバイダ用に独立したキーにしてもよい) -NPC_LLM_API_KEY= - -# ── ペルソナ固定 (変更不要) ──────────────────────────── -# Master が `npc_register` で受け取り、このペルソナだけで席を埋める -NPC_PERSONA_KEY=gina - -# ── デフォルトでよい (必要なら上書き) ────────────────── -MASTER_WS_URL=ws://127.0.0.1:8800 - -# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 -# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は -# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 -# -# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 -# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: -# xAI Grok https://api.x.ai/v1 grok-4-1-fast -# OpenAI https://api.openai.com/v1 gpt-4o-mini -# Groq https://api.groq.com/openai/v1 llama-3.3-70b -# Together AI https://api.together.xyz/v1 ... -# vLLM/Ollama http://localhost:8000/v1 ... -NPC_LLM_MODEL=grok-4-1-fast -NPC_LLM_BASE_URL=https://api.x.ai/v1 - -# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 -# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 -TTS_VOICE_ID=9 -VOICEVOX_URL=http://localhost:50021 -HEARTBEAT_INTERVAL_S=5 -LOG_LEVEL=INFO diff --git a/envs/npc/.env.jonas.example b/envs/npc/.env.jonas.example deleted file mode 100644 index 693ebdc..0000000 --- a/envs/npc/.env.jonas.example +++ /dev/null @@ -1,49 +0,0 @@ -# ── NPC bot · 🎩ジョナス (envs/npc/.env.jonas.example) ─ -# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.jonas` -# にコピーし、`要記入` の欄を埋めてから: -# -# WOLFBOT_NPC_ENV=envs/npc/.env.jonas uv run wolfbot-npc -# -# Persona: 尊大で芝居がかった話し方。 - -# ── 必須 (要記入) ────────────────────────────────────── -# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) -NPC_ID=npc_jonas -# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) -NPC_DISCORD_TOKEN= -# Master 側 .env.master と同値 -DISCORD_GUILD_ID= -MAIN_VOICE_CHANNEL_ID= -# Master の MASTER_NPC_PSK と同値 -MASTER_NPC_PSK= -# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 -# 別プロバイダ用に独立したキーにしてもよい) -NPC_LLM_API_KEY= - -# ── ペルソナ固定 (変更不要) ──────────────────────────── -# Master が `npc_register` で受け取り、このペルソナだけで席を埋める -NPC_PERSONA_KEY=jonas - -# ── デフォルトでよい (必要なら上書き) ────────────────── -MASTER_WS_URL=ws://127.0.0.1:8800 - -# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 -# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は -# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 -# -# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 -# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: -# xAI Grok https://api.x.ai/v1 grok-4-1-fast -# OpenAI https://api.openai.com/v1 gpt-4o-mini -# Groq https://api.groq.com/openai/v1 llama-3.3-70b -# Together AI https://api.together.xyz/v1 ... -# vLLM/Ollama http://localhost:8000/v1 ... -NPC_LLM_MODEL=grok-4-1-fast -NPC_LLM_BASE_URL=https://api.x.ai/v1 - -# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 -# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 -TTS_VOICE_ID=12 -VOICEVOX_URL=http://localhost:50021 -HEARTBEAT_INTERVAL_S=5 -LOG_LEVEL=INFO diff --git a/envs/npc/.env.kukrushka.example b/envs/npc/.env.kukrushka.example deleted file mode 100644 index 2ed8bcd..0000000 --- a/envs/npc/.env.kukrushka.example +++ /dev/null @@ -1,49 +0,0 @@ -# ── NPC bot · 🧸ククルシカ (envs/npc/.env.kukrushka.example) ─ -# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.kukrushka` -# にコピーし、`要記入` の欄を埋めてから: -# -# WOLFBOT_NPC_ENV=envs/npc/.env.kukrushka uv run wolfbot-npc -# -# Persona: かわいらしく見えて不穏。ほぼ無言で身振りを示す。 - -# ── 必須 (要記入) ────────────────────────────────────── -# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) -NPC_ID=npc_kukrushka -# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) -NPC_DISCORD_TOKEN= -# Master 側 .env.master と同値 -DISCORD_GUILD_ID= -MAIN_VOICE_CHANNEL_ID= -# Master の MASTER_NPC_PSK と同値 -MASTER_NPC_PSK= -# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 -# 別プロバイダ用に独立したキーにしてもよい) -NPC_LLM_API_KEY= - -# ── ペルソナ固定 (変更不要) ──────────────────────────── -# Master が `npc_register` で受け取り、このペルソナだけで席を埋める -NPC_PERSONA_KEY=kukrushka - -# ── デフォルトでよい (必要なら上書き) ────────────────── -MASTER_WS_URL=ws://127.0.0.1:8800 - -# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 -# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は -# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 -# -# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 -# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: -# xAI Grok https://api.x.ai/v1 grok-4-1-fast -# OpenAI https://api.openai.com/v1 gpt-4o-mini -# Groq https://api.groq.com/openai/v1 llama-3.3-70b -# Together AI https://api.together.xyz/v1 ... -# vLLM/Ollama http://localhost:8000/v1 ... -NPC_LLM_MODEL=grok-4-1-fast -NPC_LLM_BASE_URL=https://api.x.ai/v1 - -# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 -# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 -TTS_VOICE_ID=0 -VOICEVOX_URL=http://localhost:50021 -HEARTBEAT_INTERVAL_S=5 -LOG_LEVEL=INFO diff --git a/envs/npc/.env.npc.example b/envs/npc/.env.npc.example new file mode 100644 index 0000000..4f80013 --- /dev/null +++ b/envs/npc/.env.npc.example @@ -0,0 +1,44 @@ +# ── Unified NPC bot template (envs/npc/.env.npc.example) ───────────── +# Don't edit per-persona env files (envs/npc/.env.) by hand — +# they are generated from this template by: +# +# python3 scripts/generate_npc_envs.py +# +# Inputs the generator combines: +# - tokens.txt (one line per persona: ": ") +# - .env.master (DISCORD_GUILD_ID / MAIN_VOICE_CHANNEL_ID +# / MASTER_NPC_PSK / GAMEPLAY_LLM_API_KEY) +# - wolfbot.npc.personas (NPC_PERSONA_KEY + TTS_VOICE_ID per persona) +# +# {{...}} placeholders below are substituted; everything else is copied +# verbatim. Re-running the generator overwrites existing per-persona files +# unless --no-overwrite is passed. + +# ── Identity (per-persona) ───────────────────────────────────────────── +NPC_ID={{NPC_ID}} +NPC_DISCORD_TOKEN={{NPC_DISCORD_TOKEN}} +NPC_PERSONA_KEY={{NPC_PERSONA_KEY}} + +# ── Discord guild + VC (shared, copied from .env.master) ─────────────── +DISCORD_GUILD_ID={{DISCORD_GUILD_ID}} +MAIN_VOICE_CHANNEL_ID={{MAIN_VOICE_CHANNEL_ID}} + +# ── Master ↔ NPC WebSocket (shared, must match .env.master) ──────────── +MASTER_NPC_PSK={{MASTER_NPC_PSK}} +MASTER_WS_URL=ws://127.0.0.1:8800 + +# ── NPC LLM (this NPC's reactive-utterance backend) ──────────────────── +# Same OpenAI Chat Completions interface as Master's gameplay LLM. +# May be reused from Master's GAMEPLAY_LLM_API_KEY or split per persona +# to point at a different provider/model. +NPC_LLM_API_KEY={{NPC_LLM_API_KEY}} +NPC_LLM_MODEL=grok-4-1-fast +NPC_LLM_BASE_URL=https://api.x.ai/v1 + +# ── VOICEVOX TTS (per-persona voice id, shared engine URL) ───────────── +TTS_VOICE_ID={{TTS_VOICE_ID}} +VOICEVOX_URL=http://localhost:50021 + +# ── Runtime knobs ────────────────────────────────────────────────────── +HEARTBEAT_INTERVAL_S=5 +LOG_LEVEL=INFO diff --git a/envs/npc/.env.otome.example b/envs/npc/.env.otome.example deleted file mode 100644 index d0ed691..0000000 --- a/envs/npc/.env.otome.example +++ /dev/null @@ -1,49 +0,0 @@ -# ── NPC bot · 🐬オトメ (envs/npc/.env.otome.example) ─ -# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.otome` -# にコピーし、`要記入` の欄を埋めてから: -# -# WOLFBOT_NPC_ENV=envs/npc/.env.otome uv run wolfbot-npc -# -# Persona: 事務的で面倒見がよい。要点中心。 - -# ── 必須 (要記入) ────────────────────────────────────── -# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) -NPC_ID=npc_otome -# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) -NPC_DISCORD_TOKEN= -# Master 側 .env.master と同値 -DISCORD_GUILD_ID= -MAIN_VOICE_CHANNEL_ID= -# Master の MASTER_NPC_PSK と同値 -MASTER_NPC_PSK= -# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 -# 別プロバイダ用に独立したキーにしてもよい) -NPC_LLM_API_KEY= - -# ── ペルソナ固定 (変更不要) ──────────────────────────── -# Master が `npc_register` で受け取り、このペルソナだけで席を埋める -NPC_PERSONA_KEY=otome - -# ── デフォルトでよい (必要なら上書き) ────────────────── -MASTER_WS_URL=ws://127.0.0.1:8800 - -# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 -# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は -# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 -# -# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 -# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: -# xAI Grok https://api.x.ai/v1 grok-4-1-fast -# OpenAI https://api.openai.com/v1 gpt-4o-mini -# Groq https://api.groq.com/openai/v1 llama-3.3-70b -# Together AI https://api.together.xyz/v1 ... -# vLLM/Ollama http://localhost:8000/v1 ... -NPC_LLM_MODEL=grok-4-1-fast -NPC_LLM_BASE_URL=https://api.x.ai/v1 - -# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 -# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 -TTS_VOICE_ID=7 -VOICEVOX_URL=http://localhost:50021 -HEARTBEAT_INTERVAL_S=5 -LOG_LEVEL=INFO diff --git a/envs/npc/.env.raqio.example b/envs/npc/.env.raqio.example deleted file mode 100644 index ffa3006..0000000 --- a/envs/npc/.env.raqio.example +++ /dev/null @@ -1,49 +0,0 @@ -# ── NPC bot · 🦋ラキオ (envs/npc/.env.raqio.example) ─ -# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.raqio` -# にコピーし、`要記入` の欄を埋めてから: -# -# WOLFBOT_NPC_ENV=envs/npc/.env.raqio uv run wolfbot-npc -# -# Persona: 論理偏重で挑発的。矛盾追及が鋭い。 - -# ── 必須 (要記入) ────────────────────────────────────── -# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) -NPC_ID=npc_raqio -# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) -NPC_DISCORD_TOKEN= -# Master 側 .env.master と同値 -DISCORD_GUILD_ID= -MAIN_VOICE_CHANNEL_ID= -# Master の MASTER_NPC_PSK と同値 -MASTER_NPC_PSK= -# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 -# 別プロバイダ用に独立したキーにしてもよい) -NPC_LLM_API_KEY= - -# ── ペルソナ固定 (変更不要) ──────────────────────────── -# Master が `npc_register` で受け取り、このペルソナだけで席を埋める -NPC_PERSONA_KEY=raqio - -# ── デフォルトでよい (必要なら上書き) ────────────────── -MASTER_WS_URL=ws://127.0.0.1:8800 - -# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 -# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は -# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 -# -# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 -# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: -# xAI Grok https://api.x.ai/v1 grok-4-1-fast -# OpenAI https://api.openai.com/v1 gpt-4o-mini -# Groq https://api.groq.com/openai/v1 llama-3.3-70b -# Together AI https://api.together.xyz/v1 ... -# vLLM/Ollama http://localhost:8000/v1 ... -NPC_LLM_MODEL=grok-4-1-fast -NPC_LLM_BASE_URL=https://api.x.ai/v1 - -# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 -# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 -TTS_VOICE_ID=13 -VOICEVOX_URL=http://localhost:50021 -HEARTBEAT_INTERVAL_S=5 -LOG_LEVEL=INFO diff --git a/envs/npc/.env.remnan.example b/envs/npc/.env.remnan.example deleted file mode 100644 index 0f36102..0000000 --- a/envs/npc/.env.remnan.example +++ /dev/null @@ -1,49 +0,0 @@ -# ── NPC bot · ⚪️レムナン (envs/npc/.env.remnan.example) ─ -# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.remnan` -# にコピーし、`要記入` の欄を埋めてから: -# -# WOLFBOT_NPC_ENV=envs/npc/.env.remnan uv run wolfbot-npc -# -# Persona: 内向的で慎重。観察は細かい。 - -# ── 必須 (要記入) ────────────────────────────────────── -# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) -NPC_ID=npc_remnan -# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) -NPC_DISCORD_TOKEN= -# Master 側 .env.master と同値 -DISCORD_GUILD_ID= -MAIN_VOICE_CHANNEL_ID= -# Master の MASTER_NPC_PSK と同値 -MASTER_NPC_PSK= -# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 -# 別プロバイダ用に独立したキーにしてもよい) -NPC_LLM_API_KEY= - -# ── ペルソナ固定 (変更不要) ──────────────────────────── -# Master が `npc_register` で受け取り、このペルソナだけで席を埋める -NPC_PERSONA_KEY=remnan - -# ── デフォルトでよい (必要なら上書き) ────────────────── -MASTER_WS_URL=ws://127.0.0.1:8800 - -# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 -# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は -# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 -# -# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 -# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: -# xAI Grok https://api.x.ai/v1 grok-4-1-fast -# OpenAI https://api.openai.com/v1 gpt-4o-mini -# Groq https://api.groq.com/openai/v1 llama-3.3-70b -# Together AI https://api.together.xyz/v1 ... -# vLLM/Ollama http://localhost:8000/v1 ... -NPC_LLM_MODEL=grok-4-1-fast -NPC_LLM_BASE_URL=https://api.x.ai/v1 - -# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 -# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 -TTS_VOICE_ID=10 -VOICEVOX_URL=http://localhost:50021 -HEARTBEAT_INTERVAL_S=5 -LOG_LEVEL=INFO diff --git a/envs/npc/.env.setsu.example b/envs/npc/.env.setsu.example deleted file mode 100644 index 2f011f5..0000000 --- a/envs/npc/.env.setsu.example +++ /dev/null @@ -1,49 +0,0 @@ -# ── NPC bot · 🟡セツ (envs/npc/.env.setsu.example) ─ -# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.setsu` -# にコピーし、`要記入` の欄を埋めてから: -# -# WOLFBOT_NPC_ENV=envs/npc/.env.setsu uv run wolfbot-npc -# -# Persona: 真面目で責任感が強い。議論を整理する。 - -# ── 必須 (要記入) ────────────────────────────────────── -# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) -NPC_ID=npc_setsu -# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) -NPC_DISCORD_TOKEN= -# Master 側 .env.master と同値 -DISCORD_GUILD_ID= -MAIN_VOICE_CHANNEL_ID= -# Master の MASTER_NPC_PSK と同値 -MASTER_NPC_PSK= -# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 -# 別プロバイダ用に独立したキーにしてもよい) -NPC_LLM_API_KEY= - -# ── ペルソナ固定 (変更不要) ──────────────────────────── -# Master が `npc_register` で受け取り、このペルソナだけで席を埋める -NPC_PERSONA_KEY=setsu - -# ── デフォルトでよい (必要なら上書き) ────────────────── -MASTER_WS_URL=ws://127.0.0.1:8800 - -# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 -# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は -# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 -# -# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 -# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: -# xAI Grok https://api.x.ai/v1 grok-4-1-fast -# OpenAI https://api.openai.com/v1 gpt-4o-mini -# Groq https://api.groq.com/openai/v1 llama-3.3-70b -# Together AI https://api.together.xyz/v1 ... -# vLLM/Ollama http://localhost:8000/v1 ... -NPC_LLM_MODEL=grok-4-1-fast -NPC_LLM_BASE_URL=https://api.x.ai/v1 - -# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 -# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 -TTS_VOICE_ID=8 -VOICEVOX_URL=http://localhost:50021 -HEARTBEAT_INTERVAL_S=5 -LOG_LEVEL=INFO diff --git a/envs/npc/.env.sha_ming.example b/envs/npc/.env.sha_ming.example deleted file mode 100644 index d953ad0..0000000 --- a/envs/npc/.env.sha_ming.example +++ /dev/null @@ -1,49 +0,0 @@ -# ── NPC bot · 🥽シャーミン (envs/npc/.env.sha_ming.example) ─ -# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.sha_ming` -# にコピーし、`要記入` の欄を埋めてから: -# -# WOLFBOT_NPC_ENV=envs/npc/.env.sha_ming uv run wolfbot-npc -# -# Persona: 皮肉屋で自信家。挑発的に試す。 - -# ── 必須 (要記入) ────────────────────────────────────── -# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) -NPC_ID=npc_sha_ming -# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) -NPC_DISCORD_TOKEN= -# Master 側 .env.master と同値 -DISCORD_GUILD_ID= -MAIN_VOICE_CHANNEL_ID= -# Master の MASTER_NPC_PSK と同値 -MASTER_NPC_PSK= -# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 -# 別プロバイダ用に独立したキーにしてもよい) -NPC_LLM_API_KEY= - -# ── ペルソナ固定 (変更不要) ──────────────────────────── -# Master が `npc_register` で受け取り、このペルソナだけで席を埋める -NPC_PERSONA_KEY=sha_ming - -# ── デフォルトでよい (必要なら上書き) ────────────────── -MASTER_WS_URL=ws://127.0.0.1:8800 - -# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 -# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は -# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 -# -# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 -# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: -# xAI Grok https://api.x.ai/v1 grok-4-1-fast -# OpenAI https://api.openai.com/v1 gpt-4o-mini -# Groq https://api.groq.com/openai/v1 llama-3.3-70b -# Together AI https://api.together.xyz/v1 ... -# vLLM/Ollama http://localhost:8000/v1 ... -NPC_LLM_MODEL=grok-4-1-fast -NPC_LLM_BASE_URL=https://api.x.ai/v1 - -# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 -# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 -TTS_VOICE_ID=5 -VOICEVOX_URL=http://localhost:50021 -HEARTBEAT_INTERVAL_S=5 -LOG_LEVEL=INFO diff --git a/envs/npc/.env.shigemichi.example b/envs/npc/.env.shigemichi.example deleted file mode 100644 index 2e66bb3..0000000 --- a/envs/npc/.env.shigemichi.example +++ /dev/null @@ -1,49 +0,0 @@ -# ── NPC bot · 👽シゲミチ (envs/npc/.env.shigemichi.example) ─ -# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.shigemichi` -# にコピーし、`要記入` の欄を埋めてから: -# -# WOLFBOT_NPC_ENV=envs/npc/.env.shigemichi uv run wolfbot-npc -# -# Persona: 率直で豪快。印象や勢いを重視する。 - -# ── 必須 (要記入) ────────────────────────────────────── -# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) -NPC_ID=npc_shigemichi -# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) -NPC_DISCORD_TOKEN= -# Master 側 .env.master と同値 -DISCORD_GUILD_ID= -MAIN_VOICE_CHANNEL_ID= -# Master の MASTER_NPC_PSK と同値 -MASTER_NPC_PSK= -# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 -# 別プロバイダ用に独立したキーにしてもよい) -NPC_LLM_API_KEY= - -# ── ペルソナ固定 (変更不要) ──────────────────────────── -# Master が `npc_register` で受け取り、このペルソナだけで席を埋める -NPC_PERSONA_KEY=shigemichi - -# ── デフォルトでよい (必要なら上書き) ────────────────── -MASTER_WS_URL=ws://127.0.0.1:8800 - -# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 -# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は -# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 -# -# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 -# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: -# xAI Grok https://api.x.ai/v1 grok-4-1-fast -# OpenAI https://api.openai.com/v1 gpt-4o-mini -# Groq https://api.groq.com/openai/v1 llama-3.3-70b -# Together AI https://api.together.xyz/v1 ... -# vLLM/Ollama http://localhost:8000/v1 ... -NPC_LLM_MODEL=grok-4-1-fast -NPC_LLM_BASE_URL=https://api.x.ai/v1 - -# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 -# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 -TTS_VOICE_ID=11 -VOICEVOX_URL=http://localhost:50021 -HEARTBEAT_INTERVAL_S=5 -LOG_LEVEL=INFO diff --git a/envs/npc/.env.sq.example b/envs/npc/.env.sq.example deleted file mode 100644 index 29ab6c5..0000000 --- a/envs/npc/.env.sq.example +++ /dev/null @@ -1,49 +0,0 @@ -# ── NPC bot · 🔴SQ (envs/npc/.env.sq.example) ─ -# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.sq` -# にコピーし、`要記入` の欄を埋めてから: -# -# WOLFBOT_NPC_ENV=envs/npc/.env.sq uv run wolfbot-npc -# -# Persona: 軽快で社交的。打算的な面もありつつ場を和ませる。 - -# ── 必須 (要記入) ────────────────────────────────────── -# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) -NPC_ID=npc_sq -# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) -NPC_DISCORD_TOKEN= -# Master 側 .env.master と同値 -DISCORD_GUILD_ID= -MAIN_VOICE_CHANNEL_ID= -# Master の MASTER_NPC_PSK と同値 -MASTER_NPC_PSK= -# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 -# 別プロバイダ用に独立したキーにしてもよい) -NPC_LLM_API_KEY= - -# ── ペルソナ固定 (変更不要) ──────────────────────────── -# Master が `npc_register` で受け取り、このペルソナだけで席を埋める -NPC_PERSONA_KEY=sq - -# ── デフォルトでよい (必要なら上書き) ────────────────── -MASTER_WS_URL=ws://127.0.0.1:8800 - -# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 -# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は -# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 -# -# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 -# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: -# xAI Grok https://api.x.ai/v1 grok-4-1-fast -# OpenAI https://api.openai.com/v1 gpt-4o-mini -# Groq https://api.groq.com/openai/v1 llama-3.3-70b -# Together AI https://api.together.xyz/v1 ... -# vLLM/Ollama http://localhost:8000/v1 ... -NPC_LLM_MODEL=grok-4-1-fast -NPC_LLM_BASE_URL=https://api.x.ai/v1 - -# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 -# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 -TTS_VOICE_ID=2 -VOICEVOX_URL=http://localhost:50021 -HEARTBEAT_INTERVAL_S=5 -LOG_LEVEL=INFO diff --git a/envs/npc/.env.stella.example b/envs/npc/.env.stella.example deleted file mode 100644 index 1d928a6..0000000 --- a/envs/npc/.env.stella.example +++ /dev/null @@ -1,49 +0,0 @@ -# ── NPC bot · 🌟ステラ (envs/npc/.env.stella.example) ─ -# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.stella` -# にコピーし、`要記入` の欄を埋めてから: -# -# WOLFBOT_NPC_ENV=envs/npc/.env.stella uv run wolfbot-npc -# -# Persona: 優しく献身的。柔らかい物言いを心がける。 - -# ── 必須 (要記入) ────────────────────────────────────── -# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) -NPC_ID=npc_stella -# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) -NPC_DISCORD_TOKEN= -# Master 側 .env.master と同値 -DISCORD_GUILD_ID= -MAIN_VOICE_CHANNEL_ID= -# Master の MASTER_NPC_PSK と同値 -MASTER_NPC_PSK= -# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 -# 別プロバイダ用に独立したキーにしてもよい) -NPC_LLM_API_KEY= - -# ── ペルソナ固定 (変更不要) ──────────────────────────── -# Master が `npc_register` で受け取り、このペルソナだけで席を埋める -NPC_PERSONA_KEY=stella - -# ── デフォルトでよい (必要なら上書き) ────────────────── -MASTER_WS_URL=ws://127.0.0.1:8800 - -# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 -# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は -# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 -# -# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 -# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: -# xAI Grok https://api.x.ai/v1 grok-4-1-fast -# OpenAI https://api.openai.com/v1 gpt-4o-mini -# Groq https://api.groq.com/openai/v1 llama-3.3-70b -# Together AI https://api.together.xyz/v1 ... -# vLLM/Ollama http://localhost:8000/v1 ... -NPC_LLM_MODEL=grok-4-1-fast -NPC_LLM_BASE_URL=https://api.x.ai/v1 - -# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 -# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 -TTS_VOICE_ID=4 -VOICEVOX_URL=http://localhost:50021 -HEARTBEAT_INTERVAL_S=5 -LOG_LEVEL=INFO diff --git a/envs/npc/.env.yuriko.example b/envs/npc/.env.yuriko.example deleted file mode 100644 index 24b867a..0000000 --- a/envs/npc/.env.yuriko.example +++ /dev/null @@ -1,49 +0,0 @@ -# ── NPC bot · 👑ユリコ (envs/npc/.env.yuriko.example) ─ -# 1 NPC bot = 1 process = 1 persona. このテンプレートを `envs/npc/.env.yuriko` -# にコピーし、`要記入` の欄を埋めてから: -# -# WOLFBOT_NPC_ENV=envs/npc/.env.yuriko uv run wolfbot-npc -# -# Persona: 冷静で威圧感がある。少ない語数で核心を突く。 - -# ── 必須 (要記入) ────────────────────────────────────── -# このプロセスの一意 ID (Master WS 上の識別子。複数ホストで運用する場合は別名にする) -NPC_ID=npc_yuriko -# このペルソナ専用の Discord bot トークン (Developer Portal で発行・guild に手動招待済みのもの) -NPC_DISCORD_TOKEN= -# Master 側 .env.master と同値 -DISCORD_GUILD_ID= -MAIN_VOICE_CHANNEL_ID= -# Master の MASTER_NPC_PSK と同値 -MASTER_NPC_PSK= -# NPC LLM 認証 (Master の GAMEPLAY_LLM_API_KEY と共用してもよいし、 -# 別プロバイダ用に独立したキーにしてもよい) -NPC_LLM_API_KEY= - -# ── ペルソナ固定 (変更不要) ──────────────────────────── -# Master が `npc_register` で受け取り、このペルソナだけで席を埋める -NPC_PERSONA_KEY=yuriko - -# ── デフォルトでよい (必要なら上書き) ────────────────── -MASTER_WS_URL=ws://127.0.0.1:8800 - -# NPC LLM (この NPC bot が "VC で短い発言を生成する" 側)。 -# 担当は DAY_DISCUSSION 中の reactive な 1 行発言のみ。投票・夜行動は -# Master の GAMEPLAY_LLM が担当するので、ここでは生成しない。 -# -# OpenAI Chat Completions 互換のエンドポイントなら何でも動く。 -# プロバイダ切り替えは NPC_LLM_BASE_URL + NPC_LLM_MODEL を変える: -# xAI Grok https://api.x.ai/v1 grok-4-1-fast -# OpenAI https://api.openai.com/v1 gpt-4o-mini -# Groq https://api.groq.com/openai/v1 llama-3.3-70b -# Together AI https://api.together.xyz/v1 ... -# vLLM/Ollama http://localhost:8000/v1 ... -NPC_LLM_MODEL=grok-4-1-fast -NPC_LLM_BASE_URL=https://api.x.ai/v1 - -# VOICEVOX のスピーカー ID — ペルソナごとに重複しないよう既定で割り当て済み。 -# 好みの声に変えたい場合のみ編集 (https://voicevox.hiroshiba.jp/ で確認)。 -TTS_VOICE_ID=3 -VOICEVOX_URL=http://localhost:50021 -HEARTBEAT_INTERVAL_S=5 -LOG_LEVEL=INFO diff --git a/envs/npc/README.md b/envs/npc/README.md index 27dff4c..e94590b 100644 --- a/envs/npc/README.md +++ b/envs/npc/README.md @@ -1,43 +1,76 @@ -# NPC bot env templates +# NPC bot env files -各ペルソナごとに 1 つのテンプレ (`.env..example`) がある。NPC bot は -**1 プロセス = 1 ペルソナ**なので、立ち上げたいペルソナぶんコピーして使う。 +NPC bot は **1 プロセス = 1 ペルソナ** で動きます。各ペルソナの env ファイル +(`envs/npc/.env.`) は手書きせず、テンプレと `tokens.txt` から +**生成スクリプトで自動作成**します。 -## ファイルの意味 +## 構成 -| パターン | 意味 | git | +| ファイル | 意味 | git | |---|---|---| -| `envs/npc/.env..example` | テンプレ (秘密値は空欄) | コミットされる | -| `envs/npc/.env.` | 実 env (秘密値を埋めたもの) | gitignore | +| `envs/npc/.env.npc.example` | 単一テンプレ (`{{...}}` プレースホルダ) | コミットされる | +| `envs/npc/.env.` | 生成された実 env (秘密値を含む) | gitignored | +| `tokens.txt` (リポジトリルート) | `: ` の一覧 | gitignored | -## 使い方 +## セットアップ + +### 1. tokens.txt を用意 + +リポジトリルートに `tokens.txt` を作り、Discord Developer Portal で発行した +NPC bot のトークンを 1 行 1 ペルソナで貼る: + +``` +master: ← 任意 (生成時はスキップされる) +setsu: <セツ bot のトークン> +gina: <ジナ bot のトークン> +sq: +raqio: <ラキオ bot のトークン> +... +``` + +- 行の先頭が `#` ならコメントとして無視される +- `master` 行は **Master 用なので生成対象から除外** される (Master のトークンは + `.env.master` に書く) +- `setu` のような typo は `setsu` に自動マッピングされる (`PERSONA_ALIASES` で + 定義済み) + +### 2. .env.master を先に用意 + +生成スクリプトは以下の値を `.env.master` から拾うので、先に値を埋めておく: + +- `DISCORD_GUILD_ID` +- `MAIN_VOICE_CHANNEL_ID` +- `MASTER_NPC_PSK` +- `GAMEPLAY_LLM_API_KEY` (各 NPC の `NPC_LLM_API_KEY` として再利用される) + +### 3. 生成スクリプトを実行 ```bash -# 1) テンプレをコピー -cp envs/npc/.env.setsu.example envs/npc/.env.setsu - -# 2) 編集して秘密値を埋める -# NPC_DISCORD_TOKEN ── このペルソナ専用の Discord bot トークン -# DISCORD_GUILD_ID ── Master と同じ guild ID -# MAIN_VOICE_CHANNEL_ID ── Master と同じ VC ID -# MASTER_NPC_PSK ── Master の MASTER_NPC_PSK と同値 -# NPC_LLM_API_KEY ── NPC LLM の API キー -# (Master の GAMEPLAY_LLM_API_KEY と共用可) -$EDITOR envs/npc/.env.setsu - -# 3) 起動 (env ファイルは WOLFBOT_NPC_ENV で指定) -WOLFBOT_NPC_ENV=envs/npc/.env.setsu uv run wolfbot-npc +# 全ペルソナぶん生成 +python3 scripts/generate_npc_envs.py + +# 既存ファイルを上書きしたくない +python3 scripts/generate_npc_envs.py --no-overwrite + +# 実際には書かず内容だけ確認 +python3 scripts/generate_npc_envs.py --dry-run + +# 入力パスを変えたい +python3 scripts/generate_npc_envs.py --tokens path/to/tokens.txt --master path/to/.env.master +``` -# 4) 別ペルソナを増やす場合は別プロセスで -WOLFBOT_NPC_ENV=envs/npc/.env.gina uv run wolfbot-npc & -WOLFBOT_NPC_ENV=envs/npc/.env.sq uv run wolfbot-npc & +`tokens.txt` を更新したり `.env.master` の共有値を変えたりしたら**再実行**で +全 NPC env を一括更新できます。手書き編集はやめて、編集はテンプレ側に集約。 + +### 4. 起動 + +```bash +WOLFBOT_NPC_ENV=envs/npc/.env.setsu uv run wolfbot-npc ``` -`reactive_voice` モードで `/wolf start` するとき、Master は -**online な NPC bot のうち未割当のものを席に充てる**。出したい -ペルソナのプロセスだけを起動しておけば、それだけが選ばれる。 +複数まとめて起動したいときは `scripts/run-bots.sh` (tmux で 1 ウィンドウ = 1 bot)。 -## ペルソナ一覧 (テンプレで既定値を割り当て済み) +## ペルソナ一覧 (canonical source: `wolfbot.npc.personas`) | Key | 表示名 | スタイル | TTS_VOICE_ID | |---|---|---|---| @@ -56,14 +89,12 @@ WOLFBOT_NPC_ENV=envs/npc/.env.sq uv run wolfbot-npc & | `remnan` | ⚪️レムナン | 内向的で慎重。観察は細かい | 10 | | `yuriko` | 👑ユリコ | 冷静で威圧感がある。少ない語数で核心を突く | 3 | -`TTS_VOICE_ID` は VOICEVOX のスピーカー ID。重複しないように既定で -割り当ててあるが、好みの声に変えたい場合だけ編集する -( で確認)。 +`TTS_VOICE_ID` は `wolfbot.npc.personas` の `Persona.tts_voice_id` が一次情報。 +別の声に変えたい場合は (a) Persona 定義を編集してスクリプトを再実行、または +(b) 生成された `.env.` を後から手動で書き換える。 ## 事前にやっておくこと - 各ペルソナぶんの Discord bot アプリを Developer Portal で作成し、 - guild に**手動で招待**しておく (Discord は bot から bot を guild に - 追加できないので、これは 1 度きりのセットアップ作業)。 -- Master の `.env.master` を先に用意し、`MASTER_NPC_PSK` を決めておく。 -- VOICEVOX エンジンを立ち上げておく (既定 `http://localhost:50021`)。 + guild に手動で招待しておく (1 度きりのセットアップ作業)。 +- VOICEVOX エンジンを起動 (既定 `http://localhost:50021`)。 diff --git a/scripts/generate_npc_envs.py b/scripts/generate_npc_envs.py new file mode 100755 index 0000000..e1f80e5 --- /dev/null +++ b/scripts/generate_npc_envs.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Generate per-persona NPC env files from a single template + tokens list. + +Source files (read, not written): + tokens.txt - ": " per line + (typo-aliases like "setu" → "setsu" allowed) + .env.master - DISCORD_GUILD_ID / MAIN_VOICE_CHANNEL_ID / + MASTER_NPC_PSK / GAMEPLAY_LLM_API_KEY copied + forward + envs/npc/.env.npc.example - {{...}} placeholders substituted per persona + wolfbot.npc.personas - NPC_PERSONA_KEY + tts_voice_id authoritative + source + +Outputs: + envs/npc/.env. - one file per persona present in tokens.txt + +Usage: + python3 scripts/generate_npc_envs.py + python3 scripts/generate_npc_envs.py --tokens tokens.txt --master .env.master + python3 scripts/generate_npc_envs.py --no-overwrite + python3 scripts/generate_npc_envs.py --dry-run + +Failure modes (non-zero exit): + - tokens.txt or .env.master missing + - .env.master missing a required shared key + - tokens.txt names a persona that isn't in NPC_PERSONAS_BY_KEY (after alias) + - persona has no tts_voice_id (template can't substitute it) +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_TOKENS = REPO_ROOT / "tokens.txt" +DEFAULT_MASTER_ENV = REPO_ROOT / ".env.master" +DEFAULT_TEMPLATE = REPO_ROOT / "envs" / "npc" / ".env.npc.example" +DEFAULT_OUT_DIR = REPO_ROOT / "envs" / "npc" + +# Common typos in tokens.txt → canonical persona key. Add aliases here as +# they show up; the alias map is intentionally explicit (not fuzzy match) +# so a wrong key fails loudly rather than silently mapping to neighbours. +PERSONA_ALIASES: dict[str, str] = { + "setu": "setsu", +} + +# Master tokens are persona-shaped lines but don't generate a per-persona +# env file. The Master's DISCORD_TOKEN already lives in .env.master. +NON_NPC_KEYS: frozenset[str] = frozenset({"master"}) + +REQUIRED_MASTER_KEYS: tuple[str, ...] = ( + "DISCORD_GUILD_ID", + "MAIN_VOICE_CHANNEL_ID", + "MASTER_NPC_PSK", + "GAMEPLAY_LLM_API_KEY", +) + + +@dataclass(frozen=True) +class TokenEntry: + raw_key: str # exactly what was in tokens.txt before alias resolution + persona_key: str # canonical key after alias resolution + token: str + + +def parse_tokens(path: Path) -> list[TokenEntry]: + """Parse `: ` lines, ignore comments / blanks.""" + if not path.exists(): + sys.exit(f"ERROR: tokens file not found: {path}") + entries: list[TokenEntry] = [] + seen_raw_keys: set[str] = set() + for lineno, raw in enumerate(path.read_text().splitlines(), start=1): + line = raw.strip() + if not line or line.startswith("#"): + continue + if ":" not in line: + sys.exit(f"ERROR: {path}:{lineno}: expected ': ', got {line!r}") + key_part, token_part = line.split(":", 1) + key = key_part.strip().lower() + token = token_part.strip() + if not key or not token: + sys.exit(f"ERROR: {path}:{lineno}: empty key or token") + if key in seen_raw_keys: + sys.exit(f"ERROR: {path}:{lineno}: duplicate entry for key {key!r}") + seen_raw_keys.add(key) + canonical = PERSONA_ALIASES.get(key, key) + entries.append(TokenEntry(raw_key=key, persona_key=canonical, token=token)) + return entries + + +def parse_env_file(path: Path) -> dict[str, str]: + """Minimal `.env` reader: KEY=VALUE per line, ignore comments / blanks. + + Strips matching surrounding single or double quotes. Does not handle + multi-line values or shell expansion (we don't need those for our envs). + """ + if not path.exists(): + sys.exit(f"ERROR: env file not found: {path}") + out: dict[str, str] = {} + for lineno, raw in enumerate(path.read_text().splitlines(), start=1): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + sys.exit(f"ERROR: {path}:{lineno}: expected 'KEY=VALUE', got {line!r}") + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + out[key] = value + return out + + +def load_npc_personas() -> dict[str, object]: + """Late import so this script remains stdlib-only at import time.""" + sys.path.insert(0, str(REPO_ROOT / "src")) + try: + from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY + except ImportError as exc: + sys.exit(f"ERROR: failed to import NPC_PERSONAS_BY_KEY: {exc}") + return NPC_PERSONAS_BY_KEY # type: ignore[no-any-return] + + +_PLACEHOLDER_RE = re.compile(r"\{\{([A-Z_][A-Z0-9_]*)\}\}") + + +def render_template(template: str, values: dict[str, str]) -> str: + """Substitute `{{KEY}}` placeholders. Fail fast on missing keys.""" + missing: list[str] = [] + + def _repl(match: re.Match[str]) -> str: + key = match.group(1) + if key not in values: + missing.append(key) + return match.group(0) + return values[key] + + out = _PLACEHOLDER_RE.sub(_repl, template) + if missing: + unique = sorted(set(missing)) + sys.exit(f"ERROR: template referenced unknown placeholder(s): {unique}") + return out + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Generate envs/npc/.env. files from a single template.", + ) + def _rel(p: Path) -> str: + try: + return str(p.relative_to(REPO_ROOT)) + except ValueError: + return str(p) + + parser.add_argument("--tokens", type=Path, default=DEFAULT_TOKENS, + help=f"path to tokens file (default: {_rel(DEFAULT_TOKENS)})") + parser.add_argument("--master", type=Path, default=DEFAULT_MASTER_ENV, + help=f"path to .env.master (default: {_rel(DEFAULT_MASTER_ENV)})") + parser.add_argument("--template", type=Path, default=DEFAULT_TEMPLATE, + help=f"path to template (default: {_rel(DEFAULT_TEMPLATE)})") + parser.add_argument("--out-dir", type=Path, default=DEFAULT_OUT_DIR, + help=f"output dir (default: {_rel(DEFAULT_OUT_DIR)})") + parser.add_argument("--no-overwrite", action="store_true", + help="refuse to overwrite existing per-persona env files") + parser.add_argument("--dry-run", action="store_true", + help="print what would be written but don't touch the filesystem") + args = parser.parse_args() + + tokens = parse_tokens(args.tokens) + master_env = parse_env_file(args.master) + template = args.template.read_text() if args.template.exists() else None + if template is None: + sys.exit(f"ERROR: template not found: {args.template}") + + missing_master_keys = [k for k in REQUIRED_MASTER_KEYS if not master_env.get(k)] + if missing_master_keys: + sys.exit( + f"ERROR: {args.master} is missing or has empty value for: " + f"{', '.join(missing_master_keys)}" + ) + + npc_personas = load_npc_personas() + args.out_dir.mkdir(parents=True, exist_ok=True) + + written: list[str] = [] + skipped: list[str] = [] + aliased: list[tuple[str, str]] = [] + skipped_master: list[str] = [] + + for entry in tokens: + if entry.raw_key in NON_NPC_KEYS: + skipped_master.append(entry.raw_key) + continue + if entry.raw_key != entry.persona_key: + aliased.append((entry.raw_key, entry.persona_key)) + persona = npc_personas.get(entry.persona_key) + if persona is None: + valid = ", ".join(sorted(npc_personas.keys())) + sys.exit( + f"ERROR: tokens.txt references persona {entry.raw_key!r} " + f"(canonical {entry.persona_key!r}) which is not in NPC_PERSONAS_BY_KEY.\n" + f" Valid keys: {valid}" + ) + tts_voice_id = getattr(persona, "tts_voice_id", None) + if tts_voice_id is None: + sys.exit( + f"ERROR: persona {entry.persona_key!r} has no tts_voice_id set; " + f"add one to wolfbot.npc.personas.NPC_PERSONAS." + ) + + out_path = args.out_dir / f".env.{entry.persona_key}" + if out_path.exists() and args.no_overwrite: + skipped.append(entry.persona_key) + continue + + substitutions = { + "NPC_ID": f"npc_{entry.persona_key}", + "NPC_DISCORD_TOKEN": entry.token, + "NPC_PERSONA_KEY": entry.persona_key, + "DISCORD_GUILD_ID": master_env["DISCORD_GUILD_ID"], + "MAIN_VOICE_CHANNEL_ID": master_env["MAIN_VOICE_CHANNEL_ID"], + "MASTER_NPC_PSK": master_env["MASTER_NPC_PSK"], + "NPC_LLM_API_KEY": master_env["GAMEPLAY_LLM_API_KEY"], + "TTS_VOICE_ID": str(tts_voice_id), + } + rendered = render_template(template, substitutions) + + if args.dry_run: + print(f"[dry-run] would write {_rel(out_path)}") + else: + out_path.write_text(rendered) + written.append(entry.persona_key) + + # Summary + print() + if aliased: + for raw, canonical in aliased: + print(f"note: tokens.txt key {raw!r} aliased to canonical {canonical!r}") + if skipped_master: + print(f"note: skipped non-NPC entries from tokens.txt: {', '.join(skipped_master)} " + f"(Master token belongs in .env.master, not envs/npc/)") + if args.dry_run: + print(f"\ndry-run complete. {len(tokens) - len(skipped_master)} files would be written.") + else: + if skipped: + print(f"skipped (already exist, --no-overwrite): {', '.join(skipped)}") + print(f"\n✅ wrote {len(written)} per-persona env file(s) to {_rel(args.out_dir)}/") + for persona in written: + print(f" - .env.{persona}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From f5a7b56554a355533dbbad55b256445a06e4c191 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 18:38:08 +0900 Subject: [PATCH 008/133] fix(scripts/run-bots.sh): bypass uv shim, wait for Master WS before NPCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- scripts/run-bots.sh | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/scripts/run-bots.sh b/scripts/run-bots.sh index 8ae66f0..0d7172e 100755 --- a/scripts/run-bots.sh +++ b/scripts/run-bots.sh @@ -35,8 +35,9 @@ if ! command -v tmux >/dev/null 2>&1; then exit 1 fi -if ! command -v uv >/dev/null 2>&1; then - echo "ERROR: uv is not installed (https://docs.astral.sh/uv/)." +if [[ ! -x "${REPO_ROOT}/.venv/bin/wolfbot" || ! -x "${REPO_ROOT}/.venv/bin/wolfbot-npc" ]]; then + echo "ERROR: wolfbot entry points not found in ${REPO_ROOT}/.venv/bin/." + echo " Run 'uv sync' first to create the project venv." exit 1 fi @@ -46,6 +47,14 @@ if [[ ! -f "${REPO_ROOT}/.env.master" ]]; then exit 1 fi +# We invoke .venv/bin/wolfbot* directly rather than `uv run` so that the +# project's pinned Python 3.11 is used regardless of the user's shell +# environment. UV_PYTHON / VIRTUAL_ENV pointing elsewhere (3.12 / 3.14 +# system pythons) is common on multi-project machines and would cause +# `uv run` to error out with "incompatible interpreter". +WOLFBOT_BIN="${REPO_ROOT}/.venv/bin/wolfbot" +WOLFBOT_NPC_BIN="${REPO_ROOT}/.venv/bin/wolfbot-npc" + # ─── work out which NPC personas to launch ──────────────────────────────── declare -a PERSONAS=() if [[ $# -gt 0 ]]; then @@ -119,13 +128,36 @@ launch_in_window() { echo "Starting tmux session '${SESSION}' with 1 master + ${#PERSONAS[@]} NPC bot(s)..." tmux new-session -d -s "${SESSION}" -c "${REPO_ROOT}" -launch_in_window "master" "${LOG_DIR}/master.log" "uv run wolfbot" "no" +launch_in_window "master" "${LOG_DIR}/master.log" "'${WOLFBOT_BIN}'" "no" + +# Wait for Master to announce its WebSocket bind in the log. Polling with +# a TCP probe (e.g. /dev/tcp) would write bytes to the socket and trigger +# spurious EOFError tracebacks on the websockets server side; tailing the +# log is non-intrusive. Discord login + VC join takes ~5-10s, and any NPC +# bot that tries to connect before that gets ConnectionRefusedError +# without auto-retry, so we serialize the dependency here. +echo "Waiting for Master to announce 'master_ws_listening' (up to 60s)..." +WS_READY=0 +for _ in $(seq 1 60); do + if grep -q "master_ws_listening" "${LOG_DIR}/master.log" 2>/dev/null; then + WS_READY=1 + break + fi + sleep 1 +done +if [[ "${WS_READY}" != "1" ]]; then + echo "WARNING: Master did not announce WS readiness within 60s." + echo " Check 'tail -f ${LOG_DIR}/master.log' for errors before launching NPCs." + echo " NPC bots will still be started but may need manual restart after Master." +else + echo "Master WS is ready." +fi for persona in "${PERSONAS[@]}"; do launch_in_window \ "${persona}" \ "${LOG_DIR}/${persona}.log" \ - "WOLFBOT_NPC_ENV=envs/npc/.env.${persona} uv run wolfbot-npc" + "WOLFBOT_NPC_ENV=envs/npc/.env.${persona} '${WOLFBOT_NPC_BIN}'" done # Land on the master window when the user attaches. From 5429d6a30a27e542a88243b89678291758843d1d Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 22:09:11 +0900 Subject: [PATCH 009/133] fix(speak_arbiter): prefer silent seats in dispatch picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/master/speak_arbiter.py | 17 ++++- tests/test_reactive_voice_master.py | 101 ++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index 3db4fe2..9e19c34 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -502,14 +502,25 @@ async def try_dispatch_next(self, game_id: str) -> None: if state is None: return - # Pick the first online NPC whose assigned seat is alive and silent. + # Pick the next NPC. Priority: seats that have not yet spoken in this + # phase (PublicDiscussionState.silent_seats) before seats that have. + # Within each bucket, lowest assigned_seat wins. Without this two-key + # sort the picker would always return the lowest-seat NPC, monopolizing + # the discussion in pure-NPC games where no human speech triggers + # rotation. Once every alive NPC has spoken once the silent set is + # empty and the bucket becomes a no-op — order falls back to seat-no. online = self.registry.all_online() - for entry in sorted(online, key=lambda e: e.assigned_seat or 99): + + def _pick_key(e: object) -> tuple[int, int]: + seat = getattr(e, "assigned_seat", None) or 99 + in_silent = 0 if seat in state.silent_seats else 1 + return (in_silent, seat) + + for entry in sorted(online, key=_pick_key): if entry.assigned_seat is None or entry.game_id != game_id: continue if entry.assigned_seat not in state.alive_seat_nos: continue - # Prefer silent seats, but fall back to any alive NPC. await self.dispatch_request( state=state, candidate_npc_id=entry.npc_id, diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index 35ed6e2..e339195 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -445,6 +445,107 @@ async def test_rebuild_public_state_from_master_restart(repo: SqliteRepo) -> Non assert state.alive_seat_nos == frozenset({1, 2}) +async def test_try_dispatch_next_prefers_silent_seat_over_lowest( + repo: SqliteRepo, +) -> None: + """In pure-NPC games the only rotation signal is `silent_seats`. Without + silent-first ordering, `try_dispatch_next` would always pick the lowest + `assigned_seat` and the NPC at seat 2 would monopolize. The picker must + prefer NPCs whose seats are still in `silent_seats`. + """ + g = Game( + id="rv-pick", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + Seat(seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu"), + Seat(seat_no=3, display_name="ジーナ", discord_user_id=None, is_llm=True, persona_key="gina"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + await repo.set_player_role(g.id, 3, Role.VILLAGER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + # Seed phase baseline so PublicDiscussionState has alive_seat_nos {1,2,3}. + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + # Seat 2 has already spoken — silent_seats is now {1, 3}. Seat 1 is human + # (no assigned_seat in registry), so the only silent NPC seat is 3. + from wolfbot.services.discussion_service import make_npc_generated_event + + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="seat 2 already spoke", + created_at_ms=2, + ) + ) + + registry = InMemoryNpcRegistry() + buf2: list[str] = [] + buf3: list[str] = [] + registry.register( + npc_id="npc_setsu", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(buf2), + now_ms=1000, + persona_key="setsu", + ) + registry.register( + npc_id="npc_gina", + discord_bot_user_id="bot3", + supported_voices=(), + version="1", + send=_captured_send(buf3), + now_ms=1000, + persona_key="gina", + ) + registry.assign("npc_setsu", seat=2, game_id=g.id, phase_id=phase_id) + registry.assign("npc_gina", seat=3, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + await arb.try_dispatch_next(g.id) + + # Without the silent-first fix, the lowest-seat NPC (seat 2) would have + # received the SpeakRequest. With the fix, only seat 3 (the silent NPC) + # gets it. + assert buf3, "silent NPC at seat 3 must be picked" + assert not buf2, "non-silent NPC at seat 2 must not be picked" + + def test_logic_packet_builder_includes_co_claims_in_summary() -> None: state = PublicDiscussionState( game_id="g", From 0534f2bd7652ba638c117ee58a94727635276742 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 22:09:42 +0900 Subject: [PATCH 010/133] feat(llm): add mock provider for offline integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.master.example | 6 +- envs/npc/.env.npc.example | 4 +- src/wolfbot/llm/decider_config.py | 8 +- src/wolfbot/npc/generator_factory.py | 7 ++ src/wolfbot/npc/mock_generator.py | 152 +++++++++++++++++++++++++++ src/wolfbot/services/llm_service.py | 90 ++++++++++++++++ tests/test_config.py | 25 +++++ tests/test_llm_mock_decider.py | 76 ++++++++++++++ tests/test_npc_config.py | 24 +++++ tests/test_npc_mock_generator.py | 88 ++++++++++++++++ 10 files changed, 477 insertions(+), 3 deletions(-) create mode 100644 src/wolfbot/npc/mock_generator.py create mode 100644 tests/test_llm_mock_decider.py create mode 100644 tests/test_npc_mock_generator.py diff --git a/.env.master.example b/.env.master.example index 96afeb3..1ba3086 100644 --- a/.env.master.example +++ b/.env.master.example @@ -35,11 +35,15 @@ MASTER_NPC_PSK= # (reactive_voice モードでは議論文は NPC bot 側に移譲され、Master は # この LLM で投票と夜行動だけを生成する) # -# Backend は 3 種類から選べる: +# Backend は 4 種類から選べる: # xai ─ xAI Grok など OpenAI Chat Completions 互換 endpoint # (OpenAI / Groq / Together / vLLM / Ollama も同じ枠で可) # deepseek ─ DeepSeek (json_object モード + thinking + reasoning_effort) # gemini ─ Vertex AI Gemini (ADC/IAM 認証、API key 認証は非対応) +# mock ─ オフライン統合テスト用。API コールせず決定論的な +# LLMAction を返す (投票/夜行動は target_name=None で +# LLMAdapter 側の random fallback に委譲、議論発言は +# 定型句のラウンドロビン)。API キー不要。 # NPC bot 側 NPC_LLM_PROVIDER と独立に選べる (片方を Vertex Gemini、 # 片方を xAI Grok にする、といった片寄せも可能)。 GAMEPLAY_LLM_PROVIDER=xai diff --git a/envs/npc/.env.npc.example b/envs/npc/.env.npc.example index 585ecb0..853ff8e 100644 --- a/envs/npc/.env.npc.example +++ b/envs/npc/.env.npc.example @@ -28,10 +28,12 @@ MASTER_NPC_PSK={{MASTER_NPC_PSK}} MASTER_WS_URL=ws://127.0.0.1:8800 # ── NPC LLM (this NPC's reactive-utterance backend) ──────────────────── -# Same provider switch as Master's GAMEPLAY_LLM_*; pick any of the three: +# Same provider switch as Master's GAMEPLAY_LLM_*; pick any of the four: # xai ─ xAI Grok / OpenAI / Groq / Together / vLLM / Ollama # deepseek ─ DeepSeek # gemini ─ Vertex AI Gemini (ADC/IAM, no API key) +# mock ─ オフライン統合テスト。ペルソナ別の定型台詞を round-robin +# で返す。API キー不要、ネットワーク呼び出しなし。 # May share a credential with Master's GAMEPLAY_LLM_* or pick a different # provider/model entirely (e.g. Master on Vertex Gemini, NPC on xAI Grok). NPC_LLM_PROVIDER=xai diff --git a/src/wolfbot/llm/decider_config.py b/src/wolfbot/llm/decider_config.py index 4e1620b..2dcb4d3 100644 --- a/src/wolfbot/llm/decider_config.py +++ b/src/wolfbot/llm/decider_config.py @@ -32,7 +32,7 @@ from pydantic import SecretStr -LLMProvider = Literal["xai", "deepseek", "gemini"] +LLMProvider = Literal["xai", "deepseek", "gemini", "mock"] @dataclass(frozen=True) @@ -48,6 +48,12 @@ class LLMDeciderConfig: that the field tied to the chosen provider is non-None / non-empty by the time we construct this — the asserts in the factories are documentation aids for mypy. + + The ``"mock"`` provider is a special offline-test mode: no credential + is required, no network call is ever made, and the decider/generator + factories return a deterministic stub. Used by integration test rigs + that exercise the full Master + NPC pipeline without burning real + LLM tokens. """ provider: LLMProvider diff --git a/src/wolfbot/npc/generator_factory.py b/src/wolfbot/npc/generator_factory.py index cfba2af..aaf9a68 100644 --- a/src/wolfbot/npc/generator_factory.py +++ b/src/wolfbot/npc/generator_factory.py @@ -87,6 +87,13 @@ def make_npc_generator(cfg: LLMDeciderConfig, *, persona_key: str) -> NpcGenerat gen_gemini.set_persona(persona_key) return gen_gemini + if cfg.provider == "mock": + from wolfbot.npc.mock_generator import MockNpcGenerator + + gen_mock = MockNpcGenerator() + gen_mock.set_persona(persona_key) + return gen_mock + raise ValueError(f"unknown provider: {cfg.provider!r}") diff --git a/src/wolfbot/npc/mock_generator.py b/src/wolfbot/npc/mock_generator.py new file mode 100644 index 0000000..93b6d4f --- /dev/null +++ b/src/wolfbot/npc/mock_generator.py @@ -0,0 +1,152 @@ +"""Offline mock NPC speech generator used when ``NPC_LLM_PROVIDER=mock``. + +Returns scripted utterances per persona, no network call. + +Each NPC bot worker is bound to exactly one persona at startup. The factory +:func:`wolfbot.npc.generator_factory.make_npc_generator` calls +:meth:`MockNpcGenerator.set_persona` after construction so the mock can +pick the right canned-phrase pool. When the persona key is unknown to +this module, a generic fallback script is used so a new persona doesn't +break offline tests. + +Designed so the full Master + NPC reactive_voice pipeline (WS server, +SpeakArbiter, VOICEVOX TTS, Discord VC playback) can be exercised end- +to-end without any LLM API access — the user hears actual NPC voice in +their VC, just with deterministic content. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from wolfbot.domain.ws_messages import LogicPacket, SpeakRequest +from wolfbot.npc.speech_service import NpcGeneratedSpeech + +_PERSONA_SCRIPTS: dict[str, tuple[str, ...]] = { + "setsu": ( + "おはようございます。今日も慎重に進めましょう。", + "席3さんの発言、少し気になりますね。", + "もう少し情報が出てから判断したいです。", + ), + "gina": ( + "やっほー、皆元気にしてる?", + "うーん、誰が怪しいのかなあ。", + "まだ初日だから慌てなくていいよ。", + ), + "sq": ( + "発言の偏りを整理します。", + "現時点で確定情報は少ないです。", + "票筋を確認していきましょう。", + ), + "raqio": ( + "論理的に考えれば、まだ確定はできない。", + "矛盾点を一つずつ潰そう。", + "発言の前提条件を確認したい。", + ), + "stella": ( + "私は皆を信じたいけど、慎重にね。", + "占い師さんの結果を待ちましょう。", + "今夜は襲撃が怖いですね。", + ), + "shigemichi": ( + "よっしゃ、いっちょやったろか。", + "怪しい奴は俺がぶっ飛ばす。", + "村のためにもっと声出していこうぜ。", + ), + "chipie": ( + "ふふ、面白くなってきたね。", + "私は静かに見ているわ。", + "誰が嘘をついてるかしら。", + ), + "comet": ( + "わくわくしてきた!", + "誰が人狼でも面白いね。", + "情報を集めていこう。", + ), + "jonas": ( + "私の見立てでは、まだ動くべきではない。", + "信用すべきは行動だ。", + "焦らず観察しよう。", + ), + "kukrushka": ( + "(無言で頷く)", + "(首を傾げる)", + "(指で席を指し示す)", + ), + "otome": ( + "あら、緊張するわね。", + "皆さん、落ち着いて議論しましょう。", + "私は皆の味方ですよ。", + ), + "sha_ming": ( + "数字で見れば白要素が多い。", + "確率論的に占い師は本物だろう。", + "情報の出し方が鍵だ。", + ), + "remnan": ( + "わたしは記憶にあるパターンを照合中。", + "過去の試合と類似点が多い。", + "結論を急がないで。", + ), + "yuriko": ( + "皆の意見をまとめましょう。", + "私が信じるのは行動だけです。", + "今夜は誰を守るべきか。", + ), +} + +_FALLBACK_SCRIPT: tuple[str, ...] = ( + "そうですね、もう少し様子を見たいです。", + "皆さんの意見を聞かせてください。", + "今のところ判断は保留です。", +) + + +class MockNpcGenerator: + """Round-robin scripted NpcGenerator for offline tests. + + Implements the implicit ``set_persona`` + :meth:`generate` contract + used by ``make_npc_generator`` so it slots into the existing factory + branch without further wiring. + """ + + def __init__(self, *, scripts: dict[str, Sequence[str]] | None = None) -> None: + self._scripts: dict[str, tuple[str, ...]] = ( + {k: tuple(v) for k, v in scripts.items()} + if scripts is not None + else {k: v for k, v in _PERSONA_SCRIPTS.items()} + ) + self._persona_key: str | None = None + self._idx = 0 + self.call_count = 0 + + def set_persona(self, persona_key: str) -> None: + self._persona_key = persona_key + self._idx = 0 + + def _active_script(self) -> tuple[str, ...]: + if self._persona_key is None: + return _FALLBACK_SCRIPT + return self._scripts.get(self._persona_key, _FALLBACK_SCRIPT) + + async def generate( + self, + *, + logic: LogicPacket, + request: SpeakRequest, + ) -> NpcGeneratedSpeech | None: + self.call_count += 1 + script = self._active_script() + text = script[self._idx % len(script)] + self._idx += 1 + if len(text) > request.max_chars: + text = text[: request.max_chars] + return NpcGeneratedSpeech( + text=text, + intent="speak", + used_logic_ids=(), + estimated_duration_ms=2000, + ) + + +__all__ = ["MockNpcGenerator"] diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index 3d0d792..1df58bd 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -306,6 +306,93 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: return LLMAction.model_validate_json(content) +class MockLLMActionDecider: + """Offline mock decider used when ``GAMEPLAY_LLM_PROVIDER=mock``. + + Returns deterministic LLMActions based on phrases unique to each + ``task_*`` prompt generator in :mod:`wolfbot.llm.prompt_builder`. + Vote and night-action targets are intentionally ``None`` so + :meth:`LLMAdapter._resolve_target` falls back to a uniform-random + legal target — sidestepping the brittle "parse legal candidates out + of user_context" path while still producing valid game progression. + + Speech messages are persona-blind canned phrases drawn round-robin + from a per-instance counter, kept under 80 chars to fit the discussion + text length convention. The Master Gameplay LLM only generates speech + in ``rounds`` mode; in ``reactive_voice`` mode the speech path is + routed to NPC bots and this decider produces only votes / night + actions / wolf-chat coordination text. + """ + + _SPEECHES: tuple[str, ...] = ( + "状況を整理しましょうか。今のところ大きな決め手はないですね。", + "発言の少ない方が気になります。考えを聞きたいです。", + "占い結果が出るまで断定は避けたいですね。", + "票筋から見ると、何人か怪しい位置はあります。", + "今のところ私は様子を見たい立場です。", + ) + + _WOLF_CHAT: tuple[str, ...] = ( + "情報を持ってそうな位置を狙いたい。", + "騎士候補から先に処理する案もある。", + "今夜は無難な相手に揃えよう。", + ) + + def __init__( + self, + *, + speeches: Sequence[str] | None = None, + wolf_chat: Sequence[str] | None = None, + ) -> None: + self._speeches: tuple[str, ...] = tuple(speeches or self._SPEECHES) + self._wolf_chat: tuple[str, ...] = tuple(wolf_chat or self._WOLF_CHAT) + self._speech_idx = 0 + self._wolf_idx = 0 + self.call_count = 0 + + def _next_speech(self) -> str: + text = self._speeches[self._speech_idx % len(self._speeches)] + self._speech_idx += 1 + return text + + def _next_wolf_chat(self) -> str: + text = self._wolf_chat[self._wolf_idx % len(self._wolf_chat)] + self._wolf_idx += 1 + return text + + async def decide(self, system_prompt: str, user_context: str) -> LLMAction: + self.call_count += 1 + # The user_context is built by prompt_builder.task_*; each task has + # an unambiguous unique phrase. + if "投票先として合法な候補は" in user_context: + return LLMAction( + intent="vote", + target_name=None, + reason_summary="mock vote", + confidence=0.5, + ) + if "対象を 1 名選んでください" in user_context: + return LLMAction( + intent="night_action", + target_name=None, + reason_summary="mock night action", + confidence=0.5, + ) + if "人狼チャット" in user_context: + return LLMAction( + intent="speak", + public_message=self._next_wolf_chat(), + reason_summary="mock wolf chat", + confidence=0.5, + ) + return LLMAction( + intent="speak", + public_message=self._next_speech(), + reason_summary="mock speech", + confidence=0.5, + ) + + class FakeLLMActionDecider: """Deterministic stub. Returns ACTIONS[n] round-robin per call.""" @@ -1364,6 +1451,8 @@ def make_llm_decider(cfg: LLMDeciderConfig) -> LLMActionDecider: thinking_level=cfg.thinking_level, timeout=cfg.timeout, ) + if cfg.provider == "mock": + return MockLLMActionDecider() raise ValueError(f"unknown provider: {cfg.provider!r}") @@ -1375,6 +1464,7 @@ def make_llm_decider(cfg: LLMDeciderConfig) -> LLMActionDecider: "LLMAction", "LLMActionDecider", "LLMAdapter", + "MockLLMActionDecider", "XAILLMActionDecider", "make_deepseek_decider", "make_gemini_decider", diff --git a/tests/test_config.py b/tests/test_config.py index 2aa89bb..ab0ba27 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -142,6 +142,31 @@ def test_gemini_empty_vertex_project_rejected() -> None: ) +# --------------------------------------------------------------- mock provider +def test_mock_provider_does_not_require_api_key_or_vertex_project() -> None: + """``GAMEPLAY_LLM_PROVIDER=mock`` is for offline integration tests — + no credentials should be required.""" + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + GAMEPLAY_LLM_PROVIDER="mock", + ) + assert s.GAMEPLAY_LLM_PROVIDER == "mock" + assert s.GAMEPLAY_LLM_API_KEY is None + assert s.GAMEPLAY_LLM_VERTEX_PROJECT is None + + +def test_mock_decider_config_round_trips() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + GAMEPLAY_LLM_PROVIDER="mock", + ) + cfg = s.gameplay_decider_config() + assert cfg.provider == "mock" + assert cfg.api_key is None + + # ---------------------------------------------------------------- common def test_unknown_provider_rejected() -> None: with pytest.raises(ValidationError): diff --git a/tests/test_llm_mock_decider.py b/tests/test_llm_mock_decider.py new file mode 100644 index 0000000..90dd617 --- /dev/null +++ b/tests/test_llm_mock_decider.py @@ -0,0 +1,76 @@ +"""MockLLMActionDecider behavior tests. + +Asserts that the offline-mock gameplay decider returns the right intent +based on the unique phrases each ``task_*`` prompt emits in +:mod:`wolfbot.llm.prompt_builder`. The mock is used when +``GAMEPLAY_LLM_PROVIDER=mock`` so the full Master pipeline can run +end-to-end without burning real LLM tokens. +""" + +from __future__ import annotations + +from wolfbot.domain.enums import Role, SubmissionType +from wolfbot.llm.decider_config import LLMDeciderConfig +from wolfbot.llm.prompt_builder import ( + task_daytime_speech, + task_night_action, + task_vote, + task_wolf_chat, +) +from wolfbot.services.llm_service import ( + LLMAction, + MockLLMActionDecider, + make_llm_decider, +) + +CANDIDATES = ("席1 Alice", "席2 セツ", "席3 ジナ") + + +async def test_mock_decider_returns_vote_intent_with_null_target() -> None: + decider = MockLLMActionDecider() + user_ctx = task_vote(CANDIDATES, runoff=False) + result = await decider.decide(system_prompt="", user_context=user_ctx) + assert isinstance(result, LLMAction) + assert result.intent == "vote" + # null target → LLMAdapter._resolve_target falls back to random + assert result.target_name is None + + +async def test_mock_decider_returns_night_action_with_null_target() -> None: + decider = MockLLMActionDecider() + user_ctx = task_night_action(SubmissionType.WOLF_ATTACK, CANDIDATES) + result = await decider.decide(system_prompt="", user_context=user_ctx) + assert result.intent == "night_action" + assert result.target_name is None + + +async def test_mock_decider_returns_daytime_speech() -> None: + decider = MockLLMActionDecider() + user_ctx = task_daytime_speech(day_number=1, discussion_round=1, role=Role.VILLAGER) + result = await decider.decide(system_prompt="", user_context=user_ctx) + assert result.intent == "speak" + assert result.public_message # non-empty canned phrase + + +async def test_mock_decider_returns_wolf_chat_speech() -> None: + decider = MockLLMActionDecider() + user_ctx = task_wolf_chat(("席7 Bob",), CANDIDATES) + result = await decider.decide(system_prompt="", user_context=user_ctx) + assert result.intent == "speak" + assert result.public_message + + +async def test_mock_decider_speech_round_robins_through_pool() -> None: + decider = MockLLMActionDecider(speeches=("a", "b", "c")) + user_ctx = task_daytime_speech(day_number=1, discussion_round=1, role=Role.VILLAGER) + out = [] + for _ in range(5): + r = await decider.decide(system_prompt="", user_context=user_ctx) + out.append(r.public_message) + assert out == ["a", "b", "c", "a", "b"] + + +def test_make_llm_decider_returns_mock_when_provider_is_mock() -> None: + cfg = LLMDeciderConfig(provider="mock") + decider = make_llm_decider(cfg) + assert isinstance(decider, MockLLMActionDecider) diff --git a/tests/test_npc_config.py b/tests/test_npc_config.py index 018f4ea..2f15bbf 100644 --- a/tests/test_npc_config.py +++ b/tests/test_npc_config.py @@ -81,3 +81,27 @@ def test_npc_decider_config_round_trips_deepseek() -> None: assert cfg.api_key.get_secret_value() == "d" assert cfg.thinking == "enabled" assert cfg.reasoning_effort == "max" + + +def test_mock_provider_does_not_require_api_key_or_vertex_project() -> None: + """``NPC_LLM_PROVIDER=mock`` is for offline integration tests — + no credentials should be required.""" + s = NpcSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + NPC_LLM_PROVIDER="mock", + ) + assert s.NPC_LLM_PROVIDER == "mock" + assert s.NPC_LLM_API_KEY is None + assert s.NPC_LLM_VERTEX_PROJECT is None + + +def test_npc_decider_config_round_trips_mock() -> None: + s = NpcSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + NPC_LLM_PROVIDER="mock", + ) + cfg = s.npc_decider_config() + assert cfg.provider == "mock" + assert cfg.api_key is None diff --git a/tests/test_npc_mock_generator.py b/tests/test_npc_mock_generator.py new file mode 100644 index 0000000..c03078f --- /dev/null +++ b/tests/test_npc_mock_generator.py @@ -0,0 +1,88 @@ +"""MockNpcGenerator behavior tests. + +Used when ``NPC_LLM_PROVIDER=mock``. Asserts that the generator returns +persona-appropriate canned phrases via the ``set_persona`` + +``generate`` Protocol that ``make_npc_generator`` expects. +""" + +from __future__ import annotations + +from wolfbot.domain.ws_messages import LogicPacket, SpeakRequest +from wolfbot.llm.decider_config import LLMDeciderConfig +from wolfbot.npc.generator_factory import make_npc_generator +from wolfbot.npc.mock_generator import MockNpcGenerator + + +def _logic_packet() -> LogicPacket: + return LogicPacket( + ts=1, + trace_id="t", + packet_id="lp1", + phase_id="g::day1::DAY_DISCUSSION::1", + recipient_npc_id="npc_setsu", + public_state_summary="phase_id=foo day=1 co_claims=[(none)] silent_seats=[]", + logic_candidates=(), + expires_at_ms=2, + ) + + +def _speak_request() -> SpeakRequest: + return SpeakRequest( + ts=1, + trace_id="t", + request_id="sr1", + phase_id="g::day1::DAY_DISCUSSION::1", + npc_id="npc_setsu", + seat_no=2, + logic_packet_id="lp1", + suggested_intent="speak", + max_chars=80, + max_duration_ms=8000, + priority=0, + expires_at_ms=2, + ) + + +async def test_mock_generator_returns_setsu_specific_phrase_after_set_persona() -> None: + gen = MockNpcGenerator() + gen.set_persona("setsu") + speech = await gen.generate(logic=_logic_packet(), request=_speak_request()) + assert speech is not None + assert speech.intent == "speak" + # The first phrase in the setsu pool starts with the polite greeting. + assert "おはよう" in speech.text + + +async def test_mock_generator_unknown_persona_falls_back_to_generic_pool() -> None: + gen = MockNpcGenerator() + gen.set_persona("nonexistent_persona") + speech = await gen.generate(logic=_logic_packet(), request=_speak_request()) + assert speech is not None + assert speech.text # non-empty + + +async def test_mock_generator_round_robins_within_persona_pool() -> None: + gen = MockNpcGenerator(scripts={"setsu": ("a", "b", "c")}) + gen.set_persona("setsu") + out = [] + for _ in range(5): + speech = await gen.generate(logic=_logic_packet(), request=_speak_request()) + assert speech is not None + out.append(speech.text) + assert out == ["a", "b", "c", "a", "b"] + + +async def test_mock_generator_truncates_text_exceeding_max_chars() -> None: + long = "あ" * 200 + gen = MockNpcGenerator(scripts={"setsu": (long,)}) + gen.set_persona("setsu") + req = _speak_request() + speech = await gen.generate(logic=_logic_packet(), request=req) + assert speech is not None + assert len(speech.text) == req.max_chars + + +def test_make_npc_generator_returns_mock_when_provider_is_mock() -> None: + cfg = LLMDeciderConfig(provider="mock") + gen = make_npc_generator(cfg, persona_key="setsu") + assert isinstance(gen, MockNpcGenerator) From 2a5e8bfe9fcca65b0de131f14b5c3c367a617756 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 22:10:05 +0900 Subject: [PATCH 011/133] feat(durations): runtime-mutable PhaseDurations singleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .env.master.example | 19 +++ src/wolfbot/config.py | 21 +++ src/wolfbot/domain/durations.py | 224 ++++++++++++++++++++++++++++ src/wolfbot/domain/rules.py | 19 ++- src/wolfbot/domain/state_machine.py | 81 ++++++---- src/wolfbot/main.py | 1 + tests/test_phase_durations.py | 191 ++++++++++++++++++++++++ 7 files changed, 522 insertions(+), 34 deletions(-) create mode 100644 src/wolfbot/domain/durations.py create mode 100644 tests/test_phase_durations.py diff --git a/.env.master.example b/.env.master.example index 1ba3086..49bb2b8 100644 --- a/.env.master.example +++ b/.env.master.example @@ -66,6 +66,25 @@ GAMEPLAY_LLM_VERTEX_PROJECT= GAMEPLAY_LLM_VERTEX_LOCATION=global GAMEPLAY_LLM_THINKING_LEVEL=high +# ── フェーズ duration (検証・モック用の早送り) ──────── +# 全フェーズの長さに掛ける倍率。0.1 = 10倍速、1.0 = デフォルト。 +# 1秒未満になる場合は 1秒にクランプ。空のままならデフォルト維持。 +# 個別フェーズのみ短くしたいときは下の WOLFBOT_*_DURATION で絶対秒指定 +# (倍率と個別指定は両方適用、個別指定が後勝ち)。 +# 将来的には Master bot の slash command で動的変更できるようになるため、 +# このシングルトンが書き換え地点 (wolfbot.domain.durations.set_phase_durations)。 +WOLFBOT_PHASE_DURATION_FACTOR= + +# 個別フェーズ秒数の絶対指定 (空ならデフォルト or 倍率適用後の値を維持) +WOLFBOT_VOTE_DURATION= +WOLFBOT_RUNOFF_DURATION= +WOLFBOT_NIGHT_DURATION= +WOLFBOT_DAY_DISCUSSION_GRACE= +WOLFBOT_RUNOFF_SPEECH_GRACE= +WOLFBOT_DISCUSSION_DAY1= +WOLFBOT_DISCUSSION_DAY2= +WOLFBOT_DISCUSSION_DAY3PLUS= + # ── Voice LLM (Master が人間の音声を"聞く"側) ────────── # Master が VC に参加して人間の音声を直接受信したあと、 # 書き起こし + 要約 + CO 検出 + 投票先抽出 を 1 API コールで実行する diff --git a/src/wolfbot/config.py b/src/wolfbot/config.py index 1ecfbe6..f6027d2 100644 --- a/src/wolfbot/config.py +++ b/src/wolfbot/config.py @@ -100,6 +100,27 @@ def _require_gameplay_provider_key(self) -> MasterSettings: ) return self + def apply_phase_durations(self) -> None: + """Initialize the global :class:`PhaseDurations` singleton from env. + + Called once during boot from :mod:`wolfbot.main` after the + Settings instance is constructed. Subsequent runtime mutations + (a future ``/wolf settings duration_factor ...`` slash command, + for example) should call :func:`set_phase_durations` directly + rather than re-running this method, so the env-derived values + don't accidentally clobber a UI-driven override. + + See :class:`wolfbot.domain.durations.PhaseDurations.from_env` + for the env-var contract (``WOLFBOT_PHASE_DURATION_FACTOR`` plus + per-phase ``WOLFBOT_*_DURATION`` overrides). + """ + from wolfbot.domain.durations import ( + PhaseDurations, + set_phase_durations, + ) + + set_phase_durations(PhaseDurations.from_env()) + def gameplay_decider_config(self, *, timeout: float = 30.0) -> LLMDeciderConfig: """Project this Settings instance onto the provider-agnostic ``LLMDeciderConfig`` consumed by the decider factory. diff --git a/src/wolfbot/domain/durations.py b/src/wolfbot/domain/durations.py new file mode 100644 index 0000000..207862e --- /dev/null +++ b/src/wolfbot/domain/durations.py @@ -0,0 +1,224 @@ +"""Phase-duration configuration — runtime-mutable singleton. + +The wolf game has 5 fixed-length phases plus 3 day-numbered discussion +durations. Historically these were module-level ``int`` constants in +:mod:`wolfbot.domain.state_machine` and a function in +:mod:`wolfbot.domain.rules`. Both still exist as backwards-compatible +re-exports (state_machine.py uses :pep:`562` ``__getattr__`` to lazy- +read the singleton; ``rules.day_discussion_duration`` delegates here). + +This module is the single source of truth at runtime: + + - :class:`PhaseDurations` is a frozen dataclass — immutable values. + - The process holds one *current* :class:`PhaseDurations` instance + in this module's private ``_current`` slot. + - :func:`current_phase_durations` returns the current instance. + - :func:`set_phase_durations` swaps the slot atomically. + - :func:`reset_phase_durations_to_defaults` is a test convenience. + +The singleton design is intentional: phase durations are a process-wide +operational knob, not per-game state. One Master process serves one +guild's worth of games, and an admin who runs +``/wolf settings duration_factor 0.5`` would expect *all* future phase +deadlines to use the new value — including ones in already-running +games. The current phase's ``deadline_epoch`` (already written to the +DB) is untouched; the new value applies on the next phase transition. +For per-game overrides (a richer model that would let two games on the +same Master use different durations) a future change can add a +``phase_durations`` JSON column to the ``games`` table and have +``plan_*`` functions prefer that over the singleton — the dataclass is +designed so a row's value can be carried alongside. + +Usage: + + # at boot (main.py) + settings.apply_phase_durations() + + # in plan_*() (state_machine.py) + new_deadline_epoch=now_epoch + current_phase_durations().vote + + # future UI command (slash command handler) + set_phase_durations(replace(current_phase_durations(), vote=15)) +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, replace + + +@dataclass(frozen=True) +class PhaseDurations: + """Seconds for each phase deadline. Frozen — swap, don't mutate. + + Defaults match the historical hardcoded values from + :mod:`wolfbot.domain.state_machine` and + :func:`wolfbot.domain.rules.day_discussion_duration`. Tests assert + against these defaults, so a new field's default must keep that + equivalence to avoid silent breakage. + """ + + vote: int = 60 + runoff: int = 60 + night: int = 90 + day_discussion_grace: int = 30 + runoff_speech_grace: int = 30 + discussion_day1: int = 300 + discussion_day2: int = 240 + discussion_day3plus: int = 180 + + def discussion_for_day(self, day_number: int) -> int: + """Length of the discussion phase for ``day_number``. + + Mirrors the historical + :func:`wolfbot.domain.rules.day_discussion_duration` logic. + """ + if day_number <= 0: + raise ValueError("day_number must be >= 1 for discussion") + if day_number == 1: + return self.discussion_day1 + if day_number == 2: + return self.discussion_day2 + return self.discussion_day3plus + + def with_factor(self, factor: float) -> PhaseDurations: + """Return a new :class:`PhaseDurations` with every value scaled + by ``factor``, clamped to a minimum of 1 second per phase. + + The clamp matters in mock / fast-test mode: a factor of 0.01 + applied to a 60-second VOTE_DURATION rounds to 0, which would + make ``deadline_epoch == now`` and the engine's deadline-watcher + would advance immediately, sometimes before the LLM submission + loop can even fire. 1 second is short enough for fast iteration + but still gives the loop a tick. + """ + if factor <= 0: + raise ValueError("factor must be > 0") + + def _scale(v: int) -> int: + return max(1, round(v * factor)) + + return PhaseDurations( + vote=_scale(self.vote), + runoff=_scale(self.runoff), + night=_scale(self.night), + day_discussion_grace=_scale(self.day_discussion_grace), + runoff_speech_grace=_scale(self.runoff_speech_grace), + discussion_day1=_scale(self.discussion_day1), + discussion_day2=_scale(self.discussion_day2), + discussion_day3plus=_scale(self.discussion_day3plus), + ) + + @classmethod + def from_env( + cls, + env: dict[str, str] | None = None, + ) -> PhaseDurations: + """Build a :class:`PhaseDurations` from environment variables. + + Resolution order, applied in this sequence so individual + overrides win against the global factor: + + 1. Start with the dataclass defaults. + 2. If ``WOLFBOT_PHASE_DURATION_FACTOR`` is set, scale all + values by it (a single knob for "make everything N times + faster" — the typical mock / fast-test use case). + 3. Per-phase overrides apply absolute values, replacing + whatever the factor produced: + - ``WOLFBOT_VOTE_DURATION`` + - ``WOLFBOT_RUNOFF_DURATION`` + - ``WOLFBOT_NIGHT_DURATION`` + - ``WOLFBOT_DAY_DISCUSSION_GRACE`` + - ``WOLFBOT_RUNOFF_SPEECH_GRACE`` + - ``WOLFBOT_DISCUSSION_DAY1`` + - ``WOLFBOT_DISCUSSION_DAY2`` + - ``WOLFBOT_DISCUSSION_DAY3PLUS`` + + Each override must parse as a positive integer (seconds); a + non-positive value or unparseable string raises ``ValueError`` + at load time so config errors fail fast at boot. + """ + e = env if env is not None else os.environ + d = cls() + factor_raw = e.get("WOLFBOT_PHASE_DURATION_FACTOR") + if factor_raw is not None and factor_raw.strip(): + try: + factor = float(factor_raw) + except ValueError as exc: + raise ValueError( + f"WOLFBOT_PHASE_DURATION_FACTOR must be a number, got {factor_raw!r}" + ) from exc + d = d.with_factor(factor) + + overrides: dict[str, str] = { + "vote": "WOLFBOT_VOTE_DURATION", + "runoff": "WOLFBOT_RUNOFF_DURATION", + "night": "WOLFBOT_NIGHT_DURATION", + "day_discussion_grace": "WOLFBOT_DAY_DISCUSSION_GRACE", + "runoff_speech_grace": "WOLFBOT_RUNOFF_SPEECH_GRACE", + "discussion_day1": "WOLFBOT_DISCUSSION_DAY1", + "discussion_day2": "WOLFBOT_DISCUSSION_DAY2", + "discussion_day3plus": "WOLFBOT_DISCUSSION_DAY3PLUS", + } + applied: dict[str, int] = {} + for field_name, env_name in overrides.items(): + raw = e.get(env_name) + if raw is None or not raw.strip(): + continue + try: + value = int(raw) + except ValueError as exc: + raise ValueError( + f"{env_name} must be an integer, got {raw!r}" + ) from exc + if value <= 0: + raise ValueError(f"{env_name} must be > 0, got {value}") + applied[field_name] = value + if applied: + d = replace(d, **applied) + return d + + +_current: PhaseDurations = PhaseDurations() + + +def current_phase_durations() -> PhaseDurations: + """Return the active :class:`PhaseDurations` instance. + + This is the read API every ``plan_*`` transition function uses. + Reading is just a singleton-slot access — no I/O, deterministic. + """ + return _current + + +def set_phase_durations(durations: PhaseDurations) -> None: + """Swap the active :class:`PhaseDurations` instance. + + Called once at boot from + :meth:`wolfbot.config.MasterSettings.apply_phase_durations`, and + intended to be the exact same hook a future + ``/wolf settings duration_factor ...`` slash command would call to + flip the value at runtime. The change applies on the *next* phase + transition; the current phase's already-written ``deadline_epoch`` + is unchanged. + """ + global _current + _current = durations + + +def reset_phase_durations_to_defaults() -> None: + """Test convenience — restore the singleton to ``PhaseDurations()``. + + Tests that mutate the singleton must restore it afterward, otherwise + later tests in the same process inherit the override. + """ + global _current + _current = PhaseDurations() + + +__all__ = [ + "PhaseDurations", + "current_phase_durations", + "reset_phase_durations_to_defaults", + "set_phase_durations", +] diff --git a/src/wolfbot/domain/rules.py b/src/wolfbot/domain/rules.py index 030edd2..b2619c0 100644 --- a/src/wolfbot/domain/rules.py +++ b/src/wolfbot/domain/rules.py @@ -34,14 +34,17 @@ def assign_roles(seats: Sequence[Seat], rng: Random) -> dict[int, Role]: def day_discussion_duration(day_number: int) -> int: - """Seconds for the discussion phase. Day 1: 300, day 2: 240, day 3+: 180.""" - if day_number <= 0: - raise ValueError("day_number must be >= 1 for discussion") - if day_number == 1: - return 300 - if day_number == 2: - return 240 - return 180 + """Seconds for the discussion phase. Defaults: day 1: 300, day 2: + 240, day 3+: 180. + + Reads the live :class:`PhaseDurations` singleton — runtime-mutable + so a future ``/wolf settings`` slash command can change durations + without restarting Master. See :mod:`wolfbot.domain.durations` for + the full singleton contract. + """ + from wolfbot.domain.durations import current_phase_durations + + return current_phase_durations().discussion_for_day(day_number) def legal_attack_targets(players: Sequence[Player], actor_seat: int) -> list[int]: diff --git a/src/wolfbot/domain/state_machine.py b/src/wolfbot/domain/state_machine.py index 9dc512b..5baa1fd 100644 --- a/src/wolfbot/domain/state_machine.py +++ b/src/wolfbot/domain/state_machine.py @@ -21,6 +21,7 @@ from collections.abc import Mapping, Sequence from random import Random +from wolfbot.domain.durations import current_phase_durations from wolfbot.domain.enums import ( FACTION_JA, ROLE_JA, @@ -55,23 +56,44 @@ resolve_wolf_attack, ) -VOTE_DURATION = 60 -RUNOFF_DURATION = 60 -NIGHT_DURATION = 90 - -# Re-check interval used by `plan_day_discussion_wait` — engine sleeps this long -# between checks when the discussion deadline has passed but at least one alive -# LLM seat hasn't completed both rounds. The LLM completion path also calls -# `wake.wake(game_id)`, so this is a fallback ceiling, not the typical wait time. -DAY_DISCUSSION_GRACE = 30 - # Initial deadline for DAY_RUNOFF_SPEECH. Acts as a safety net so a hung LLM # task doesn't freeze the game; the LLM dispatcher's per-seat `finally` block # always advances `runoff_speech_done` so the engine can move on. +# +# Not part of :class:`PhaseDurations` because it's a hard safety floor rather +# than a tunable phase length — adjust here if real LLM submissions need more +# headroom, but don't expose it to the duration_factor knob. RUNOFF_SPEECH_DEADLINE = 60 -# Re-check interval used by `plan_runoff_speech_wait`, mirroring DAY_DISCUSSION_GRACE. -RUNOFF_SPEECH_GRACE = 30 + +# Backwards-compatible re-exports of the historical ``int`` constants. +# Per :pep:`562`, this module-level ``__getattr__`` is consulted when a +# name is not found in the module's normal namespace, so +# ``state_machine.VOTE_DURATION`` returns the *current* singleton value +# at access time rather than a stale snapshot from import. +# +# Note: ``from wolfbot.domain.state_machine import VOTE_DURATION`` still +# binds the local name to the value resolved at import time, so any +# tests that rely on dynamic re-reading must access the constant via +# ``current_phase_durations()`` directly. The aliases here exist so +# (a) tests that assert against the default value keep working, and +# (b) any third-party caller that reads the module attribute gets the +# current value. +_DURATION_ALIASES: dict[str, str] = { + "VOTE_DURATION": "vote", + "RUNOFF_DURATION": "runoff", + "NIGHT_DURATION": "night", + "DAY_DISCUSSION_GRACE": "day_discussion_grace", + "RUNOFF_SPEECH_GRACE": "runoff_speech_grace", +} + + +def __getattr__(name: str) -> int: + field = _DURATION_ALIASES.get(name) + if field is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value: int = getattr(current_phase_durations(), field) + return value # ---------------------------------------------------------------- helpers @@ -307,7 +329,7 @@ def plan_day_discussion_to_vote(game: Game, now_epoch: int) -> Transition: return Transition( next_phase=Phase.DAY_VOTE, next_day=game.day_number, - new_deadline_epoch=now_epoch + VOTE_DURATION, + new_deadline_epoch=now_epoch + current_phase_durations().vote, public_logs=(pub,), ) @@ -394,7 +416,7 @@ def plan_day_vote_resolve( return Transition( next_phase=Phase.DAY_RUNOFF, next_day=game.day_number, - new_deadline_epoch=now_epoch + RUNOFF_DURATION, + new_deadline_epoch=now_epoch + current_phase_durations().runoff, public_logs=(pub,), clear_force_skip=True, ) @@ -409,7 +431,7 @@ def plan_day_vote_resolve( return Transition( next_phase=Phase.NIGHT, next_day=game.day_number, - new_deadline_epoch=now_epoch + NIGHT_DURATION, + new_deadline_epoch=now_epoch + current_phase_durations().night, public_logs=(pub,), clear_force_skip=True, ) @@ -479,7 +501,7 @@ def plan_day_runoff_resolve( return Transition( next_phase=Phase.NIGHT, next_day=game.day_number, - new_deadline_epoch=now_epoch + NIGHT_DURATION, + new_deadline_epoch=now_epoch + current_phase_durations().night, public_logs=(pub,), clear_force_skip=True, ) @@ -510,7 +532,7 @@ def _apply_execution( return Transition( next_phase=Phase.NIGHT, next_day=game.day_number, - new_deadline_epoch=now_epoch + NIGHT_DURATION, + new_deadline_epoch=now_epoch + current_phase_durations().night, public_logs=(pub,), clear_force_skip=clear_force, ) @@ -554,7 +576,7 @@ def _apply_execution( return Transition( next_phase=Phase.NIGHT, next_day=game.day_number, - new_deadline_epoch=now_epoch + NIGHT_DURATION, + new_deadline_epoch=now_epoch + current_phase_durations().night, player_updates=updates, public_logs=public_logs, newly_dead_seats=(executed_seat,), @@ -848,7 +870,7 @@ def plan_day_discussion_wait(game: Game, now_epoch: int) -> Transition: return Transition( next_phase=Phase.DAY_DISCUSSION, next_day=game.day_number, - new_deadline_epoch=now_epoch + DAY_DISCUSSION_GRACE, + new_deadline_epoch=now_epoch + current_phase_durations().day_discussion_grace, ) @@ -875,7 +897,7 @@ def plan_runoff_speech_to_runoff( return Transition( next_phase=Phase.DAY_RUNOFF, next_day=game.day_number, - new_deadline_epoch=now_epoch + RUNOFF_DURATION, + new_deadline_epoch=now_epoch + current_phase_durations().runoff, public_logs=(pub,), ) @@ -891,17 +913,24 @@ def plan_runoff_speech_wait(game: Game, now_epoch: int) -> Transition: return Transition( next_phase=Phase.DAY_RUNOFF_SPEECH, next_day=game.day_number, - new_deadline_epoch=now_epoch + RUNOFF_SPEECH_GRACE, + new_deadline_epoch=now_epoch + current_phase_durations().runoff_speech_grace, ) +# Names listed below that are NOT bound at module level +# (DAY_DISCUSSION_GRACE / NIGHT_DURATION / RUNOFF_DURATION / +# RUNOFF_SPEECH_GRACE / VOTE_DURATION) are exported dynamically through +# the module-level ``__getattr__`` defined above (PEP 562). They are +# intentionally part of the public API for backwards compatibility with +# code that imports the historical constants. Ruff cannot see the +# dynamic binding, hence the ``noqa: F822`` markers. __all__ = [ - "DAY_DISCUSSION_GRACE", - "NIGHT_DURATION", - "RUNOFF_DURATION", + "DAY_DISCUSSION_GRACE", # noqa: F822 — see PEP 562 __getattr__ above + "NIGHT_DURATION", # noqa: F822 + "RUNOFF_DURATION", # noqa: F822 "RUNOFF_SPEECH_DEADLINE", - "RUNOFF_SPEECH_GRACE", - "VOTE_DURATION", + "RUNOFF_SPEECH_GRACE", # noqa: F822 + "VOTE_DURATION", # noqa: F822 "plan_day_discussion_to_vote", "plan_day_discussion_wait", "plan_day_runoff_resolve", diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 74ad703..dad727e 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -28,6 +28,7 @@ async def _run() -> None: load_dotenv(".env.master") settings = MasterSettings() # type: ignore[call-arg] + settings.apply_phase_durations() logging.basicConfig( level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO), format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", diff --git a/tests/test_phase_durations.py b/tests/test_phase_durations.py new file mode 100644 index 0000000..6b4ce99 --- /dev/null +++ b/tests/test_phase_durations.py @@ -0,0 +1,191 @@ +"""PhaseDurations singleton + env loader tests. + +Covers: + +- Default values match the historical hardcoded constants (so existing + test suite assertions like ``1000 + NIGHT_DURATION == 1000 + 90`` + remain valid). +- ``with_factor`` scales correctly and clamps to a 1-second floor for + fast-test mode. +- ``from_env`` honors both the global factor and per-phase absolute + overrides, with the per-phase override winning when both are set. +- ``set_phase_durations`` flips the singleton process-wide; the + module ``__getattr__`` aliases on ``state_machine`` reflect the new + value (the dynamic-attribute path the future UI will rely on). +- Each test resets the singleton on teardown so leakage between tests + is impossible. +""" + +from __future__ import annotations + +import pytest + +from wolfbot.domain import state_machine +from wolfbot.domain.durations import ( + PhaseDurations, + current_phase_durations, + reset_phase_durations_to_defaults, + set_phase_durations, +) +from wolfbot.domain.rules import day_discussion_duration + + +@pytest.fixture(autouse=True) +def _restore_singleton() -> None: + yield + reset_phase_durations_to_defaults() + + +def test_defaults_match_historical_hardcoded_values() -> None: + d = PhaseDurations() + assert d.vote == 60 + assert d.runoff == 60 + assert d.night == 90 + assert d.day_discussion_grace == 30 + assert d.runoff_speech_grace == 30 + assert d.discussion_day1 == 300 + assert d.discussion_day2 == 240 + assert d.discussion_day3plus == 180 + + +def test_discussion_for_day_matches_historical_function() -> None: + d = PhaseDurations() + assert d.discussion_for_day(1) == 300 + assert d.discussion_for_day(2) == 240 + assert d.discussion_for_day(3) == 180 + assert d.discussion_for_day(10) == 180 + + +def test_discussion_for_day_rejects_non_positive_day() -> None: + d = PhaseDurations() + with pytest.raises(ValueError): + d.discussion_for_day(0) + with pytest.raises(ValueError): + d.discussion_for_day(-1) + + +def test_with_factor_scales_all_fields_proportionally() -> None: + d = PhaseDurations().with_factor(0.5) + assert d.vote == 30 + assert d.night == 45 + assert d.discussion_day1 == 150 + + +def test_with_factor_clamps_to_one_second_floor() -> None: + """Tiny factors mustn't produce a 0-second deadline; the engine's + deadline-watcher would advance immediately, sometimes before the + LLM submission loop has even fired.""" + d = PhaseDurations().with_factor(0.001) + assert d.vote >= 1 + assert d.night >= 1 + assert d.discussion_day1 >= 1 + + +def test_with_factor_rejects_non_positive() -> None: + with pytest.raises(ValueError): + PhaseDurations().with_factor(0) + with pytest.raises(ValueError): + PhaseDurations().with_factor(-1) + + +def test_from_env_with_no_overrides_returns_defaults() -> None: + d = PhaseDurations.from_env(env={}) + assert d == PhaseDurations() + + +def test_from_env_factor_scales_all_phases() -> None: + d = PhaseDurations.from_env(env={"WOLFBOT_PHASE_DURATION_FACTOR": "0.1"}) + # 60 * 0.1 = 6 + assert d.vote == 6 + # 90 * 0.1 = 9 + assert d.night == 9 + + +def test_from_env_per_phase_override_beats_factor() -> None: + d = PhaseDurations.from_env( + env={ + "WOLFBOT_PHASE_DURATION_FACTOR": "0.1", + "WOLFBOT_VOTE_DURATION": "42", + } + ) + # vote was overridden to 42 absolute, but night still gets factor (90*0.1=9). + assert d.vote == 42 + assert d.night == 9 + + +def test_from_env_rejects_unparseable_factor() -> None: + with pytest.raises(ValueError, match="WOLFBOT_PHASE_DURATION_FACTOR"): + PhaseDurations.from_env(env={"WOLFBOT_PHASE_DURATION_FACTOR": "fast"}) + + +def test_from_env_rejects_unparseable_override() -> None: + with pytest.raises(ValueError, match="WOLFBOT_NIGHT_DURATION"): + PhaseDurations.from_env(env={"WOLFBOT_NIGHT_DURATION": "long"}) + + +def test_from_env_rejects_non_positive_override() -> None: + with pytest.raises(ValueError, match="must be > 0"): + PhaseDurations.from_env(env={"WOLFBOT_VOTE_DURATION": "0"}) + with pytest.raises(ValueError, match="must be > 0"): + PhaseDurations.from_env(env={"WOLFBOT_VOTE_DURATION": "-5"}) + + +def test_from_env_ignores_blank_values() -> None: + """Empty strings in .env files (e.g. ``WOLFBOT_VOTE_DURATION=``) must + not be treated as zero — that's the standard "I left this blank, use + the default" pattern in this repo's env files.""" + d = PhaseDurations.from_env(env={"WOLFBOT_VOTE_DURATION": " "}) + assert d.vote == 60 + + +def test_set_phase_durations_swaps_singleton_globally() -> None: + set_phase_durations(PhaseDurations(vote=5)) + assert current_phase_durations().vote == 5 + assert current_phase_durations().night == 90 # unchanged + + +def test_state_machine_lazy_alias_reflects_singleton() -> None: + """``state_machine.NIGHT_DURATION`` must return the current + singleton value, not a snapshot from import time. This is the + dynamic-attribute path a future UI command will rely on for + third-party callers that already imported the module.""" + assert state_machine.NIGHT_DURATION == 90 + set_phase_durations(PhaseDurations(night=7)) + assert state_machine.NIGHT_DURATION == 7 + + +def test_state_machine_unknown_attribute_raises() -> None: + with pytest.raises(AttributeError): + _ = state_machine.NOT_A_REAL_DURATION # type: ignore[attr-defined] + + +def test_day_discussion_duration_function_uses_singleton() -> None: + assert day_discussion_duration(1) == 300 + set_phase_durations(PhaseDurations(discussion_day1=11)) + assert day_discussion_duration(1) == 11 + # day 2 unchanged + assert day_discussion_duration(2) == 240 + + +def test_master_settings_apply_phase_durations_loads_from_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The Settings boot hook must populate the singleton end-to-end.""" + from pydantic import SecretStr + + from wolfbot.config import MasterSettings + + monkeypatch.setenv("WOLFBOT_PHASE_DURATION_FACTOR", "0.5") + monkeypatch.setenv("WOLFBOT_NIGHT_DURATION", "13") + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + DISCORD_TOKEN=SecretStr("token"), + DISCORD_GUILD_ID=1, + MAIN_TEXT_CHANNEL_ID=2, + MAIN_VOICE_CHANNEL_ID=3, + GAMEPLAY_LLM_PROVIDER="mock", + ) + s.apply_phase_durations() + d = current_phase_durations() + assert d.vote == 30 # 60 * 0.5 + assert d.night == 13 # absolute override From eab5a6f9838bf2a2017fdf0990e6d18da83921bd Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 22:10:25 +0900 Subject: [PATCH 012/133] feat(scripts/run-bots.sh): --mock flag and VOICEVOX probe 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. 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. --- scripts/run-bots.sh | 77 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/scripts/run-bots.sh b/scripts/run-bots.sh index 0d7172e..1e81902 100755 --- a/scripts/run-bots.sh +++ b/scripts/run-bots.sh @@ -6,8 +6,21 @@ # Usage: # scripts/run-bots.sh # auto-detect personas from envs/npc/.env.* # scripts/run-bots.sh setsu gina sq # only the listed personas +# scripts/run-bots.sh --mock # offline mock mode (no LLM API calls, +# # no real VOICEVOX needed beyond local +# # daemon, fast phase durations) +# scripts/run-bots.sh --mock setsu # mock mode + persona filter # FORCE=1 scripts/run-bots.sh # kill & recreate a stale session # +# Mock mode injects these env vars into every spawned bot, overriding the +# values in the user's real .env.master / envs/npc/.env. via +# pydantic-settings precedence (process env beats .env file): +# GAMEPLAY_LLM_PROVIDER=mock # Master gameplay LLM → mock decider +# NPC_LLM_PROVIDER=mock # NPC speech LLM → scripted phrases +# WOLFBOT_PHASE_DURATION_FACTOR=0.1 # phases 10x faster (60s vote → 6s) +# So the user keeps their real Discord token / VOICEVOX URL etc. and only +# the LLM and pacing layers swap to deterministic test stand-ins. +# # After the session is up: # tmux attach -t wolfbot # open the session # prefix + n / p # next/prev window @@ -18,6 +31,29 @@ set -euo pipefail +# ─── parse --mock flag (must come before persona args) ──────────────────── +MOCK_MODE=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --mock) + MOCK_MODE=1 + shift + ;; + --) + shift + break + ;; + -*) + echo "ERROR: unknown flag: $1" + echo "Usage: $0 [--mock] [persona ...]" + exit 1 + ;; + *) + break + ;; + esac +done + # ─── locate repo root (parent of this script's dir) ─────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" @@ -47,6 +83,24 @@ if [[ ! -f "${REPO_ROOT}/.env.master" ]]; then exit 1 fi +# ─── VOICEVOX engine reachability probe (warn, don't block) ─────────────── +# In reactive_voice mode every NPC bot needs the VOICEVOX HTTP engine on +# its NPC_LLM_VOICEVOX_URL (default localhost:50021). Probing once before +# launch saves debugging time when the engine wasn't started — NPCs +# would otherwise spin up, register on the WS, and fail silently the +# moment a SpeakRequest arrives. +VOICEVOX_URL="${WOLFBOT_VOICEVOX_URL:-http://localhost:50021}" +if command -v curl >/dev/null 2>&1; then + if ! curl -fsS --max-time 2 "${VOICEVOX_URL}/version" >/dev/null 2>&1; then + echo "WARNING: VOICEVOX engine not reachable at ${VOICEVOX_URL}/version" + echo " NPCs will register with Master but fail on the first SpeakRequest." + echo " Start it before NPCs are dispatched. Examples:" + echo " macOS app: open -a VOICEVOX" + echo " Docker: docker run --rm -p 50021:50021 voicevox/voicevox_engine:cpu-latest" + echo " Override the probe target via WOLFBOT_VOICEVOX_URL=..." + fi +fi + # We invoke .venv/bin/wolfbot* directly rather than `uv run` so that the # project's pinned Python 3.11 is used regardless of the user's shell # environment. UV_PYTHON / VIRTUAL_ENV pointing elsewhere (3.12 / 3.14 @@ -55,6 +109,23 @@ fi WOLFBOT_BIN="${REPO_ROOT}/.venv/bin/wolfbot" WOLFBOT_NPC_BIN="${REPO_ROOT}/.venv/bin/wolfbot-npc" +# ─── mock-mode env injection ────────────────────────────────────────────── +# Built once, then prefixed to every bot's command line so it shows up as +# real OS env at process start. pydantic-settings reads OS env *before* +# the .env file, so these values cleanly override whatever the user has +# in .env.master / envs/npc/.env.. +MOCK_ENV_PREFIX="" +if [[ "${MOCK_MODE}" == "1" ]]; then + # Each variable is double-quoted so a value with shell-special chars + # would still be safe — there's nothing to interpolate today, but + # this keeps the pattern future-proof. + MOCK_ENV_PREFIX="\ +GAMEPLAY_LLM_PROVIDER='mock' \ +NPC_LLM_PROVIDER='mock' \ +WOLFBOT_PHASE_DURATION_FACTOR='0.1' " + echo "Mock mode ON — injecting GAMEPLAY_LLM_PROVIDER=mock, NPC_LLM_PROVIDER=mock, WOLFBOT_PHASE_DURATION_FACTOR=0.1" +fi + # ─── work out which NPC personas to launch ──────────────────────────────── declare -a PERSONAS=() if [[ $# -gt 0 ]]; then @@ -128,7 +199,7 @@ launch_in_window() { echo "Starting tmux session '${SESSION}' with 1 master + ${#PERSONAS[@]} NPC bot(s)..." tmux new-session -d -s "${SESSION}" -c "${REPO_ROOT}" -launch_in_window "master" "${LOG_DIR}/master.log" "'${WOLFBOT_BIN}'" "no" +launch_in_window "master" "${LOG_DIR}/master.log" "${MOCK_ENV_PREFIX}'${WOLFBOT_BIN}'" "no" # Wait for Master to announce its WebSocket bind in the log. Polling with # a TCP probe (e.g. /dev/tcp) would write bytes to the socket and trigger @@ -157,7 +228,7 @@ for persona in "${PERSONAS[@]}"; do launch_in_window \ "${persona}" \ "${LOG_DIR}/${persona}.log" \ - "WOLFBOT_NPC_ENV=envs/npc/.env.${persona} '${WOLFBOT_NPC_BIN}'" + "${MOCK_ENV_PREFIX}WOLFBOT_NPC_ENV=envs/npc/.env.${persona} '${WOLFBOT_NPC_BIN}'" done # Land on the master window when the user attaches. @@ -165,7 +236,7 @@ tmux select-window -t "${SESSION}:master" cat <.log From 0a4c342a5a3cf7ad496366251d79eb1f335c925b Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 22:31:01 +0900 Subject: [PATCH 013/133] feat(speech-event): plumb addressed_name/addressed_seat_no end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/domain/discussion.py | 15 +++++++ src/wolfbot/domain/ws_messages.py | 9 ++++ src/wolfbot/master/stt_service.py | 13 +++++- src/wolfbot/master/voice_ingest_service.py | 1 + src/wolfbot/persistence/schema.py | 5 +++ src/wolfbot/services/discussion_service.py | 50 +++++++++++++++++++--- 6 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/wolfbot/domain/discussion.py b/src/wolfbot/domain/discussion.py index f8946f0..c1d6abb 100644 --- a/src/wolfbot/domain/discussion.py +++ b/src/wolfbot/domain/discussion.py @@ -87,6 +87,18 @@ class SpeechEvent(BaseModel): "legacy events fall back to `_CO_MARKERS` substring scan." ), ) + addressed_seat_no: int | None = Field( + default=None, + ge=1, + le=9, + description=( + "Seat number this utterance is addressed to " + "('〜さん、どう思う' style direct address). " + "Resolved on Master from the analyzer's `addressed_name` against the " + "current seats table. SpeakArbiter prefers this NPC when picking " + "the next speaker." + ), + ) created_at_ms: int def is_baseline(self) -> bool: @@ -138,3 +150,6 @@ class PublicDiscussionState(BaseModel): open_topics: tuple[str, ...] = () silent_seats: frozenset[int] = frozenset() recent_speech_event_ids: tuple[str, ...] = () + last_addressed_seat: int | None = None + last_addressed_speaker_seat: int | None = None + last_addressed_text: str = "" diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index 8a827ac..7c2cec3 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -205,6 +205,15 @@ class SpeechEventPayload(BaseEnvelope): "analyzer (`co_claim` field). Master persists it on SpeechEvent." ), ) + addressed_name: str | None = Field( + default=None, + description=( + "Literal name/handle the speaker called out (e.g. 'セツ', " + "'ジーナさん', '席3'). Master resolves this against the current " + "seats table to populate SpeechEvent.addressed_seat_no. None when " + "the analyzer didn't detect a named address." + ), + ) class SttFailed(BaseEnvelope): diff --git a/src/wolfbot/master/stt_service.py b/src/wolfbot/master/stt_service.py index e43daf9..381c21a 100644 --- a/src/wolfbot/master/stt_service.py +++ b/src/wolfbot/master/stt_service.py @@ -47,6 +47,7 @@ class SttResult: duration_ms: int summary: str | None = None co_declaration: str | None = None + addressed_name: str | None = None class SttProviderError(RuntimeError): @@ -176,7 +177,8 @@ class GeminiAudioAnalyzer: ' "confidence": 0.95,\n' ' "co_claim": null,\n' ' "vote_target_seat": null,\n' - ' "stance": {}\n' + ' "stance": {},\n' + ' "addressed_name": null\n' "}\n" "```\n\n" "フィールド説明:\n" @@ -186,6 +188,8 @@ class GeminiAudioAnalyzer: "- co_claim: 役職CO(自称)があれば \"seer\"/\"medium\"/\"knight\"、なければ null\n" "- vote_target_seat: 処刑対象として名指しした席番号(1〜9)、なければ null\n" "- stance: 言及した席への態度 {\"席番号\": \"positive\"/\"negative\"/\"neutral\"}\n" + "- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 \"セツ\"、\"ジーナさん\"、\"席3\"、\"3番\")、なければ null。" + "「みんな」「全員」など全体への呼びかけは null。さん/くん/ちゃん 等の敬称は付けたままでも構わない。\n" "\n音声が不明瞭な場合は confidence を低くし、transcript は聞き取れた範囲で。" ) @@ -278,6 +282,12 @@ async def transcribe( co_raw if co_raw in ("seer", "medium", "knight") else None ) + addressed_raw = parsed.get("addressed_name") + addressed_name: str | None = None + if isinstance(addressed_raw, str): + stripped = addressed_raw.strip() + addressed_name = stripped or None + # Estimate duration from audio size (assume 16kHz 16-bit mono WAV) data_bytes = max(0, len(audio) - 44) duration_ms = int(data_bytes / (16_000 * 2) * 1000) @@ -288,6 +298,7 @@ async def transcribe( duration_ms=duration_ms, summary=summary_str, co_declaration=co_declaration, + addressed_name=addressed_name, ) except SttProviderError: diff --git a/src/wolfbot/master/voice_ingest_service.py b/src/wolfbot/master/voice_ingest_service.py index de2344f..da11b9c 100644 --- a/src/wolfbot/master/voice_ingest_service.py +++ b/src/wolfbot/master/voice_ingest_service.py @@ -300,6 +300,7 @@ async def _run_stt( audio_end_ms=audio_end_ms, summary=result.summary, co_declaration=co_decl, # type: ignore[arg-type] + addressed_name=result.addressed_name, ) ) diff --git a/src/wolfbot/persistence/schema.py b/src/wolfbot/persistence/schema.py index 5dd8fdc..011ccb4 100644 --- a/src/wolfbot/persistence/schema.py +++ b/src/wolfbot/persistence/schema.py @@ -168,6 +168,7 @@ alive_seat_nos_json TEXT, summary TEXT, co_declaration TEXT, + addressed_seat_no INTEGER, created_at_ms INTEGER NOT NULL ) """, @@ -295,4 +296,8 @@ async def migrate(db_path: str | Path) -> None: await db.execute( "ALTER TABLE speech_events ADD COLUMN co_declaration TEXT" ) + if "addressed_seat_no" not in cols: + await db.execute( + "ALTER TABLE speech_events ADD COLUMN addressed_seat_no INTEGER" + ) await db.commit() diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py index 6641af9..43167e8 100644 --- a/src/wolfbot/services/discussion_service.py +++ b/src/wolfbot/services/discussion_service.py @@ -93,8 +93,9 @@ async def insert(self, event: SpeechEvent) -> None: INSERT INTO speech_events ( event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, - alive_seat_nos_json, summary, co_declaration, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + alive_seat_nos_json, summary, co_declaration, addressed_seat_no, + created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( event.event_id, @@ -112,6 +113,7 @@ async def insert(self, event: SpeechEvent) -> None: event.alive_seat_nos_json, event.summary, event.co_declaration, + event.addressed_seat_no, event.created_at_ms, ), ) @@ -122,7 +124,8 @@ async def load_phase(self, game_id: str, phase_id: str) -> Sequence[SpeechEvent] """ SELECT event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, - alive_seat_nos_json, summary, co_declaration, created_at_ms + alive_seat_nos_json, summary, co_declaration, addressed_seat_no, + created_at_ms FROM speech_events WHERE game_id=? AND phase_id=? ORDER BY created_at_ms ASC, event_id ASC @@ -137,7 +140,8 @@ async def load_for_game(self, game_id: str) -> Sequence[SpeechEvent]: """ SELECT event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, - alive_seat_nos_json, summary, co_declaration, created_at_ms + alive_seat_nos_json, summary, co_declaration, addressed_seat_no, + created_at_ms FROM speech_events WHERE game_id=? ORDER BY created_at_ms ASC, event_id ASC @@ -165,7 +169,8 @@ def _row_to_event(row: Any) -> SpeechEvent: alive_seat_nos_json=row[12], summary=row[13], co_declaration=row[14], - created_at_ms=row[15], + addressed_seat_no=row[15], + created_at_ms=row[16], ) @@ -279,6 +284,7 @@ def make_voice_stt_event( audio_start_ms: int, audio_end_ms: int, co_declaration: str | None = None, + addressed_seat_no: int | None = None, created_at_ms: int | None = None, ) -> SpeechEvent: return SpeechEvent( @@ -295,6 +301,7 @@ def make_voice_stt_event( audio_start_ms=audio_start_ms, audio_end_ms=audio_end_ms, co_declaration=co_declaration, + addressed_seat_no=addressed_seat_no, created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), ) @@ -498,6 +505,22 @@ def apply_speech_event( recent = [*state.recent_speech_event_ids, event.event_id][-10:] + # An NPC utterance answers any pending address; a fresh human address + # supersedes prior ones. Anything else (other human speech without an + # `addressed_seat_no`) keeps the standing address — humans often cluster + # short sentences and we don't want a generic follow-up to clear the hint. + last_addressed_seat = state.last_addressed_seat + last_addressed_speaker_seat = state.last_addressed_speaker_seat + last_addressed_text = state.last_addressed_text + if event.source == SpeechSource.NPC_GENERATED: + last_addressed_seat = None + last_addressed_speaker_seat = None + last_addressed_text = "" + elif event.addressed_seat_no is not None: + last_addressed_seat = event.addressed_seat_no + last_addressed_speaker_seat = speaker + last_addressed_text = event.text + return PublicDiscussionState( game_id=state.game_id, phase_id=state.phase_id, @@ -509,6 +532,9 @@ def apply_speech_event( open_topics=state.open_topics, silent_seats=frozenset(silent), recent_speech_event_ids=tuple(recent), + last_addressed_seat=last_addressed_seat, + last_addressed_speaker_seat=last_addressed_speaker_seat, + last_addressed_text=last_addressed_text, ) @@ -556,12 +582,23 @@ def rebuild_public_state_from_events( co_claims: list[CoClaim] = [] recent_ids: list[str] = [] seen_co: set[tuple[int, str]] = set() + last_addressed_seat: int | None = None + last_addressed_speaker_seat: int | None = None + last_addressed_text: str = "" for event in events: if event.source == SpeechSource.PHASE_BASELINE: continue if event.speaker_seat is not None: spoken_seats.add(event.speaker_seat) recent_ids.append(event.event_id) + if event.source == SpeechSource.NPC_GENERATED: + last_addressed_seat = None + last_addressed_speaker_seat = None + last_addressed_text = "" + elif event.addressed_seat_no is not None: + last_addressed_seat = event.addressed_seat_no + last_addressed_speaker_seat = event.speaker_seat + last_addressed_text = event.text if event.speaker_seat is None: continue role_key = _resolve_co_role(event) @@ -582,6 +619,9 @@ def rebuild_public_state_from_events( state.co_claims = tuple(co_claims) state.silent_seats = frozenset(alive_seats - spoken_seats) state.recent_speech_event_ids = tuple(recent_ids[-10:]) + state.last_addressed_seat = last_addressed_seat + state.last_addressed_speaker_seat = last_addressed_speaker_seat + state.last_addressed_text = last_addressed_text return state From 45daeccd478a6683cff3733768faaf63c0220265 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 22:31:17 +0900 Subject: [PATCH 014/133] feat(speak_arbiter): route addressed NPC ahead of silent rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/main.py | 10 + src/wolfbot/master/ingest_service.py | 123 +++++- src/wolfbot/master/logic_service.py | 15 + src/wolfbot/master/speak_arbiter.py | 24 +- tests/test_addressed_npc_routing.py | 536 +++++++++++++++++++++++++++ tests/test_master_ws_server.py | 15 +- 6 files changed, 712 insertions(+), 11 deletions(-) create mode 100644 tests/test_addressed_npc_routing.py diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index dad727e..145907e 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -208,6 +208,16 @@ async def get_alive_seat_nos(self, game_id: str) -> list[int]: players = await repo.load_players(game_id) return sorted(p.seat_no for p in players if p.alive) + async def resolve_addressed_seat( + self, game_id: str, addressed_name: str + ) -> int | None: + from wolfbot.master.ingest_service import resolve_seat_by_name + + seats = await repo.load_seats(game_id) + players = await repo.load_players(game_id) + alive = {p.seat_no for p in players if p.alive} + return resolve_seat_by_name(addressed_name, seats, alive) + ingest_service = MasterIngestService( registry=npc_registry, discussion=discussion_service, diff --git a/src/wolfbot/master/ingest_service.py b/src/wolfbot/master/ingest_service.py index 1ea652c..f8b37a6 100644 --- a/src/wolfbot/master/ingest_service.py +++ b/src/wolfbot/master/ingest_service.py @@ -17,6 +17,9 @@ from __future__ import annotations import logging +import re +import unicodedata +from collections.abc import Iterable from typing import Protocol, runtime_checkable from wolfbot.domain.discussion import ( @@ -25,6 +28,7 @@ make_phase_id, ) from wolfbot.domain.enums import Phase +from wolfbot.domain.models import Seat from wolfbot.domain.ws_messages import SpeechEventPayload from wolfbot.master.npc_registry import NpcRegistry from wolfbot.services.discussion_service import ( @@ -38,6 +42,97 @@ log = logging.getLogger(__name__) +_HONORIFICS: tuple[str, ...] = ("さん", "くん", "ちゃん", "様", "さま", "君") + + +def _strip_emoji(name: str) -> str: + """Drop leading emoji + whitespace; persona display_names are like '🌙セツ'.""" + out: list[str] = [] + skipping = True + for ch in name: + if skipping: + if ch.isspace(): + continue + cat = unicodedata.category(ch) + # Symbols (S*) and 'Cn' (unassigned) covers most emoji codepoints. + if cat.startswith("S") or cat == "Cn": + continue + skipping = False + out.append(ch) + return "".join(out).strip() + + +def _normalize_name(name: str) -> str: + """Lowercase + NFKC + strip whitespace, emoji, and trailing honorifics. + + Used for both the spoken alias and the seat display name so the + comparison is symmetric and forgiving. + """ + folded = unicodedata.normalize("NFKC", name).strip().lower() + folded = _strip_emoji(folded) + for honorific in _HONORIFICS: + if folded.endswith(honorific): + folded = folded[: -len(honorific)].rstrip() + break + return folded + + +_SEAT_NUMBER_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"^席?\s*(\d+)\s*(?:番|ばん)?$"), + re.compile(r"^seat\s*(\d+)$"), +) + + +def resolve_seat_by_name( + addressed_name: str, + seats: Iterable[Seat], + alive: frozenset[int] | set[int] | None = None, +) -> int | None: + """Map ``addressed_name`` (literal handle from voice) to a seat number. + + Returns ``None`` when no seat matches, or when ``alive`` is provided and + the candidate is not currently alive (we never route an address at a + dead seat — they cannot reply). Ambiguous matches across multiple seats + also return ``None`` to fail closed. + """ + norm = _normalize_name(addressed_name) + if not norm: + return None + + for pat in _SEAT_NUMBER_PATTERNS: + m = pat.match(norm) + if m: + try: + seat_no = int(m.group(1)) + except ValueError: + continue + if 1 <= seat_no <= 9 and (alive is None or seat_no in alive): + return seat_no + return None + + matches: set[int] = set() + for seat in seats: + if alive is not None and seat.seat_no not in alive: + continue + candidates = {_normalize_name(seat.display_name)} + if seat.persona_key: + candidates.add(_normalize_name(seat.persona_key)) + candidates.discard("") + if norm in candidates: + matches.add(seat.seat_no) + continue + # Fall back to substring containment in either direction so handles + # like "ジナ" still match a display_name "ジナ・メイユイ". + for cand in candidates: + if cand and (norm == cand or norm in cand or cand in norm): + matches.add(seat.seat_no) + break + + if len(matches) == 1: + return matches.pop() + return None + + @runtime_checkable class PhaseLookup(Protocol): """Resolves the current phase / day / alive seats for a game id. @@ -51,6 +146,14 @@ async def get_phase(self, game_id: str) -> tuple[Phase, int] | None: ... async def get_alive_seat_nos(self, game_id: str) -> list[int]: ... + async def resolve_addressed_seat( + self, game_id: str, addressed_name: str + ) -> int | None: + """Map a literal name/handle ('セツ', '席3', 'Alice さん') to a seat + number for `game_id`. Returns ``None`` when no seat matches or the + match is ambiguous.""" + ... + class MasterIngestService: """Boundary handler for voice-ingest payloads.""" @@ -113,6 +216,23 @@ async def ingest_voice( alive_seat_nos=alive_seat_nos, ) + addressed_seat_no: int | None = None + if payload.addressed_name: + try: + addressed_seat_no = await self.phase_lookup.resolve_addressed_seat( + payload.game_id, payload.addressed_name + ) + except Exception: + log.exception( + "addressed_seat_resolution_failed game=%s name=%s", + payload.game_id, + payload.addressed_name, + ) + addressed_seat_no = None + # Self-address never needs a routed reply. + if addressed_seat_no is not None and addressed_seat_no == payload.seat_no: + addressed_seat_no = None + event = SpeechEvent( event_id=new_event_id(), game_id=payload.game_id, @@ -128,10 +248,11 @@ async def ingest_voice( audio_end_ms=payload.audio_end_ms, summary=payload.summary, co_declaration=payload.co_declaration, + addressed_seat_no=addressed_seat_no, created_at_ms=default_now_ms(), ) await self.discussion.record(event) return (event, None) -__all__ = ["MasterIngestService", "PhaseLookup"] +__all__ = ["MasterIngestService", "PhaseLookup", "resolve_seat_by_name"] diff --git a/src/wolfbot/master/logic_service.py b/src/wolfbot/master/logic_service.py index 95c0ac7..006d3df 100644 --- a/src/wolfbot/master/logic_service.py +++ b/src/wolfbot/master/logic_service.py @@ -65,6 +65,21 @@ def build_logic_packet( else "(none)" ) summary = f"phase_id={state.phase_id} day={state.day} co_claims=[{co_repr}] {silent_repr}" + if state.last_addressed_seat is not None: + speaker_repr = ( + f"席{state.last_addressed_speaker_seat}" + if state.last_addressed_speaker_seat is not None + else "human" + ) + # Truncate the spoken line so the packet stays small even if the + # speaker rambled. NPCs only need the gist to respond on-topic. + utter = state.last_addressed_text.strip().replace("\n", " ") + if len(utter) > 160: + utter = utter[:160] + "…" + summary += ( + f" last_address=席{state.last_addressed_seat}" + f" from={speaker_repr} text=\"{utter}\"" + ) return LogicPacket( ts=now_ms, diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index 9e19c34..c4007f2 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -502,19 +502,25 @@ async def try_dispatch_next(self, game_id: str) -> None: if state is None: return - # Pick the next NPC. Priority: seats that have not yet spoken in this - # phase (PublicDiscussionState.silent_seats) before seats that have. - # Within each bucket, lowest assigned_seat wins. Without this two-key - # sort the picker would always return the lowest-seat NPC, monopolizing - # the discussion in pure-NPC games where no human speech triggers - # rotation. Once every alive NPC has spoken once the silent set is - # empty and the bucket becomes a no-op — order falls back to seat-no. + # Pick the next NPC. Priority order, applied as a 3-key sort: + # 1. addressed seat — if a recent human utterance carries + # `addressed_seat_no`, that NPC must reply before anyone else. + # 2. silent seats — NPCs who haven't yet spoken in this phase win + # over those who have. Without this the lowest-seat NPC would + # monopolize pure-NPC games where no human speech triggers + # rotation. Once every alive NPC has spoken the bucket becomes + # a no-op and order falls back to seat number. + # 3. lowest assigned_seat as a stable tiebreaker. + addressed = state.last_addressed_seat online = self.registry.all_online() - def _pick_key(e: object) -> tuple[int, int]: + def _pick_key(e: object) -> tuple[int, int, int]: seat = getattr(e, "assigned_seat", None) or 99 + is_addressed = 0 if ( + addressed is not None and seat == addressed + ) else 1 in_silent = 0 if seat in state.silent_seats else 1 - return (in_silent, seat) + return (is_addressed, in_silent, seat) for entry in sorted(online, key=_pick_key): if entry.assigned_seat is None or entry.game_id != game_id: diff --git a/tests/test_addressed_npc_routing.py b/tests/test_addressed_npc_routing.py new file mode 100644 index 0000000..8409bdc --- /dev/null +++ b/tests/test_addressed_npc_routing.py @@ -0,0 +1,536 @@ +"""Addressed-NPC priority routing — end-to-end coverage. + +Verifies the four-layer change so a human's named-address ('セツさん、どう +思う?') is dispatched to the addressed NPC seat first, with the utterance +text forwarded to that NPC's logic packet so the LLM can actually reply +on-topic: + +1. Gemini analyzer prompt parses `addressed_name` from the JSON envelope. +2. `MasterIngestService.ingest_voice` resolves `addressed_name` against the + live seats table to populate `SpeechEvent.addressed_seat_no`. +3. `PublicDiscussionState` fold tracks the most recent unanswered address. +4. `SpeakArbiter.try_dispatch_next` picks the addressed NPC ahead of silent + seats and lowest-seat tiebreaker, and `build_logic_packet` includes the + utterance in the packet summary. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from wolfbot.domain.discussion import ( + PublicDiscussionState, + make_phase_id, +) +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Game, Seat +from wolfbot.domain.ws_messages import SpeechEventPayload +from wolfbot.master.ingest_service import ( + MasterIngestService, + resolve_seat_by_name, +) +from wolfbot.master.logic_service import build_logic_packet +from wolfbot.master.npc_registry import InMemoryNpcRegistry +from wolfbot.master.speak_arbiter import SpeakArbiter +from wolfbot.master.stt_service import GeminiAudioAnalyzer +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + make_human_text_event, + make_npc_generated_event, + make_phase_baseline, + rebuild_public_state_from_events, +) + +# ------------------------------------------------------ Layer 1: Gemini parser + + +def test_gemini_analyzer_parses_addressed_name() -> None: + """The analyzer's JSON parser must surface `addressed_name` on SttResult.""" + raw = ( + '{"transcript":"セツさん、どう思う?","summary":"セツに意見を求めた",' + '"confidence":0.91,"co_claim":null,"vote_target_seat":null,' + '"stance":{},"addressed_name":"セツ"}' + ) + parsed = GeminiAudioAnalyzer._parse_response(raw) + assert parsed.get("addressed_name") == "セツ" + + +def test_gemini_analyzer_addressed_name_null_when_global_address() -> None: + """Non-personal addresses ('みんな') must not surface as addressed_name.""" + raw = ( + '{"transcript":"みんなどう思う?","summary":"全体への問いかけ",' + '"confidence":0.88,"co_claim":null,"vote_target_seat":null,' + '"stance":{},"addressed_name":null}' + ) + parsed = GeminiAudioAnalyzer._parse_response(raw) + assert parsed.get("addressed_name") is None + + +# ------------------------------------------------------ Layer 2: name → seat resolver + + +def _seat(no: int, *, name: str, persona: str | None = None, llm: bool = True) -> Seat: + return Seat( + seat_no=no, + display_name=name, + discord_user_id=None if llm else f"u{no}", + is_llm=llm, + persona_key=persona, + ) + + +def test_resolve_seat_by_name_strips_emoji_and_honorific() -> None: + seats = [ + _seat(1, name="Alice", persona=None, llm=False), + _seat(2, name="🌙セツ", persona="setsu"), + _seat(3, name="🟣ジナ", persona="gina"), + ] + assert resolve_seat_by_name("セツさん", seats) == 2 + assert resolve_seat_by_name("ジナ", seats) == 3 + assert resolve_seat_by_name("ジナちゃん", seats) == 3 + + +def test_resolve_seat_by_name_recognises_seat_number_forms() -> None: + seats = [ + _seat(1, name="Alice", persona=None, llm=False), + _seat(2, name="🌙セツ", persona="setsu"), + _seat(3, name="🟣ジナ", persona="gina"), + ] + assert resolve_seat_by_name("席3", seats) == 3 + assert resolve_seat_by_name("3番", seats) == 3 + assert resolve_seat_by_name("seat 2", seats) == 2 + + +def test_resolve_seat_by_name_returns_none_for_unknown_or_dead() -> None: + seats = [ + _seat(2, name="🌙セツ", persona="setsu"), + _seat(3, name="🟣ジナ", persona="gina"), + ] + assert resolve_seat_by_name("ククルシカ", seats) is None + # Dead seat — must not return it even if name matches. + assert resolve_seat_by_name("セツ", seats, alive=frozenset({3})) is None + + +def test_resolve_seat_by_name_returns_none_on_ambiguity() -> None: + seats = [ + _seat(2, name="Alice", persona=None, llm=False), + _seat(3, name="Alice", persona=None, llm=False), + ] + assert resolve_seat_by_name("Alice", seats) is None + + +# ------------------------------------------------------ Layer 2: ingest service + + +class _SeatedPhaseLookup: + def __init__(self, seats: list[Seat], alive: list[int]) -> None: + self._seats = seats + self._alive = alive + + async def get_phase(self, game_id: str) -> tuple[Phase, int] | None: + return (Phase.DAY_DISCUSSION, 1) + + async def get_alive_seat_nos(self, game_id: str) -> list[int]: + return list(self._alive) + + async def resolve_addressed_seat( + self, game_id: str, addressed_name: str + ) -> int | None: + return resolve_seat_by_name( + addressed_name, self._seats, alive=frozenset(self._alive) + ) + + +async def _make_seated_game(repo: SqliteRepo) -> tuple[Game, list[Seat]]: + g = Game( + id="g_addr", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + _seat(1, name="Alice", persona=None, llm=False), + _seat(2, name="🌙セツ", persona="setsu"), + _seat(3, name="🟣ジナ", persona="gina"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + await repo.set_player_role(g.id, 3, Role.VILLAGER) + return g, seats + + +async def test_ingest_voice_sets_addressed_seat_from_payload( + repo: SqliteRepo, +) -> None: + g, seats = await _make_seated_game(repo) + registry = InMemoryNpcRegistry() + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + lookup = _SeatedPhaseLookup(seats=seats, alive=[1, 2, 3]) + svc = MasterIngestService( + registry=registry, discussion=discussion, phase_lookup=lookup + ) + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id=g.id, + phase_id=make_phase_id(g.id, 1, Phase.DAY_DISCUSSION), + seat_no=1, + speaker_discord_user_id="u1", + segment_id="s1", + text="セツさん、どう思う?", + confidence=0.92, + duration_ms=600, + audio_start_ms=0, + audio_end_ms=600, + addressed_name="セツ", + ) + event, reason = await svc.ingest_voice(payload) + assert reason is None + assert event is not None + assert event.addressed_seat_no == 2 + rows = await store.load_phase(g.id, event.phase_id) + assert any(r.event_id == event.event_id and r.addressed_seat_no == 2 for r in rows) + + +async def test_ingest_voice_self_address_is_dropped(repo: SqliteRepo) -> None: + """A speaker calling their own name must not produce a routed address.""" + g, seats = await _make_seated_game(repo) + registry = InMemoryNpcRegistry() + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + lookup = _SeatedPhaseLookup(seats=seats, alive=[1, 2, 3]) + svc = MasterIngestService( + registry=registry, discussion=discussion, phase_lookup=lookup + ) + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id=g.id, + phase_id=make_phase_id(g.id, 1, Phase.DAY_DISCUSSION), + seat_no=2, # speaker is セツ + speaker_discord_user_id="u2", + segment_id="s1", + text="セツです、よろしく", + confidence=0.92, + duration_ms=600, + audio_start_ms=0, + audio_end_ms=600, + addressed_name="セツ", + ) + event, reason = await svc.ingest_voice(payload) + assert reason is None + assert event is not None + assert event.addressed_seat_no is None + + +# ------------------------------------------------------ Layer 3: state fold + + +def test_public_state_fold_tracks_last_addressed_seat() -> None: + phase_id = make_phase_id("g", 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id="g", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + addressed = make_human_text_event( + game_id="g", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="セツさん、どう思う?", + created_at_ms=2, + ).model_copy(update={"addressed_seat_no": 2}) + + state = rebuild_public_state_from_events([sentinel, addressed]) + assert state is not None + assert state.last_addressed_seat == 2 + assert state.last_addressed_speaker_seat == 1 + assert "セツさん" in state.last_addressed_text + + +def test_public_state_fold_clears_address_after_npc_reply() -> None: + phase_id = make_phase_id("g", 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id="g", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + addressed = make_human_text_event( + game_id="g", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="セツさん、どう思う?", + created_at_ms=2, + ).model_copy(update={"addressed_seat_no": 2}) + npc_reply = make_npc_generated_event( + game_id="g", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="そうだね", + created_at_ms=3, + ) + + state = rebuild_public_state_from_events([sentinel, addressed, npc_reply]) + assert state is not None + assert state.last_addressed_seat is None + assert state.last_addressed_text == "" + + +# ------------------------------------------------------ Layer 4: arbiter picker + + +def _captured_send(buf: list[str]) -> Callable[[str], Awaitable[None]]: + async def send(msg: str) -> None: + buf.append(msg) + + return send + + +async def test_speak_arbiter_prefers_addressed_seat_over_silent_lowest( + repo: SqliteRepo, +) -> None: + """Even when seat 2 is also silent (and would otherwise win on the + lowest-seat tiebreaker), the addressed seat 3 must be picked.""" + g, _seats = await _make_seated_game(repo) + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + addressed = make_human_text_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="ジナさん、どう思う?", + created_at_ms=2, + ).model_copy(update={"addressed_seat_no": 3}) + await store.insert(addressed) + + registry = InMemoryNpcRegistry() + buf2: list[str] = [] + buf3: list[str] = [] + registry.register( + npc_id="npc_setsu", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(buf2), + now_ms=1000, + persona_key="setsu", + ) + registry.register( + npc_id="npc_gina", + discord_bot_user_id="bot3", + supported_voices=(), + version="1", + send=_captured_send(buf3), + now_ms=1000, + persona_key="gina", + ) + registry.assign("npc_setsu", seat=2, game_id=g.id, phase_id=phase_id) + registry.assign("npc_gina", seat=3, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500 + ) + await arb.try_dispatch_next(g.id) + + assert buf3, "addressed NPC at seat 3 must be dispatched first" + assert not buf2, "lowest-seat NPC at seat 2 must not preempt the addressed one" + + +async def test_speak_arbiter_skips_addressed_when_offline_falls_back_to_silent( + repo: SqliteRepo, +) -> None: + """Address resolution must not strand the discussion: if the named NPC + is offline, the picker should fall through to the existing silent-first + rotation rather than no-op.""" + g, _seats = await _make_seated_game(repo) + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + # Address seat 3, but only seat 2's NPC is online. + addressed = make_human_text_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="ジナさん、どう?", + created_at_ms=2, + ).model_copy(update={"addressed_seat_no": 3}) + await store.insert(addressed) + + registry = InMemoryNpcRegistry() + buf2: list[str] = [] + registry.register( + npc_id="npc_setsu", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(buf2), + now_ms=1000, + persona_key="setsu", + ) + registry.assign("npc_setsu", seat=2, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500 + ) + await arb.try_dispatch_next(g.id) + + assert buf2, "fallback to the only online silent NPC must still happen" + + +# ------------------------------------------------------ Layer 4: logic packet + + +def test_logic_packet_summary_includes_address_signal() -> None: + state = PublicDiscussionState( + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + alive_seat_nos=frozenset({1, 2, 3}), + last_addressed_seat=2, + last_addressed_speaker_seat=1, + last_addressed_text="セツさん、占い結果はどうだった?", + ) + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_setsu", + expires_at_ms=2000, + now_ms=1500, + ) + assert "last_address=席2" in packet.public_state_summary + assert "from=席1" in packet.public_state_summary + assert "セツさん" in packet.public_state_summary + + +def test_logic_packet_summary_omits_address_when_none() -> None: + state = PublicDiscussionState( + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + alive_seat_nos=frozenset({1, 2, 3}), + ) + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_setsu", + expires_at_ms=2000, + now_ms=1500, + ) + assert "last_address" not in packet.public_state_summary + + +# ------------------------------------------------------ End-to-end: payload → arbiter + + +async def test_end_to_end_addressed_dispatch(repo: SqliteRepo) -> None: + """A SpeechEventPayload carrying `addressed_name='ジナ'` must result in + the seat-3 NPC receiving the next SpeakRequest, not seat 2.""" + g, seats = await _make_seated_game(repo) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + registry = InMemoryNpcRegistry() + buf2: list[str] = [] + buf3: list[str] = [] + registry.register( + npc_id="npc_setsu", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(buf2), + now_ms=1000, + persona_key="setsu", + ) + registry.register( + npc_id="npc_gina", + discord_bot_user_id="bot3", + supported_voices=(), + version="1", + send=_captured_send(buf3), + now_ms=1000, + persona_key="gina", + ) + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + registry.assign("npc_setsu", seat=2, game_id=g.id, phase_id=phase_id) + registry.assign("npc_gina", seat=3, game_id=g.id, phase_id=phase_id) + + lookup = _SeatedPhaseLookup(seats=seats, alive=[1, 2, 3]) + ingest = MasterIngestService( + registry=registry, discussion=discussion, phase_lookup=lookup + ) + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500 + ) + + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id=g.id, + phase_id=phase_id, + seat_no=1, + speaker_discord_user_id="u1", + segment_id="s1", + text="ジナさん、どう思う?", + confidence=0.92, + duration_ms=600, + audio_start_ms=0, + audio_end_ms=600, + addressed_name="ジナ", + ) + event, reason = await ingest.ingest_voice(payload) + assert reason is None and event is not None and event.addressed_seat_no == 3 + + await arb.try_dispatch_next(g.id) + assert buf3, "addressed NPC at seat 3 must be dispatched" + assert not buf2, "non-addressed NPC at seat 2 must wait" + # The dispatched logic packet must carry the human's utterance so the + # NPC's LLM has actual context to reply to. + assert any("ジナさん" in m for m in buf3) + + diff --git a/tests/test_master_ws_server.py b/tests/test_master_ws_server.py index 2165301..5ae5f5a 100644 --- a/tests/test_master_ws_server.py +++ b/tests/test_master_ws_server.py @@ -249,9 +249,11 @@ def __init__( self, mapping: dict[str, tuple[Phase, int]], alive_seats: dict[str, list[int]] | None = None, + addressed: dict[tuple[str, str], int] | None = None, ) -> None: self._mapping = mapping self._alive = alive_seats or {} + self._addressed = addressed or {} async def get_phase(self, game_id: str) -> tuple[Phase, int] | None: return self._mapping.get(game_id) @@ -259,6 +261,11 @@ async def get_phase(self, game_id: str) -> tuple[Phase, int] | None: async def get_alive_seat_nos(self, game_id: str) -> list[int]: return self._alive.get(game_id, []) + async def resolve_addressed_seat( + self, game_id: str, addressed_name: str + ) -> int | None: + return self._addressed.get((game_id, addressed_name)) + async def test_master_ingest_discards_npc_speaker(repo: SqliteRepo) -> None: registry = InMemoryNpcRegistry() @@ -505,7 +512,8 @@ async def test_close_npc_playback_only_affects_open_rows(repo: SqliteRepo) -> No def test_phase_lookup_protocol_runtime_check() -> None: - """A class with `get_phase` and `get_alive_seat_nos` satisfies PhaseLookup.""" + """A class with `get_phase`, `get_alive_seat_nos`, and + `resolve_addressed_seat` satisfies PhaseLookup.""" class Impl: async def get_phase(self, game_id: str) -> tuple[Phase, int] | None: @@ -514,6 +522,11 @@ async def get_phase(self, game_id: str) -> tuple[Phase, int] | None: async def get_alive_seat_nos(self, game_id: str) -> list[int]: return [] + async def resolve_addressed_seat( + self, game_id: str, addressed_name: str + ) -> int | None: + return None + obj = Impl() # Use the runtime-checkable protocol; this asserts the structural match. assert isinstance(obj, PhaseLookup) From da3a4dc70f1a9af33d663d9bc3185e14e7f3e8aa Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 22:54:58 +0900 Subject: [PATCH 015/133] feat(text-analyzer): mirror voice's structured analysis on the text channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/wolfbot/main.py | 20 ++ src/wolfbot/master/text_analyzer.py | 212 +++++++++++++++++++++ src/wolfbot/services/discord_service.py | 51 ++++- src/wolfbot/services/discussion_service.py | 2 + tests/test_addressed_npc_routing.py | 101 ++++++++++ tests/test_text_analyzer.py | 80 ++++++++ 6 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 src/wolfbot/master/text_analyzer.py create mode 100644 tests/test_text_analyzer.py diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 145907e..0196593 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -146,6 +146,25 @@ async def _on_speech_recorded(game_id: str) -> None: if _reactive_phase_cb: await _reactive_phase_cb[0].try_dispatch_next(game_id) + # Mirror the voice path's structured analysis on the text channel: + # one Gemini call per typed message yields the same `addressed_name` + # / `co_claim` signal so SpeakArbiter can route a text address to + # the right NPC seat. Wired only when reactive_voice is enabled and a + # Voice LLM key is present; absent → WolfCog falls back to plain raw + # capture (the historical behavior). + text_analyzer: Any = None + if ( + settings.LLM_DISCUSSION_MODE == "reactive_voice" + and settings.MASTER_NPC_PSK is not None + and settings.VOICE_LLM_API_KEY is not None + ): + from wolfbot.master.text_analyzer import GeminiTextAnalyzer + + text_analyzer = GeminiTextAnalyzer( + api_key=settings.VOICE_LLM_API_KEY.get_secret_value(), + model=settings.VOICE_LLM_MODEL, + ) + cog = WolfCog( bot=bot, repo=repo, @@ -157,6 +176,7 @@ async def _on_speech_recorded(game_id: str) -> None: discussion_service=discussion_service, on_speech_recorded=_on_speech_recorded, npc_registry=npc_registry, + text_analyzer=text_analyzer, ) await bot.add_cog(cog) bot.tree.add_command(cog.wolf, guild=discord.Object( diff --git a/src/wolfbot/master/text_analyzer.py b/src/wolfbot/master/text_analyzer.py new file mode 100644 index 0000000..73f4bc5 --- /dev/null +++ b/src/wolfbot/master/text_analyzer.py @@ -0,0 +1,212 @@ +"""Text-channel utterance analyzer — mirrors `GeminiAudioAnalyzer`. + +Voice utterances go through `GeminiAudioAnalyzer` (audio → transcript + +structured fields). Text-channel utterances were previously persisted raw +with no structure, so a human typing a direct address ("〜さん、どう思う" +style) never set `SpeechEvent.addressed_seat_no` and the SpeakArbiter +could not route the reply. + +This module gives the text path the same structured signal: + + TextAnalyzer.analyze(text) → TextAnalysis( + addressed_name: str | None, # 'セツ' / '席3' / None + co_declaration: str | None, # 'seer' / 'medium' / 'knight' / None + ) + +Master then resolves `addressed_name` via the same `resolve_seat_by_name` +helper used by the voice path and passes both fields to +`make_human_text_event(...)`. The downstream pipeline (DiscussionService, +PublicDiscussionState fold, SpeakArbiter picker, LogicPacket summary) is +identical for text and voice — only the analyzer differs. + +Providers: +- ``GeminiTextAnalyzer`` — production. Sends one cheap Gemini call per + message (no audio, ~25-token prompt + the message text). +- ``FakeTextAnalyzer`` — deterministic stub for tests. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class TextAnalysis: + """Structured analysis of a single text-channel utterance.""" + + addressed_name: str | None = None + co_declaration: str | None = None + + +class TextAnalyzerError(RuntimeError): + """Raised on hard provider failures (timeout, 5xx, malformed response).""" + + def __init__(self, failure_reason: str) -> None: + super().__init__(failure_reason) + self.failure_reason = failure_reason + + +@runtime_checkable +class TextAnalyzer(Protocol): + """Async analyzer used by `WolfCog.on_message`. Must be cancellable and + must not block the asyncio loop on the network call.""" + + async def analyze(self, *, text: str, timeout_s: float) -> TextAnalysis: ... + + +class FakeTextAnalyzer: + """In-memory analyzer for tests. + + Either return a scripted sequence of results / errors, or a fixed + `default`. Tests can also pass a callable to derive the analysis from + the input text. + """ + + def __init__( + self, + scripted: list[TextAnalysis | Exception] | None = None, + default: TextAnalysis | None = None, + ) -> None: + self._scripted: list[TextAnalysis | Exception] = list(scripted or []) + self._default: TextAnalysis | None = default + self.call_count = 0 + self.last_text: str | None = None + + async def analyze(self, *, text: str, timeout_s: float) -> TextAnalysis: + self.call_count += 1 + self.last_text = text + if self._scripted: + head = self._scripted.pop(0) + if isinstance(head, Exception): + raise head + return head + if self._default is None: + return TextAnalysis() + return self._default + + +class GeminiTextAnalyzer: + """Gemini text-only analyzer. Schema mirrors `GeminiAudioAnalyzer`. + + Uses ``httpx`` lazily (no module-level import) so test environments + without httpx still load this file. Default model is the same cheap + flash-lite used for audio so cost stays bounded. + """ + + _SYSTEM_PROMPT: str = ( + "あなたは人狼ゲームのチャット発言分析エンジンです。\n" + "渡された発言テキスト(日本語)を読み、以下のJSON形式で返してください。\n" + "JSONのみ返答し、他のテキストは含めないでください。\n\n" + "```json\n" + "{\n" + ' "co_claim": null,\n' + ' "addressed_name": null\n' + "}\n" + "```\n\n" + "フィールド説明:\n" + "- co_claim: 役職CO(自称)があれば \"seer\"/\"medium\"/\"knight\"、なければ null\n" + "- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 \"セツ\"、\"ジーナさん\"、\"席3\"、\"3番\")、なければ null。" + "「みんな」「全員」など全体への呼びかけは null。さん/くん/ちゃん 等の敬称は付けたままでも構わない。" + "発言内で他プレイヤーに言及するだけ(例: 『セツの判定が気になる』)は呼びかけではないので null。" + "明確な宛先のある呼びかけ(例: 『セツさん、どう思う』)のみ設定。" + ) + + def __init__( + self, + *, + api_key: str, + model: str = "gemini-2.0-flash-lite", + api_base: str = "https://generativelanguage.googleapis.com/v1beta", + timeout_s: float = 8.0, + ) -> None: + self.api_key = api_key + self.model = model + self.api_base = api_base.rstrip("/") + self.timeout_s = timeout_s + + async def analyze(self, *, text: str, timeout_s: float) -> TextAnalysis: + import httpx + + effective_timeout = min(timeout_s, self.timeout_s) + body = { + "contents": [ + { + "parts": [ + {"text": self._SYSTEM_PROMPT}, + {"text": f"発言:\n{text}"}, + ] + } + ], + "generationConfig": { + "responseMimeType": "application/json", + "temperature": 0.0, + }, + } + url = ( + f"{self.api_base}/models/{self.model}:generateContent" + f"?key={self.api_key}" + ) + try: + async with httpx.AsyncClient(timeout=effective_timeout) as client: + resp = await client.post(url, json=body) + if resp.status_code != 200: + raise TextAnalyzerError(f"gemini_http_{resp.status_code}") + resp_json = resp.json() + raw_text = ( + resp_json.get("candidates", [{}])[0] + .get("content", {}) + .get("parts", [{}])[0] + .get("text", "") + ) + parsed = self._parse_response(raw_text) + except TextAnalyzerError: + raise + except httpx.TimeoutException as exc: + raise TextAnalyzerError("gemini_timeout") from exc + except httpx.ConnectError as exc: + raise TextAnalyzerError("gemini_connection_refused") from exc + except Exception as exc: + raise TextAnalyzerError( + f"gemini_unexpected_{type(exc).__name__}" + ) from exc + + co_raw = parsed.get("co_claim") + co_declaration = ( + co_raw if co_raw in ("seer", "medium", "knight") else None + ) + addressed_raw = parsed.get("addressed_name") + addressed_name: str | None = None + if isinstance(addressed_raw, str): + stripped = addressed_raw.strip() + addressed_name = stripped or None + return TextAnalysis( + addressed_name=addressed_name, + co_declaration=co_declaration, + ) + + @staticmethod + def _parse_response(raw: str) -> dict: # type: ignore[type-arg] + text = raw.strip() + if text.startswith("```"): + lines = text.split("\n") + lines = [ln for ln in lines if not ln.strip().startswith("```")] + text = "\n".join(lines).strip() + try: + return json.loads(text) # type: ignore[no-any-return] + except json.JSONDecodeError: + log.warning("gemini_text_json_parse_failed raw=%s", text[:200]) + return {} + + +__all__ = [ + "FakeTextAnalyzer", + "GeminiTextAnalyzer", + "TextAnalysis", + "TextAnalyzer", + "TextAnalyzerError", +] diff --git a/src/wolfbot/services/discord_service.py b/src/wolfbot/services/discord_service.py index 8e6be69..227e96e 100644 --- a/src/wolfbot/services/discord_service.py +++ b/src/wolfbot/services/discord_service.py @@ -17,7 +17,7 @@ import time from collections.abc import Awaitable, Callable, Sequence from random import Random -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import discord from discord import app_commands @@ -419,6 +419,7 @@ def __init__( discussion_service: DiscussionService | None = None, on_speech_recorded: Callable[[str], Awaitable[None]] | None = None, npc_registry: NpcRegistry | None = None, + text_analyzer: Any = None, ) -> None: super().__init__() self.bot = bot @@ -436,6 +437,13 @@ def __init__( # start` fills LLM seats from online NPC bots (each carrying a fixed # persona) instead of randomly drawing from NPC_PERSONAS. self._npc_registry = npc_registry + # Optional structured analyzer for text-channel utterances. Mirrors + # the voice path's `GeminiAudioAnalyzer` — extracts `addressed_name` + # and `co_declaration` so SpeakArbiter can route replies to the + # named NPC seat just like for voice. Wired only when reactive_voice + # is active and a Voice LLM key is set; falls back to plain raw + # capture when None. + self._text_analyzer = text_analyzer def _select_llm_seat_personas( self, @@ -540,6 +548,45 @@ async def on_message(self, message: discord.Message) -> None: alive_seat_nos=alive_seat_nos, ) phase_id = make_phase_id(game.id, game.day_number, game.phase) + + # Mirror the voice path: when a TextAnalyzer is wired, + # extract `addressed_name` + `co_declaration` so the + # downstream SpeakArbiter / state fold see the same + # structured signal regardless of whether the human + # spoke or typed. Failures are best-effort — a slow or + # broken analyzer must not block the SpeechEvent write. + addressed_seat_no: int | None = None + co_declaration: str | None = None + if self._text_analyzer is not None: + try: + analysis = await self._text_analyzer.analyze( + text=message.content, timeout_s=8.0 + ) + except Exception: + log.exception( + "text_analyzer_failed game=%s seat=%s", + game.id, + author_seat, + ) + else: + co_declaration = analysis.co_declaration + if analysis.addressed_name: + from wolfbot.master.ingest_service import ( + resolve_seat_by_name, + ) + + seats = await self.repo.load_seats(game.id) + alive_set = { + p.seat_no for p in players if p.alive + } + seat_no = resolve_seat_by_name( + analysis.addressed_name, + seats, + alive=frozenset(alive_set), + ) + if seat_no is not None and seat_no != author_seat: + addressed_seat_no = seat_no + event = make_human_text_event( game_id=game.id, phase_id=phase_id, @@ -547,6 +594,8 @@ async def on_message(self, message: discord.Message) -> None: phase=game.phase, speaker_seat=author_seat, text=message.content, + co_declaration=co_declaration, + addressed_seat_no=addressed_seat_no, ) await self._discussion_service.record(event) # Trigger arbiter dispatch so NPCs can respond to new text. diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py index 43167e8..4f51d13 100644 --- a/src/wolfbot/services/discussion_service.py +++ b/src/wolfbot/services/discussion_service.py @@ -229,6 +229,7 @@ def make_human_text_event( speaker_seat: int, text: str, co_declaration: str | None = None, + addressed_seat_no: int | None = None, created_at_ms: int | None = None, ) -> SpeechEvent: return SpeechEvent( @@ -242,6 +243,7 @@ def make_human_text_event( speaker_seat=speaker_seat, text=text, co_declaration=co_declaration, + addressed_seat_no=addressed_seat_no, created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), ) diff --git a/tests/test_addressed_npc_routing.py b/tests/test_addressed_npc_routing.py index 8409bdc..9930696 100644 --- a/tests/test_addressed_npc_routing.py +++ b/tests/test_addressed_npc_routing.py @@ -469,6 +469,107 @@ def test_logic_packet_summary_omits_address_when_none() -> None: # ------------------------------------------------------ End-to-end: payload → arbiter +async def test_wolfcog_on_message_uses_text_analyzer_to_set_addressed_seat( + repo: SqliteRepo, +) -> None: + """When a TextAnalyzer is wired, `WolfCog.on_message` must call it and + propagate the resolved `addressed_seat_no` onto the SpeechEvent so the + text path matches the voice path.""" + from unittest.mock import MagicMock + + from wolfbot.domain.discussion import SpeechSource + from wolfbot.master.text_analyzer import FakeTextAnalyzer, TextAnalysis + from wolfbot.services.discord_service import WolfCog + from wolfbot.services.discussion_service import SqliteSpeechEventStore + + g, _seats = await _make_seated_game(repo) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store, log_sink=repo) + fake_analyzer = FakeTextAnalyzer( + scripted=[TextAnalysis(addressed_name="ジナ")] + ) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=discussion, + text_analyzer=fake_analyzer, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "u1" + msg.guild.id = g.guild_id + msg.channel.id = "c1" # main_text_channel_id + msg.content = "ジナさん、どう思う?" + + # Patch the message channel to match game.main_text_channel_id ("c1"). + await WolfCog.on_message(cog, msg) + + # The fake analyzer should have been called once with the message body. + assert fake_analyzer.call_count == 1 + assert fake_analyzer.last_text == "ジナさん、どう思う?" + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(g.id, phase_id) + text_events = [e for e in events if e.source == SpeechSource.TEXT] + assert len(text_events) == 1 + assert text_events[0].addressed_seat_no == 3 + assert text_events[0].speaker_seat == 1 + + +async def test_wolfcog_on_message_skips_addressed_when_analyzer_fails( + repo: SqliteRepo, +) -> None: + """A broken analyzer must not block the SpeechEvent write — the row + should still land with `addressed_seat_no=None` so plain text capture + keeps working.""" + from unittest.mock import MagicMock + + from wolfbot.domain.discussion import SpeechSource + from wolfbot.master.text_analyzer import FakeTextAnalyzer + from wolfbot.services.discord_service import WolfCog + from wolfbot.services.discussion_service import SqliteSpeechEventStore + + g, _seats = await _make_seated_game(repo) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store, log_sink=repo) + broken = FakeTextAnalyzer(scripted=[RuntimeError("provider down")]) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=discussion, + text_analyzer=broken, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "u1" + msg.guild.id = g.guild_id + msg.channel.id = "c1" + msg.content = "セツさん、どう?" + + await WolfCog.on_message(cog, msg) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(g.id, phase_id) + text_events = [e for e in events if e.source == SpeechSource.TEXT] + assert len(text_events) == 1 + assert text_events[0].addressed_seat_no is None + assert text_events[0].text == "セツさん、どう?" + + async def test_end_to_end_addressed_dispatch(repo: SqliteRepo) -> None: """A SpeechEventPayload carrying `addressed_name='ジナ'` must result in the seat-3 NPC receiving the next SpeakRequest, not seat 2.""" diff --git a/tests/test_text_analyzer.py b/tests/test_text_analyzer.py new file mode 100644 index 0000000..fed1cc2 --- /dev/null +++ b/tests/test_text_analyzer.py @@ -0,0 +1,80 @@ +"""GeminiTextAnalyzer + FakeTextAnalyzer — parser and stub coverage. + +The analyzer mirrors `GeminiAudioAnalyzer` for the text channel. End-to-end +routing (analyzer → SpeechEvent.addressed_seat_no → SpeakArbiter dispatch) +is covered by `test_addressed_npc_routing.py`; this file exercises the +analyzer module in isolation. +""" + +from __future__ import annotations + +import pytest + +from wolfbot.master.text_analyzer import ( + FakeTextAnalyzer, + GeminiTextAnalyzer, + TextAnalysis, +) + + +def test_parse_response_handles_plain_json() -> None: + raw = '{"co_claim":"seer","addressed_name":"セツ"}' + parsed = GeminiTextAnalyzer._parse_response(raw) + assert parsed == {"co_claim": "seer", "addressed_name": "セツ"} + + +def test_parse_response_strips_markdown_fences() -> None: + raw = '```json\n{"co_claim":null,"addressed_name":"席3"}\n```' + parsed = GeminiTextAnalyzer._parse_response(raw) + assert parsed == {"co_claim": None, "addressed_name": "席3"} + + +def test_parse_response_returns_empty_on_garbage() -> None: + parsed = GeminiTextAnalyzer._parse_response("not-json") + assert parsed == {} + + +async def test_fake_analyzer_returns_scripted_in_order() -> None: + fake = FakeTextAnalyzer( + scripted=[ + TextAnalysis(addressed_name="セツ"), + TextAnalysis(co_declaration="seer"), + ] + ) + a = await fake.analyze(text="セツさん、どう?", timeout_s=5.0) + b = await fake.analyze(text="占いCO", timeout_s=5.0) + assert a.addressed_name == "セツ" + assert b.co_declaration == "seer" + assert fake.call_count == 2 + + +async def test_fake_analyzer_falls_through_to_default() -> None: + fake = FakeTextAnalyzer(default=TextAnalysis()) + a = await fake.analyze(text="特になし", timeout_s=5.0) + assert a.addressed_name is None and a.co_declaration is None + + +async def test_fake_analyzer_records_last_text_for_assertions() -> None: + fake = FakeTextAnalyzer(default=TextAnalysis()) + await fake.analyze(text="記録される", timeout_s=5.0) + assert fake.last_text == "記録される" + + +async def test_fake_analyzer_propagates_scripted_exception() -> None: + """A scripted exception must be raised verbatim so the cog's broad + try/except logs and falls back to raw capture.""" + + class _Boom(RuntimeError): + pass + + fake = FakeTextAnalyzer(scripted=[_Boom("kaboom")]) + with pytest.raises(_Boom): + await fake.analyze(text="x", timeout_s=1.0) + + +async def test_fake_analyzer_no_default_returns_empty_analysis_when_unscripted() -> None: + """When no scripted result and no default is set, FakeTextAnalyzer must + return an empty TextAnalysis (matches a Gemini call that found nothing).""" + fake = FakeTextAnalyzer() + result = await fake.analyze(text="anything", timeout_s=1.0) + assert result == TextAnalysis() From 998433a076bfde83a91bdd1f677d946c6d1b0793 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 23:04:41 +0900 Subject: [PATCH 016/133] fix(npc): join VC only when Master picks the bot via /wolf start 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. --- src/wolfbot/domain/ws_messages.py | 25 +++ src/wolfbot/main.py | 55 ++++++ src/wolfbot/master/npc_registry.py | 27 +++ src/wolfbot/npc/client.py | 67 ++++++- src/wolfbot/npc/main.py | 59 ++++-- src/wolfbot/services/game_service.py | 19 ++ tests/test_game_service_advance.py | 30 ++++ tests/test_npc_seat_lifecycle.py | 256 +++++++++++++++++++++++++++ 8 files changed, 521 insertions(+), 17 deletions(-) create mode 100644 tests/test_npc_seat_lifecycle.py diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index 7c2cec3..3d37245 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -56,6 +56,29 @@ class NpcRegistered(BaseEnvelope): phase_id: str | None = None +class SeatAssigned(BaseEnvelope): + """Master → NPC: this NPC bot was picked for `game_id`. Join VC and + stand by for SpeakRequests. Sent at phase entry after the registry + pairs the bot with an LLM seat.""" + + type: Literal["seat_assigned"] = "seat_assigned" + npc_id: str + seat_no: int + game_id: str + phase_id: str + + +class SeatReleased(BaseEnvelope): + """Master → NPC: this NPC bot is no longer attached to a game. Leave + VC and idle until the next `seat_assigned`. Sent on game end / host + abort / explicit unassign so unselected bots don't linger in VC.""" + + type: Literal["seat_released"] = "seat_released" + npc_id: str + game_id: str | None = None + reason: str = "game_ended" + + class Heartbeat(BaseEnvelope): type: Literal["heartbeat"] = "heartbeat" npc_id: str | None = None # populated by NPC bots; None for voice-ingest @@ -261,6 +284,8 @@ class HandshakeError(BaseEnvelope): "PlaybackRejected", "RegistrySnapshot", "RegistryUpdate", + "SeatAssigned", + "SeatReleased", "SpeakRequest", "SpeakResult", "SpeechEventPayload", diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 0196593..4586873 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -110,6 +110,8 @@ async def _on_reactive_phase_enter(game_id: str) -> None: await _refresh_voice_ingest_cache(game_id) # Assign online NPC bots to their game seats so the arbiter can pick them. if _npc_registry_ref and _vc_phase_cache[0] is not None: + from wolfbot.domain.ws_messages import SeatAssigned + _game_id, phase_id = _vc_phase_cache[0] npc_reg = _npc_registry_ref[0] seats = await repo.load_seats(game_id) @@ -128,15 +130,68 @@ async def _on_reactive_phase_enter(game_id: str) -> None: game_id=game_id, phase_id=phase_id) log.info("npc_seat_assigned npc=%s seat=%d game=%s", npc_entry.npc_id, seat.seat_no, game_id) + # Tell the picked bot to join VC. Unselected NPCs receive + # nothing and stay idle (no VC). + if npc_entry.send is not None: + try: + msg = SeatAssigned( + ts=int(asyncio.get_running_loop().time() * 1000), + trace_id=f"assign-{game_id}-{seat.seat_no}", + npc_id=npc_entry.npc_id, + seat_no=seat.seat_no, + game_id=game_id, + phase_id=phase_id, + ) + await npc_entry.send(msg.model_dump_json()) + except Exception: + log.exception( + "seat_assigned_send_failed npc=%s seat=%d", + npc_entry.npc_id, + seat.seat_no, + ) if _reactive_phase_cb: await _reactive_phase_cb[0].try_dispatch_next(game_id) + async def _on_reactive_game_end(game_id: str) -> None: + """Release every NPC bot attached to this game so they leave VC. + + Called from `GameService` at natural end + host abort. Sends a + `seat_released` to each, then clears the registry's assignment + fields so the bot is available for the next /wolf start. + """ + if not _npc_registry_ref: + return + from wolfbot.domain.ws_messages import SeatReleased + + npc_reg = _npc_registry_ref[0] + attached = list(npc_reg.assigned_to_game(game_id)) + for entry in attached: + if entry.send is not None: + try: + msg = SeatReleased( + ts=int(asyncio.get_running_loop().time() * 1000), + trace_id=f"release-{game_id}-{entry.npc_id}", + npc_id=entry.npc_id, + game_id=game_id, + reason="game_ended", + ) + await entry.send(msg.model_dump_json()) + except Exception: + log.exception( + "seat_released_send_failed npc=%s game=%s", + entry.npc_id, + game_id, + ) + npc_reg.unassign(entry.npc_id) + log.info("npc_seat_unassigned npc=%s game=%s", entry.npc_id, game_id) + game_service = GameService( repo=repo, discord=discord_adapter, llm=llm_adapter, wake=registry, on_reactive_phase_enter=_on_reactive_phase_enter, + on_reactive_game_end=_on_reactive_game_end, ) discord_adapter.set_game_service(game_service) llm_adapter.set_game_service(game_service) diff --git a/src/wolfbot/master/npc_registry.py b/src/wolfbot/master/npc_registry.py index 0402693..1de8494 100644 --- a/src/wolfbot/master/npc_registry.py +++ b/src/wolfbot/master/npc_registry.py @@ -81,6 +81,19 @@ def discord_bot_user_ids(self) -> set[str]: ... def prune_offline(self, now_ms: int, timeout_ms: int) -> list[str]: ... + def assign( + self, + npc_id: str, + *, + seat: int, + game_id: str, + phase_id: str, + ) -> None: ... + + def unassign(self, npc_id: str) -> None: ... + + def assigned_to_game(self, game_id: str) -> list[NpcEntry]: ... + class InMemoryNpcRegistry: """The default registry — production and test both use this.""" @@ -201,6 +214,20 @@ def assign( entry.game_id = game_id entry.phase_id = phase_id + def unassign(self, npc_id: str) -> None: + """Clear assignment fields for one NPC (game ended / never picked).""" + entry = self._entries.get(npc_id) + if entry is None: + return + entry.assigned_seat = None + entry.game_id = None + entry.phase_id = None + + def assigned_to_game(self, game_id: str) -> list[NpcEntry]: + """All entries currently assigned to ``game_id``. Used by the + game-end hook to release every bot in one pass.""" + return [e for e in self._entries.values() if e.game_id == game_id] + # ---------------------------------------------------------- listeners def add_listener(self, fn: Callable[[set[str], set[str]], Awaitable[None]]) -> None: diff --git a/src/wolfbot/npc/client.py b/src/wolfbot/npc/client.py index 3fd75c3..5675e4d 100644 --- a/src/wolfbot/npc/client.py +++ b/src/wolfbot/npc/client.py @@ -31,6 +31,8 @@ PlaybackFailed, PlaybackFinished, PlaybackRejected, + SeatAssigned, + SeatReleased, SpeakRequest, TtsFailed, TtsFinished, @@ -83,11 +85,20 @@ class NpcClient: send: Callable[[str], Awaitable[None]] now_ms: Callable[[], int] cache: InMemoryTtsCache = field(default_factory=lambda: InMemoryTtsCache(max_entries=64)) + # VC lifecycle callbacks. Master sends `seat_assigned` only to NPC bots + # that were picked for an active game — at that moment we join VC; on + # `seat_released` (or `npc_registered` arriving with no assignment) we + # leave. Optional so tests / pure-message-loop scenarios can omit them. + on_vc_join: Callable[[], Awaitable[None]] | None = None + on_vc_leave: Callable[[], Awaitable[None]] | None = None _logic_cache: dict[str, LogicPacket] = field(default_factory=dict) _pending_playback: dict[str, _PendingForPlayback] = field(default_factory=dict) pending_authorizations: list[_AuthorizedPlayback] = field(default_factory=list) registered: bool = False + assigned_seat: int | None = None + assigned_game_id: str | None = None + assigned_phase_id: str | None = None # ---------------------------------------------------------- registration @@ -121,7 +132,11 @@ async def process_message(self, raw_json: str) -> None: return t = payload["type"] if t == "npc_registered": - self._on_registered(NpcRegistered.model_validate(payload)) + await self._on_registered(NpcRegistered.model_validate(payload)) + elif t == "seat_assigned": + await self._on_seat_assigned(SeatAssigned.model_validate(payload)) + elif t == "seat_released": + await self._on_seat_released(SeatReleased.model_validate(payload)) elif t == "logic_packet": self._on_logic_packet(LogicPacket.model_validate(payload)) elif t == "speak_request": @@ -133,14 +148,62 @@ async def process_message(self, raw_json: str) -> None: else: log.info("npc_client_unhandled_type type=%s", t) - def _on_registered(self, msg: NpcRegistered) -> None: + async def _on_registered(self, msg: NpcRegistered) -> None: self.registered = True + self.assigned_seat = msg.assigned_seat + self.assigned_game_id = msg.game_id + self.assigned_phase_id = msg.phase_id log.info( "npc_registered npc_id=%s seat=%s game=%s", msg.npc_id, msg.assigned_seat, msg.game_id, ) + # Recovery path: if Master tells us we were already assigned at + # register time (e.g. NPC bot reconnected mid-game), join VC. + if msg.assigned_seat is not None and self.on_vc_join is not None: + try: + await self.on_vc_join() + except Exception: + log.exception( + "npc_vc_join_failed_on_recovery npc_id=%s", msg.npc_id + ) + + async def _on_seat_assigned(self, msg: SeatAssigned) -> None: + self.assigned_seat = msg.seat_no + self.assigned_game_id = msg.game_id + self.assigned_phase_id = msg.phase_id + log.info( + "npc_seat_assigned npc_id=%s seat=%d game=%s", + msg.npc_id, + msg.seat_no, + msg.game_id, + ) + if self.on_vc_join is not None: + try: + await self.on_vc_join() + except Exception: + log.exception( + "npc_vc_join_failed npc_id=%s seat=%d", + msg.npc_id, + msg.seat_no, + ) + + async def _on_seat_released(self, msg: SeatReleased) -> None: + log.info( + "npc_seat_released npc_id=%s game=%s reason=%s", + msg.npc_id, + msg.game_id, + msg.reason, + ) + self.assigned_seat = None + self.assigned_game_id = None + self.assigned_phase_id = None + if self.on_vc_leave is not None: + try: + await self.on_vc_leave() + except Exception: + log.exception("npc_vc_leave_failed npc_id=%s", msg.npc_id) def _on_logic_packet(self, packet: LogicPacket) -> None: self._logic_cache[packet.packet_id] = packet diff --git a/src/wolfbot/npc/main.py b/src/wolfbot/npc/main.py index 065bfab..4e5904a 100644 --- a/src/wolfbot/npc/main.py +++ b/src/wolfbot/npc/main.py @@ -78,27 +78,54 @@ async def _main() -> None: vc_client_ref: list[discord.VoiceClient | None] = [None] ready_event = asyncio.Event() + vc_lock = asyncio.Lock() @bot.event async def on_ready() -> None: + # Don't auto-join VC. The bot stays Discord-connected + WS-registered + # so Master can pick it later via /wolf start; the actual VC join + # waits for a `seat_assigned` message from Master. This keeps + # unselected NPCs out of the voice channel until they're chosen. log.info("npc_discord_ready user=%s", bot.user) - guild = bot.get_guild(settings.DISCORD_GUILD_ID) - if guild is None: - log.error("npc_guild_not_found id=%s", settings.DISCORD_GUILD_ID) - return - vc_channel = guild.get_channel(settings.MAIN_VOICE_CHANNEL_ID) - if vc_channel is None or not isinstance(vc_channel, discord.VoiceChannel): - log.error("npc_vc_channel_not_found id=%s", - settings.MAIN_VOICE_CHANNEL_ID) - return - try: - vc_client_ref[0] = await vc_channel.connect() - log.info("npc_vc_joined channel=%s", settings.MAIN_VOICE_CHANNEL_ID) - except Exception: - log.exception("npc_vc_join_failed channel=%s", - settings.MAIN_VOICE_CHANNEL_ID) ready_event.set() + async def _ensure_vc_joined() -> None: + async with vc_lock: + existing = vc_client_ref[0] + if existing is not None and existing.is_connected(): + return + guild = bot.get_guild(settings.DISCORD_GUILD_ID) + if guild is None: + log.error("npc_guild_not_found id=%s", settings.DISCORD_GUILD_ID) + return + vc_channel = guild.get_channel(settings.MAIN_VOICE_CHANNEL_ID) + if vc_channel is None or not isinstance(vc_channel, discord.VoiceChannel): + log.error( + "npc_vc_channel_not_found id=%s", settings.MAIN_VOICE_CHANNEL_ID + ) + return + try: + vc_client_ref[0] = await vc_channel.connect() + log.info("npc_vc_joined channel=%s", settings.MAIN_VOICE_CHANNEL_ID) + except Exception: + log.exception( + "npc_vc_join_failed channel=%s", settings.MAIN_VOICE_CHANNEL_ID + ) + + async def _ensure_vc_left() -> None: + async with vc_lock: + vc = vc_client_ref[0] + if vc is None: + return + try: + if vc.is_connected(): + await vc.disconnect() + log.info("npc_vc_left channel=%s", settings.MAIN_VOICE_CHANNEL_ID) + except Exception: + log.exception("npc_vc_leave_failed") + finally: + vc_client_ref[0] = None + # Start Discord in background discord_task = asyncio.create_task( bot.start(settings.NPC_DISCORD_TOKEN.get_secret_value())) @@ -182,6 +209,8 @@ async def _ws_send(msg: str) -> None: playback=playback, send=_ws_send, now_ms=_now_ms, + on_vc_join=_ensure_vc_joined, + on_vc_leave=_ensure_vc_left, ) # Register with Master diff --git a/src/wolfbot/services/game_service.py b/src/wolfbot/services/game_service.py index 1f210db..916c941 100644 --- a/src/wolfbot/services/game_service.py +++ b/src/wolfbot/services/game_service.py @@ -144,6 +144,7 @@ def __init__( clock: Callable[[], int] = lambda: int(time.time()), rng: Random | None = None, on_reactive_phase_enter: Callable[[str], Awaitable[None]] | None = None, + on_reactive_game_end: Callable[[str], Awaitable[None]] | None = None, ) -> None: self.repo = repo self.discord = discord @@ -153,6 +154,10 @@ def __init__( self.rng = rng or Random() self._advance_locks: dict[str, asyncio.Lock] = {} self._on_reactive_phase_enter = on_reactive_phase_enter + # Symmetric to `on_reactive_phase_enter`: invoked at natural end and + # host abort so reactive_voice plumbing (NPC seat assignments, VC + # joins) can be released. + self._on_reactive_game_end = on_reactive_game_end def _lock_for(self, game_id: str) -> asyncio.Lock: lock = self._advance_locks.get(game_id) @@ -261,6 +266,13 @@ async def _advance_once(self, game_id: str) -> None: await self.discord.on_game_end(new_game, seats) except Exception: log.exception("on_game_end failed for %s", game_id) + if self._on_reactive_game_end is not None: + try: + await self._on_reactive_game_end(game_id) + except Exception: + log.exception( + "on_reactive_game_end failed for %s", game_id + ) await self.repo.end_game(game_id, ended_at_epoch=self.clock()) # 7. Wake the engine so it reschedules on the new deadline. @@ -990,6 +1002,13 @@ async def host_abort(self, game_id: str) -> bool: await self.discord.on_game_end(game, seats) except Exception: log.exception("on_game_end failed during abort %s", game_id) + if self._on_reactive_game_end is not None: + try: + await self._on_reactive_game_end(game_id) + except Exception: + log.exception( + "on_reactive_game_end failed during abort %s", game_id + ) await self.repo.end_game(game_id, ended_at_epoch=self.clock()) self.wake.wake(game_id) return True diff --git a/tests/test_game_service_advance.py b/tests/test_game_service_advance.py index 2d57192..b60fada 100644 --- a/tests/test_game_service_advance.py +++ b/tests/test_game_service_advance.py @@ -585,6 +585,36 @@ async def test_host_abort_ends_game( assert any(c.name == "on_game_end" for c in disc.calls) +async def test_host_abort_invokes_on_reactive_game_end_callback( + repo: SqliteRepo, +) -> None: + """The reactive_voice plumbing release path must fire on host_abort so + NPC bots leave VC. Mirrors `discord.on_game_end` but on a separate hook + so reactive_voice plumbing stays out of the GameService core.""" + disc = FakeDiscordAdapter() + llm = FakeLLMAdapter() + reg = EngineRegistry() + fired: list[str] = [] + + async def on_end(game_id: str) -> None: + fired.append(game_id) + + service = GameService( + repo=repo, + discord=disc, + llm=llm, + wake=reg, + rng=random.Random(0), + on_reactive_game_end=on_end, + ) + game = await _make_game_in_setup(repo) + await service.advance(game.id) + + ok = await service.host_abort(game.id) + assert ok + assert fired == [game.id] + + async def test_host_abort_returns_false_when_already_ended( repo: SqliteRepo, svc: tuple[GameService, FakeDiscordAdapter, FakeLLMAdapter, EngineRegistry, FakeClock], diff --git a/tests/test_npc_seat_lifecycle.py b/tests/test_npc_seat_lifecycle.py new file mode 100644 index 0000000..66599ab --- /dev/null +++ b/tests/test_npc_seat_lifecycle.py @@ -0,0 +1,256 @@ +"""NPC bot seat lifecycle — verify the assignment / release plumbing. + +Goal: NPC bots no longer auto-join VC at startup. They idle (Discord + +WS connected) until Master picks them via `/wolf start` and sends a +`seat_assigned`; at game end Master sends `seat_released` and the bot +leaves VC. This file pins down each piece of that contract. + +Covers: +- `NpcClient` invokes its `on_vc_join` callback when Master sends + `seat_assigned`, and `on_vc_leave` when `seat_released` arrives. +- A reconnecting NPC whose registration reply already names a seat + still triggers `on_vc_join` (recovery path). +- `npc_registered` with `assigned_seat=None` does NOT trigger join. +- `NpcRegistry.unassign` clears the entry's assignment fields. +- `NpcRegistry.assigned_to_game` returns only entries currently + assigned to the given game id (used by Master's release sweep). +- `seat_assigned` / `seat_released` round-trip through the + Pydantic envelope. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from wolfbot.domain.ws_messages import ( + NpcRegistered, + SeatAssigned, + SeatReleased, +) +from wolfbot.master.npc_registry import InMemoryNpcRegistry +from wolfbot.npc.client import NpcClient, NpcClientConfig +from wolfbot.npc.playback import FakeVoicePlayback +from wolfbot.npc.speech_service import FakeNpcGenerator, NpcSpeechService +from wolfbot.npc.tts import FakeTtsService + + +@dataclass +class _VcCallbackProbe: + join_calls: int = 0 + leave_calls: int = 0 + join_log: list[str] = field(default_factory=list) + leave_log: list[str] = field(default_factory=list) + + async def on_join(self) -> None: + self.join_calls += 1 + self.join_log.append("join") + + async def on_leave(self) -> None: + self.leave_calls += 1 + self.leave_log.append("leave") + + +def _make_client(probe: _VcCallbackProbe) -> NpcClient: + captured: list[str] = [] + + async def send(msg: str) -> None: + captured.append(msg) + + return NpcClient( + config=NpcClientConfig( + npc_id="npc_setsu", + discord_bot_user_id="bot2", + persona_key="setsu", + voice_id="vox-1", + ), + speech=NpcSpeechService(FakeNpcGenerator()), + tts=FakeTtsService(), + playback=FakeVoicePlayback(), + send=send, + now_ms=lambda: 1, + on_vc_join=probe.on_join, + on_vc_leave=probe.on_leave, + ) + + +# ------------------------------------------------------ NpcClient lifecycle + + +async def test_seat_assigned_triggers_vc_join() -> None: + probe = _VcCallbackProbe() + client = _make_client(probe) + msg = SeatAssigned( + ts=1, + trace_id="t", + npc_id="npc_setsu", + seat_no=2, + game_id="g1", + phase_id="g1::day1::DAY_DISCUSSION::1", + ) + await client.process_message(msg.model_dump_json()) + assert probe.join_calls == 1 + assert probe.leave_calls == 0 + assert client.assigned_seat == 2 + assert client.assigned_game_id == "g1" + + +async def test_seat_released_triggers_vc_leave_and_clears_state() -> None: + probe = _VcCallbackProbe() + client = _make_client(probe) + # Pre-assign so leave has something to clear. + await client.process_message( + SeatAssigned( + ts=1, trace_id="t", npc_id="npc_setsu", seat_no=2, + game_id="g1", phase_id="g1::day1::DAY_DISCUSSION::1", + ).model_dump_json() + ) + assert client.assigned_seat == 2 + + await client.process_message( + SeatReleased( + ts=2, trace_id="t", npc_id="npc_setsu", game_id="g1", + reason="game_ended", + ).model_dump_json() + ) + assert probe.leave_calls == 1 + assert client.assigned_seat is None + assert client.assigned_game_id is None + assert client.assigned_phase_id is None + + +async def test_npc_registered_without_seat_does_not_join_vc() -> None: + """Default startup case: NPC bot registers, Master replies with no seat. + The bot must NOT auto-join VC — it idles until /wolf start picks it.""" + probe = _VcCallbackProbe() + client = _make_client(probe) + msg = NpcRegistered(ts=1, trace_id="t", npc_id="npc_setsu") + await client.process_message(msg.model_dump_json()) + assert probe.join_calls == 0 + assert probe.leave_calls == 0 + assert client.registered is True + assert client.assigned_seat is None + + +async def test_npc_registered_with_assigned_seat_triggers_recovery_join() -> None: + """Reconnect mid-game: Master tells the bot it's already assigned, and + the bot rejoins VC without waiting for a fresh seat_assigned.""" + probe = _VcCallbackProbe() + client = _make_client(probe) + msg = NpcRegistered( + ts=1, + trace_id="t", + npc_id="npc_setsu", + assigned_seat=3, + game_id="g1", + phase_id="g1::day1::DAY_DISCUSSION::1", + ) + await client.process_message(msg.model_dump_json()) + assert probe.join_calls == 1 + assert client.assigned_seat == 3 + + +async def test_seat_assigned_join_failure_does_not_propagate() -> None: + """A discord.connect() failure must be logged but never crash the + message loop — the WS stays alive so Master can retry.""" + + class _Boom(_VcCallbackProbe): + async def on_join(self) -> None: + self.join_calls += 1 + raise RuntimeError("vc unavailable") + + probe = _Boom() + client = _make_client(probe) + msg = SeatAssigned( + ts=1, trace_id="t", npc_id="npc_setsu", seat_no=2, + game_id="g1", phase_id="g1::day1::DAY_DISCUSSION::1", + ) + # Must NOT raise. + await client.process_message(msg.model_dump_json()) + assert probe.join_calls == 1 + # State still updated even though VC join failed — Master treats the + # bot as assigned and the next reconnect will retry the join via + # the recovery path. + assert client.assigned_seat == 2 + + +# ------------------------------------------------------ NpcRegistry helpers + + +def test_registry_unassign_clears_fields() -> None: + reg = InMemoryNpcRegistry() + reg.register( + npc_id="npc_setsu", + discord_bot_user_id="bot2", + persona_key="setsu", + supported_voices=(), + version="1", + send=None, + now_ms=1, + ) + reg.assign("npc_setsu", seat=2, game_id="g1", phase_id="ph1") + entry = reg.get("npc_setsu") + assert entry is not None and entry.assigned_seat == 2 + + reg.unassign("npc_setsu") + entry = reg.get("npc_setsu") + assert entry is not None + assert entry.assigned_seat is None + assert entry.game_id is None + assert entry.phase_id is None + + +def test_registry_assigned_to_game_filters_correctly() -> None: + reg = InMemoryNpcRegistry() + for idx, key in enumerate(["setsu", "gina", "sq"], start=1): + reg.register( + npc_id=f"npc_{key}", + discord_bot_user_id=f"bot{idx}", + persona_key=key, + supported_voices=(), + version="1", + send=None, + now_ms=1, + ) + reg.assign("npc_setsu", seat=2, game_id="g1", phase_id="ph1") + reg.assign("npc_gina", seat=3, game_id="g1", phase_id="ph1") + reg.assign("npc_sq", seat=4, game_id="g2", phase_id="ph2") + + g1 = {e.npc_id for e in reg.assigned_to_game("g1")} + g2 = {e.npc_id for e in reg.assigned_to_game("g2")} + g3 = list(reg.assigned_to_game("g3-nonexistent")) + + assert g1 == {"npc_setsu", "npc_gina"} + assert g2 == {"npc_sq"} + assert g3 == [] + + +def test_registry_unassign_unknown_id_is_silent() -> None: + reg = InMemoryNpcRegistry() + # Must not raise on a missing id. + reg.unassign("never-registered") + + +# ------------------------------------------------------ wire round-trip + + +def test_seat_assigned_pydantic_round_trip() -> None: + msg = SeatAssigned( + ts=1, + trace_id="t", + npc_id="npc_setsu", + seat_no=2, + game_id="g1", + phase_id="ph", + ) + out = SeatAssigned.model_validate_json(msg.model_dump_json()) + assert out.npc_id == "npc_setsu" + assert out.seat_no == 2 + assert out.game_id == "g1" + + +def test_seat_released_pydantic_round_trip_default_reason() -> None: + msg = SeatReleased(ts=1, trace_id="t", npc_id="npc_setsu") + out = SeatReleased.model_validate_json(msg.model_dump_json()) + assert out.npc_id == "npc_setsu" + assert out.reason == "game_ended" + assert out.game_id is None From 539b041754d59d9b1149b76707a5c077d025390c Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 23:30:05 +0900 Subject: [PATCH 017/133] fix(master): join VC only when /wolf start fires reactive_voice 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. --- src/wolfbot/main.py | 125 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 103 insertions(+), 22 deletions(-) diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 4586873..9bb0384 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -106,8 +106,91 @@ async def _refresh_voice_ingest_cache(game_id: str) -> None: if s.discord_user_id and any(p.seat_no == s.seat_no and p.alive for p in players): _vc_seat_map[s.discord_user_id] = s.seat_no + # ---- Master VC join lifecycle --------------------------------------- + # Single VC connection; one Master = one guild = at most one active + # reactive_voice game at a time. Held in a list so closures can rebind. + master_vc_ref: list[Any] = [None] + master_vc_lock = asyncio.Lock() + + async def _master_join_vc_for_game(game: Any) -> None: + """Join the game's VC if reactive_voice + voice_ingest is active. + + Idempotent: returns immediately when the existing connection is + still healthy. Called from `_on_reactive_phase_enter` and from + recovery so a Master restart mid-game re-joins. In rounds mode + Master never joins VC at all. + """ + if voice_ingest is None: + return + if getattr(game, "discussion_mode", None) != "reactive_voice": + return + async with master_vc_lock: + existing = master_vc_ref[0] + if existing is not None: + try: + if existing.is_connected(): + return + except Exception: + pass + master_vc_ref[0] = None + from discord.ext import voice_recv + + from wolfbot.master.audio_sink import WolfbotAudioSink + + try: + channel_id = int(game.main_vc_channel_id) + except (TypeError, ValueError): + log.warning( + "master_vc_channel_id_invalid game=%s value=%r", + game.id, + game.main_vc_channel_id, + ) + return + vc_channel = bot.get_channel(channel_id) + if vc_channel is None or not isinstance(vc_channel, discord.VoiceChannel): + log.warning( + "master_vc_channel_not_found id=%s", channel_id + ) + return + try: + vc_client = await vc_channel.connect(cls=voice_recv.VoiceRecvClient) + sink = WolfbotAudioSink( + voice_ingest, loop=asyncio.get_running_loop() + ) + vc_client.listen(sink) + master_vc_ref[0] = vc_client + log.info( + "master_vc_joined channel=%s game=%s", channel_id, game.id + ) + except Exception: + log.warning( + "master_vc_join_failed channel=%s", + channel_id, + exc_info=True, + ) + + async def _master_leave_vc() -> None: + """Disconnect Master from VC. Idempotent.""" + async with master_vc_lock: + vc = master_vc_ref[0] + if vc is None: + return + try: + if vc.is_connected(): + await vc.disconnect() + log.info("master_vc_left") + except Exception: + log.exception("master_vc_leave_failed") + finally: + master_vc_ref[0] = None + async def _on_reactive_phase_enter(game_id: str) -> None: await _refresh_voice_ingest_cache(game_id) + # Master joins the game's VC the first time the game enters a + # public-speech phase. Idempotent so re-entries are no-ops. + g_for_vc = await repo.load_game(game_id) + if g_for_vc is not None: + await _master_join_vc_for_game(g_for_vc) # Assign online NPC bots to their game seats so the arbiter can pick them. if _npc_registry_ref and _vc_phase_cache[0] is not None: from wolfbot.domain.ws_messages import SeatAssigned @@ -184,6 +267,9 @@ async def _on_reactive_game_end(game_id: str) -> None: ) npc_reg.unassign(entry.npc_id) log.info("npc_seat_unassigned npc=%s game=%s", entry.npc_id, game_id) + # Drop Master's own VC connection too — keeps the bot out of the + # voice channel between games. Reattaches at the next /wolf start. + await _master_leave_vc() game_service = GameService( repo=repo, @@ -268,7 +354,18 @@ async def _on_speech_recorded(game_id: str) -> None: ) _reactive_phase_cb.append(arbiter) recovery._reactive_voice_sweep = arbiter.reactive_voice_recovery_sweep - recovery._reactive_voice_reenter = arbiter.try_dispatch_next + + async def _reactive_voice_reenter(game_id: str) -> None: + # On Master restart, reactive_voice games still in + # DAY_DISCUSSION need their VC joined again before the + # arbiter starts dispatching. `_master_join_vc_for_game` is + # idempotent so non-reactive_voice games are a no-op. + g = await repo.load_game(game_id) + if g is not None: + await _master_join_vc_for_game(g) + await arbiter.try_dispatch_next(game_id) + + recovery._reactive_voice_reenter = _reactive_voice_reenter class _RepoPhase: """Adapts SqliteRepo to MasterIngestService.PhaseLookup.""" @@ -486,27 +583,11 @@ async def on_ready() -> None: await ws_server.start() log.info("master WS server started on %s", settings.MASTER_WS_LISTEN) - # Join VC and start listening via discord-ext-voice-recv AudioSink. - if voice_ingest is not None: - from discord.ext import voice_recv - - from wolfbot.master.audio_sink import WolfbotAudioSink - - vc_channel = bot.get_channel(settings.MAIN_VOICE_CHANNEL_ID) - if vc_channel is not None and isinstance(vc_channel, discord.VoiceChannel): - try: - vc_client = await vc_channel.connect(cls=voice_recv.VoiceRecvClient) - sink = WolfbotAudioSink( - voice_ingest, loop=asyncio.get_running_loop()) - vc_client.listen(sink) - log.info("master_vc_joined channel=%s, audio_sink active", - settings.MAIN_VOICE_CHANNEL_ID) - except Exception: - log.warning("master_vc_join_failed channel=%s", - settings.MAIN_VOICE_CHANNEL_ID, exc_info=True) - else: - log.warning("voice_channel_not_found id=%s", - settings.MAIN_VOICE_CHANNEL_ID) + # Master no longer auto-joins VC at startup. Joining is deferred + # to `_on_reactive_phase_enter` (the first public-speech phase of + # a reactive_voice game) and to recovery for in-flight games. + # Hosts who want Master in a non-active VC still rely on starting + # a game; rounds-mode games never need VC. recovered = await recovery.recover_all() log.info("recovered %d game(s)", len(recovered)) From 10d4a9f4063743258813d340fe8a3f5205f7f554 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Mon, 27 Apr 2026 23:30:22 +0900 Subject: [PATCH 018/133] feat(settings): /wolf settings View for phase durations (host-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/services/discord_service.py | 31 +++ src/wolfbot/ui/settings_view.py | 265 ++++++++++++++++++++++++ tests/test_settings_view.py | 179 ++++++++++++++++ 3 files changed, 475 insertions(+) create mode 100644 src/wolfbot/ui/settings_view.py create mode 100644 tests/test_settings_view.py diff --git a/src/wolfbot/services/discord_service.py b/src/wolfbot/services/discord_service.py index 227e96e..beb4b88 100644 --- a/src/wolfbot/services/discord_service.py +++ b/src/wolfbot/services/discord_service.py @@ -1014,6 +1014,37 @@ async def abort(self, interaction: discord.Interaction) -> None: ephemeral=True, ) + @wolf.command( + name="settings", + description="フェイズ時間などの設定をホスト用 UI で調整", + ) + async def settings_command(self, interaction: discord.Interaction) -> None: + if interaction.guild is None: + await interaction.response.send_message( + "ギルド内で実行してください。", ephemeral=True + ) + return + game = await self.repo.load_active_game_for_guild( + str(interaction.guild_id) + ) + if game is None: + await interaction.response.send_message( + "進行中のゲームがありません。`/wolf create` で作成してから設定してください。", + ephemeral=True, + ) + return + if str(interaction.user.id) != game.host_user_id: + await interaction.response.send_message( + "設定はホストのみが変更できます。", ephemeral=True + ) + return + from wolfbot.ui.settings_view import render_initial_message + + embed, view = render_initial_message(host_user_id=game.host_user_id) + await interaction.response.send_message( + embed=embed, view=view, ephemeral=True + ) + # ----------------------------------------------------------- internals async def _host_check(self, interaction: discord.Interaction) -> Game | None: if interaction.guild is None: diff --git a/src/wolfbot/ui/settings_view.py b/src/wolfbot/ui/settings_view.py new file mode 100644 index 0000000..63aa43b --- /dev/null +++ b/src/wolfbot/ui/settings_view.py @@ -0,0 +1,265 @@ +"""`/wolf settings` interactive UI for runtime-mutable phase durations. + +Permission model: only the host of the currently-active game can open the +view, and `interaction_check` re-validates on every nested interaction so +a passed-around ephemeral message still rejects non-host clicks. + +Persistence model: process memory only — `set_phase_durations` swaps the +singleton in `wolfbot.domain.durations`. A Master restart resets to env- +derived defaults. This matches the user's choice of "no DB" for v1; if +persistence is added later, the View hooks stay the same and only the +write path needs to also persist. + +Components: +- ``PhaseDurationsView``: top-level view with a per-field Select, a + global-factor Button, and a "reset to defaults" Button. +- ``_DurationEditModal``: 1-field modal opened by the Select for fine- + grained edits (current value pre-filled). +- ``_FactorModal``: 1-field modal for the with_factor bulk knob. +""" + +from __future__ import annotations + +import logging +from dataclasses import replace + +import discord + +from wolfbot.domain.durations import ( + PhaseDurations, + current_phase_durations, + reset_phase_durations_to_defaults, + set_phase_durations, +) + +log = logging.getLogger(__name__) + + +_DURATION_FIELD_LABELS: tuple[tuple[str, str], ...] = ( + ("vote", "投票"), + ("runoff", "決選投票"), + ("night", "夜"), + ("day_discussion_grace", "議論猶予"), + ("runoff_speech_grace", "決選演説猶予"), + ("discussion_day1", "議論 (1日目)"), + ("discussion_day2", "議論 (2日目)"), + ("discussion_day3plus", "議論 (3日目以降)"), +) + + +def _format_durations_embed(d: PhaseDurations) -> discord.Embed: + embed = discord.Embed( + title="⚙️ Phase Durations", + description=( + "プロセスメモリ上で設定が反映されます (Master 再起動でデフォルトに戻ります)。\n" + "現在の値を選択肢から編集するか、ボタンで一括変更できます。" + ), + color=discord.Color.blurple(), + ) + for field_name, label in _DURATION_FIELD_LABELS: + embed.add_field( + name=f"{label} ({field_name})", + value=f"`{getattr(d, field_name)}` 秒", + inline=True, + ) + return embed + + +class _DurationEditModal(discord.ui.Modal): + """Single-field modal that updates one PhaseDurations attribute.""" + + def __init__( + self, + *, + field_name: str, + label: str, + current_value: int, + parent: PhaseDurationsView, + ) -> None: + super().__init__(title=f"{label} を変更", timeout=180) + self._field_name = field_name + self._parent = parent + self.value_input: discord.ui.TextInput[_DurationEditModal] = discord.ui.TextInput( + label=f"{label} (秒、整数 ≥1)", + default=str(current_value), + required=True, + max_length=8, + ) + self.add_item(self.value_input) + + async def on_submit(self, interaction: discord.Interaction) -> None: + raw = self.value_input.value.strip() + try: + value = int(raw) + except ValueError: + await interaction.response.send_message( + f"整数で入力してください (`{raw}` は不正)。", ephemeral=True + ) + return + if value < 1: + await interaction.response.send_message( + "1 秒以上の値を指定してください。", ephemeral=True + ) + return + new_durations = replace(current_phase_durations(), **{self._field_name: value}) + set_phase_durations(new_durations) + log.info( + "phase_duration_updated field=%s value=%d by_user=%s", + self._field_name, + value, + interaction.user.id, + ) + await self._parent.refresh(interaction, new_durations) + + +class _FactorModal(discord.ui.Modal): + """Modal for the bulk `with_factor` knob.""" + + def __init__(self, *, parent: PhaseDurationsView) -> None: + super().__init__(title="全フェイズを一括スケール", timeout=180) + self._parent = parent + self.factor_input: discord.ui.TextInput[_FactorModal] = discord.ui.TextInput( + label="スケール係数 (例 0.5 で半分、2.0 で倍)", + default="1.0", + required=True, + max_length=8, + ) + self.add_item(self.factor_input) + + async def on_submit(self, interaction: discord.Interaction) -> None: + raw = self.factor_input.value.strip() + try: + factor = float(raw) + except ValueError: + await interaction.response.send_message( + f"数値で入力してください (`{raw}` は不正)。", ephemeral=True + ) + return + if factor <= 0: + await interaction.response.send_message( + "0 より大きい値を指定してください。", ephemeral=True + ) + return + new_durations = current_phase_durations().with_factor(factor) + set_phase_durations(new_durations) + log.info( + "phase_durations_scaled factor=%s by_user=%s", + factor, + interaction.user.id, + ) + await self._parent.refresh(interaction, new_durations) + + +class _DurationFieldSelect(discord.ui.Select["PhaseDurationsView"]): + """Select dropdown listing each duration field with its current value.""" + + def __init__(self, current: PhaseDurations) -> None: + options = [ + discord.SelectOption( + label=f"{label}", + value=field_name, + description=f"現在 {getattr(current, field_name)} 秒", + ) + for field_name, label in _DURATION_FIELD_LABELS + ] + super().__init__( + placeholder="編集する項目を選択…", + min_values=1, + max_values=1, + options=options, + ) + + async def callback(self, interaction: discord.Interaction) -> None: + assert self.view is not None + field_name = self.values[0] + label = next( + (lbl for fn, lbl in _DURATION_FIELD_LABELS if fn == field_name), + field_name, + ) + modal = _DurationEditModal( + field_name=field_name, + label=label, + current_value=getattr(current_phase_durations(), field_name), + parent=self.view, + ) + await interaction.response.send_modal(modal) + + +class PhaseDurationsView(discord.ui.View): + """Top-level View backing `/wolf settings`. + + Restricted to ``host_user_id`` — every interaction is gated through + ``interaction_check`` so the ephemeral message can't be exploited + even if its token leaks. The original slash command handler is also + expected to do the same check before posting the View; this is the + second line of defense. + """ + + def __init__(self, *, host_user_id: str, timeout: float = 600.0) -> None: + super().__init__(timeout=timeout) + self._host_user_id = host_user_id + self._select = _DurationFieldSelect(current_phase_durations()) + self.add_item(self._select) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if str(interaction.user.id) != self._host_user_id: + await interaction.response.send_message( + "この設定パネルはホスト専用です。", ephemeral=True + ) + return False + return True + + async def refresh( + self, interaction: discord.Interaction, new_durations: PhaseDurations + ) -> None: + """Re-render the embed + select after a value changed.""" + # Rebuild the select so its option descriptions reflect the new + # current values (Discord won't re-query a Select's options). + self.remove_item(self._select) + self._select = _DurationFieldSelect(new_durations) + self.add_item(self._select) + embed = _format_durations_embed(new_durations) + await interaction.response.edit_message(embed=embed, view=self) + + @discord.ui.button( + label="一括スケール…", + style=discord.ButtonStyle.secondary, + custom_id="wolfbot:settings:factor", + ) + async def factor_button( + self, + interaction: discord.Interaction, + _button: discord.ui.Button[PhaseDurationsView], + ) -> None: + await interaction.response.send_modal(_FactorModal(parent=self)) + + @discord.ui.button( + label="デフォルトに戻す", + style=discord.ButtonStyle.danger, + custom_id="wolfbot:settings:reset", + ) + async def reset_button( + self, + interaction: discord.Interaction, + _button: discord.ui.Button[PhaseDurationsView], + ) -> None: + reset_phase_durations_to_defaults() + log.info( + "phase_durations_reset_to_defaults by_user=%s", interaction.user.id + ) + await self.refresh(interaction, current_phase_durations()) + + +def render_initial_message( + *, host_user_id: str +) -> tuple[discord.Embed, PhaseDurationsView]: + """Build the embed + view used by the `/wolf settings` slash command.""" + view = PhaseDurationsView(host_user_id=host_user_id) + embed = _format_durations_embed(current_phase_durations()) + return embed, view + + +__all__ = [ + "PhaseDurationsView", + "render_initial_message", +] diff --git a/tests/test_settings_view.py b/tests/test_settings_view.py new file mode 100644 index 0000000..b13ca56 --- /dev/null +++ b/tests/test_settings_view.py @@ -0,0 +1,179 @@ +"""`/wolf settings` interactive view — unit coverage. + +The view itself is a discord.ui composition; we exercise the embed + view +construction, the duration mutation logic, and the interaction guard. We +mock just enough of `discord.Interaction` to test the logic without +spinning up the bot. +""" + +from __future__ import annotations + +from dataclasses import replace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from wolfbot.domain.durations import ( + PhaseDurations, + current_phase_durations, + reset_phase_durations_to_defaults, + set_phase_durations, +) +from wolfbot.ui.settings_view import ( + _DURATION_FIELD_LABELS, + PhaseDurationsView, + render_initial_message, +) + + +@pytest.fixture(autouse=True) +def _reset_durations() -> None: + """Each test starts from clean defaults — the singleton is process- + global, so leakage across tests would otherwise compound.""" + reset_phase_durations_to_defaults() + + +def test_render_initial_message_lists_all_fields() -> None: + embed, view = render_initial_message(host_user_id="host1") + assert isinstance(view, PhaseDurationsView) + field_names = {f.name for f in embed.fields} + for fn, label in _DURATION_FIELD_LABELS: + # Each line is "label (field_name)" — both substrings must appear. + assert any(label in name and fn in name for name in field_names), fn + + +def test_render_initial_message_reflects_current_singleton() -> None: + set_phase_durations(replace(current_phase_durations(), vote=42)) + embed, _view = render_initial_message(host_user_id="host1") + vote_field = next( + f for f in embed.fields if "vote" in f.name + ) + assert "42" in vote_field.value + + +async def test_interaction_check_rejects_non_host() -> None: + view = PhaseDurationsView(host_user_id="host1") + interaction = MagicMock() + interaction.user.id = 9999 # not the host + interaction.response.send_message = AsyncMock() + ok = await view.interaction_check(interaction) + assert ok is False + interaction.response.send_message.assert_called_once() + _args, kwargs = interaction.response.send_message.call_args + assert kwargs.get("ephemeral") is True + + +async def test_interaction_check_accepts_host() -> None: + view = PhaseDurationsView(host_user_id="host1") + interaction = MagicMock() + interaction.user.id = "host1" # may arrive as int or str depending on call site + interaction.response.send_message = AsyncMock() + ok = await view.interaction_check(interaction) + assert ok is True + interaction.response.send_message.assert_not_called() + + +async def test_refresh_updates_singleton_via_modal_submit() -> None: + """Submitting an integer through the duration edit modal must swap + the singleton and re-render the embed.""" + from wolfbot.ui.settings_view import _DurationEditModal + + view = PhaseDurationsView(host_user_id="host1") + modal = _DurationEditModal( + field_name="vote", + label="投票", + current_value=current_phase_durations().vote, + parent=view, + ) + modal.value_input = MagicMock() + modal.value_input.value = "45" + interaction = MagicMock() + interaction.user.id = "host1" + interaction.response.send_message = AsyncMock() + interaction.response.edit_message = AsyncMock() + await modal.on_submit(interaction) + assert current_phase_durations().vote == 45 + interaction.response.edit_message.assert_called_once() + + +async def test_refresh_rejects_non_integer() -> None: + from wolfbot.ui.settings_view import _DurationEditModal + + view = PhaseDurationsView(host_user_id="host1") + modal = _DurationEditModal( + field_name="vote", + label="投票", + current_value=60, + parent=view, + ) + modal.value_input = MagicMock() + modal.value_input.value = "not-a-number" + interaction = MagicMock() + interaction.user.id = "host1" + interaction.response.send_message = AsyncMock() + interaction.response.edit_message = AsyncMock() + await modal.on_submit(interaction) + # Singleton must not have changed. + assert current_phase_durations().vote == 60 + interaction.response.send_message.assert_called_once() + interaction.response.edit_message.assert_not_called() + + +async def test_refresh_rejects_zero_or_negative() -> None: + from wolfbot.ui.settings_view import _DurationEditModal + + view = PhaseDurationsView(host_user_id="host1") + modal = _DurationEditModal( + field_name="vote", + label="投票", + current_value=60, + parent=view, + ) + modal.value_input = MagicMock() + modal.value_input.value = "0" + interaction = MagicMock() + interaction.user.id = "host1" + interaction.response.send_message = AsyncMock() + interaction.response.edit_message = AsyncMock() + await modal.on_submit(interaction) + assert current_phase_durations().vote == 60 + interaction.response.send_message.assert_called_once() + + +async def test_factor_modal_scales_all_fields() -> None: + from wolfbot.ui.settings_view import _FactorModal + + set_phase_durations(PhaseDurations()) # known starting point + before = current_phase_durations() + view = PhaseDurationsView(host_user_id="host1") + modal = _FactorModal(parent=view) + modal.factor_input = MagicMock() + modal.factor_input.value = "0.5" + interaction = MagicMock() + interaction.user.id = "host1" + interaction.response.send_message = AsyncMock() + interaction.response.edit_message = AsyncMock() + await modal.on_submit(interaction) + after = current_phase_durations() + # Every field is halved (with the floor of 1). + assert after.vote == max(1, round(before.vote * 0.5)) + assert after.night == max(1, round(before.night * 0.5)) + assert after.discussion_day1 == max(1, round(before.discussion_day1 * 0.5)) + + +async def test_factor_modal_rejects_invalid_factor() -> None: + from wolfbot.ui.settings_view import _FactorModal + + set_phase_durations(PhaseDurations()) + before_vote = current_phase_durations().vote + view = PhaseDurationsView(host_user_id="host1") + modal = _FactorModal(parent=view) + modal.factor_input = MagicMock() + modal.factor_input.value = "0" + interaction = MagicMock() + interaction.user.id = "host1" + interaction.response.send_message = AsyncMock() + interaction.response.edit_message = AsyncMock() + await modal.on_submit(interaction) + assert current_phase_durations().vote == before_vote + interaction.response.send_message.assert_called_once() From eb622cf2725c3e3aedef51fdf87a4b9066012c39 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 00:07:02 +0900 Subject: [PATCH 019/133] feat(master): Levi-style TTS narration in VC + phase_baseline seed fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/wolfbot/config.py | 11 + src/wolfbot/main.py | 164 +++++++++++++++ src/wolfbot/master/narration.py | 269 ++++++++++++++++++++++++ src/wolfbot/master/personas.py | 129 ++++-------- src/wolfbot/master/tts_playback.py | 182 ++++++++++++++++ src/wolfbot/services/discord_service.py | 38 ++++ tests/test_master_narration.py | 251 ++++++++++++++++++++++ 7 files changed, 961 insertions(+), 83 deletions(-) create mode 100644 src/wolfbot/master/narration.py create mode 100644 src/wolfbot/master/tts_playback.py create mode 100644 tests/test_master_narration.py diff --git a/src/wolfbot/config.py b/src/wolfbot/config.py index f6027d2..28604f6 100644 --- a/src/wolfbot/config.py +++ b/src/wolfbot/config.py @@ -80,6 +80,17 @@ class MasterSettings(BaseSettings): VOICE_LLM_API_KEY: SecretStr | None = None VOICE_LLM_MODEL: str = "gemini-2.0-flash-lite" + # ── Master TTS narration (reactive_voice only) ──────────────────── + # When `LLM_DISCUSSION_MODE=reactive_voice` and Master is in VC, + # phase-transition announcements (PHASE_CHANGE / MORNING / VICTORY + # / EXECUTION headlines) are read aloud by Master via VOICEVOX. Long + # content (vote tallies, role reveal) goes to the VC's text chat. + # Default speaker 47 = ナースロボ_タイプT (machine-like polite). + MASTER_TTS_VOICE_ID: int = 47 + # Reuse the NPC-side VOICEVOX HTTP engine. Single shared local engine + # is the typical setup; override only if Master needs a different one. + MASTER_VOICEVOX_URL: str = "http://localhost:50021" + @model_validator(mode="after") def _require_gameplay_provider_key(self) -> MasterSettings: if ( diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 9bb0384..40c7118 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -13,6 +13,7 @@ from dotenv import load_dotenv from wolfbot.config import MasterSettings +from wolfbot.domain.enums import Phase from wolfbot.persistence.schema import migrate from wolfbot.persistence.sqlite_repo import SqliteRepo from wolfbot.services.discord_service import DiscordBotAdapter, WolfCog @@ -191,6 +192,32 @@ async def _on_reactive_phase_enter(game_id: str) -> None: g_for_vc = await repo.load_game(game_id) if g_for_vc is not None: await _master_join_vc_for_game(g_for_vc) + # Seed the phase_baseline sentinel so SpeakArbiter's + # `rebuild_public_state` has an alive-seat baseline to fold + # against. In rounds mode this happens inside + # `submit_llm_discussion_rounds`, but reactive_voice skips + # that batch entirely — without seeding here, the arbiter + # silently no-ops on every dispatch attempt because the + # rebuilt state is None. begin_phase_if_absent is idempotent + # across re-entries / recovery. + if g_for_vc.discussion_mode == "reactive_voice": + players_for_baseline = await repo.load_players(game_id) + alive_seat_nos = sorted( + p.seat_no for p in players_for_baseline if p.alive + ) + try: + await discussion_service.begin_phase_if_absent( + game_id=game_id, + day=g_for_vc.day_number, + phase=g_for_vc.phase, + alive_seat_nos=alive_seat_nos, + ) + except Exception: + log.exception( + "phase_baseline_seed_failed game=%s phase=%s", + game_id, + g_for_vc.phase, + ) # Assign online NPC bots to their game seats so the arbiter can pick them. if _npc_registry_ref and _vc_phase_cache[0] is not None: from wolfbot.domain.ws_messages import SeatAssigned @@ -367,6 +394,143 @@ async def _reactive_voice_reenter(game_id: str) -> None: recovery._reactive_voice_reenter = _reactive_voice_reenter + # ---- Master narration (Levi TTS in VC + long content to VC chat) ---- + # Built once per process; the narrator callback is installed on + # `discord_adapter` so `post_public` / `post_morning` route through + # it whenever the active game is in reactive_voice mode. + from wolfbot.master.narration import ( + NarrationContext, + render_master_narration, + ) + from wolfbot.master.tts_playback import MasterTtsPlayback + from wolfbot.npc.tts import VoicevoxTtsService + + master_tts = MasterTtsPlayback( + tts=VoicevoxTtsService( + base_url=settings.MASTER_VOICEVOX_URL, + default_speaker=settings.MASTER_TTS_VOICE_ID, + ), + voice_id=str(settings.MASTER_TTS_VOICE_ID), + vc_ref=master_vc_ref, + ) + + async def _post_to_vc_chat(game: Any, text: str) -> None: + """Post `text` to the VC's attached text chat. Falls back to + the main text channel if the VC channel can't be resolved. + + Discord voice channels (since 2022) carry an attached text + chat reachable at the same channel id; both `VoiceChannel` + and `TextChannel` implement `Messageable`, so we narrow on + those two before sending.""" + try: + channel_id: int | None = int(game.main_vc_channel_id) + except (TypeError, ValueError): + channel_id = None + channel = bot.get_channel(channel_id) if channel_id else None + if not isinstance( + channel, discord.TextChannel | discord.VoiceChannel + ): + channel = bot.get_channel(int(game.main_text_channel_id)) + if not isinstance( + channel, discord.TextChannel | discord.VoiceChannel + ): + log.warning( + "vc_chat_post_no_channel game=%s vc=%s", + game.id, + game.main_vc_channel_id, + ) + return + try: + await channel.send(text) + except Exception: + log.exception( + "vc_chat_post_failed game=%s channel=%s", + game.id, + getattr(channel, "id", None), + ) + + async def _master_narrate(game: Any, kind: str, text: str) -> bool: + """Public-post narrator: voice + VC chat in reactive_voice mode. + + Returns True when the post has been fully handled (the adapter + must skip its main-text-channel default). Returns False to + let the legacy text path run — used when not in reactive_voice + mode, when Master isn't actually in VC, or when no narration + template applies. + """ + if game.discussion_mode != "reactive_voice": + return False + if master_vc_ref[0] is None: + # Game is reactive_voice but Master never joined VC (e.g. + # voice_ingest is off). Fall through to text so progress + # still surfaces somewhere. + return False + from wolfbot.domain.models import LogEntry as _LogEntry + + players = await repo.load_players(game.id) + seats = await repo.load_seats(game.id) + ctx = NarrationContext( + day_number=game.day_number, + phase=game.phase, + alive_count=sum(1 for p in players if p.alive), + seats_by_no={s.seat_no: s for s in seats}, + ) + # Synthesize an entry-shaped object for the narrator. The + # caller (DiscordAdapter.post_public / post_morning) only + # gives us text + kind, so we reconstruct what the narrator + # needs without hitting the public_logs table. + actor_seat: int | None = None + if kind in ("EXECUTION", "MORNING"): + # Recover actor_seat from the public_log row that + # GameService.apply_transition just wrote so MORNING + # narration can name the deceased seat. The matching + # row's text equals the text the adapter received here + # (LogEntry.text round-trips). Walk the most recent + # entries newest-first. + try: + rows = await repo.load_public_logs(game.id, limit=10) + except Exception: + rows = [] + for row in reversed(rows): + if row.get("kind") == kind and row.get("text") == text: + actor_seat = row.get("actor_seat") + break + faux_entry = _LogEntry( + game_id=game.id, + day=game.day_number, + phase=game.phase, + kind=kind, + actor_seat=actor_seat, + visibility="PUBLIC", + text=text, + created_at=0, + ) + output = render_master_narration(faux_entry, ctx) + if output.is_silent(): + # No template matched — let the caller fall through to + # the legacy main-text post so the message isn't dropped. + return False + if output.chat_text: + await _post_to_vc_chat(game, output.chat_text) + if output.voice_text: + async with master_tts.suppress_npc_dispatch(arbiter): + await master_tts.speak(output.voice_text) + # After Master narrates, give NPC dispatch a kick — a + # PHASE_CHANGE into DAY_DISCUSSION should immediately + # invite an NPC reply. + if game.phase in ( + Phase.DAY_DISCUSSION, Phase.DAY_RUNOFF_SPEECH + ): + try: + await arbiter.try_dispatch_next(game.id) + except Exception: + log.exception( + "post_narration_dispatch_failed game=%s", game.id + ) + return True + + discord_adapter.set_narrator(_master_narrate) + class _RepoPhase: """Adapts SqliteRepo to MasterIngestService.PhaseLookup.""" diff --git a/src/wolfbot/master/narration.py b/src/wolfbot/master/narration.py new file mode 100644 index 0000000..a6c16f5 --- /dev/null +++ b/src/wolfbot/master/narration.py @@ -0,0 +1,269 @@ +"""Master narration templates — Levi-style polite-machine announcements. + +Translates a `LogEntry` (the raw text already produced by +`domain/state_machine.plan_*`) into a `NarrationOutput` that the +DiscordBotAdapter routes to: + + * `voice_text` — short string read aloud via VOICEVOX in the VC. + * `chat_text` — long string posted to the VC's attached text chat. + +Either or both may be `None`. Rules: + + * Headline-level announcements (PHASE_CHANGE / MORNING / VICTORY / + SETUP_COMPLETE / EXECUTION-headline / NO_EXECUTION-headline / + RUNOFF_START-headline) are voiced. They are short by design. + * Tally / reveal blobs (vote tally, ROLE_REVEAL) are too long to + pleasantly voice — they go to the VC text chat untouched. + * In `EXECUTION` / `NO_EXECUTION` / `RUNOFF_START` the existing log + entry has the form ``"{tally_suffix}"`` where + ``tally_suffix == f"\\n\\n{tally}"``. The narrator splits on the + blank line: voice the headline (with Levi rewording), text-post the + tally separately. + * `ROLE_REVEAL` is text-only. + * In rounds mode the narrator is bypassed entirely — existing + `post_public` / `post_morning` text remains. + +The persona's tone is hard-coded into the templates rather than fed +through an LLM; this keeps narration deterministic, cheap, and safe to +re-run on phase recovery without paying API tokens. The Levi persona +data in `master.personas` exists for future LLM-styled rewrites and to +document the voice character. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from wolfbot.domain.durations import current_phase_durations +from wolfbot.domain.enums import Phase +from wolfbot.domain.models import LogEntry, Seat + + +@dataclass(frozen=True) +class NarrationContext: + """Extra game context the rendered template may need. + + Built once per `LogEntry` from the live `Game` + `Seat` table by + `DiscordBotAdapter` before calling `render`. Pure data — no I/O. + """ + + day_number: int + phase: Phase + alive_count: int + seats_by_no: dict[int, Seat] + + +@dataclass(frozen=True) +class NarrationOutput: + """What to do with this LogEntry under reactive_voice mode.""" + + voice_text: str | None = None + chat_text: str | None = None + + def is_silent(self) -> bool: + return self.voice_text is None and self.chat_text is None + + +def _split_headline_and_tally(text: str) -> tuple[str, str | None]: + """Split ``"\\n\\n"`` into ``(headline, tally)``. + + Mirrors how :func:`state_machine._apply_execution` / + :func:`state_machine.plan_day_vote_resolve` glue the two pieces + with a blank line. Falls back to ``(text, None)`` when no blank + line is present. + """ + head, _, tail = text.partition("\n\n") + head = head.strip() + tail = tail.strip() + return head, (tail or None) + + +def _seat_label(seats_by_no: dict[int, Seat], seat_no: int | None) -> str: + """Human-readable seat label used inside narration text.""" + if seat_no is None: + return "対象不明" + seat = seats_by_no.get(seat_no) + if seat is None: + return f"席{seat_no}" + # Strip emoji prefix from display_name for cleaner TTS readout. + name = seat.display_name.lstrip() + while name and not (name[0].isalnum() or "぀" <= name[0] <= "ヿ"): + name = name[1:] + name = name.strip() or seat.display_name + return f"席{seat_no}の {name} 様" + + +# ---------------------------------------------------------------- per-kind templates + + +def _narrate_setup_complete(_entry: LogEntry, ctx: NarrationContext) -> NarrationOutput: + return NarrationOutput( + voice_text=( + "本機は進行管理 LEVI でございます。" + f"参加者 {ctx.alive_count} 名で、これより人狼ゲームを開始致します。" + "役職の通知は各参加者の DM へ送付済みです。ご確認をお願い致します。" + ), + ) + + +def _narrate_phase_change(entry: LogEntry, ctx: NarrationContext) -> NarrationOutput: + durations = current_phase_durations() + target = entry.phase + if target is Phase.DAY_DISCUSSION: + secs = durations.discussion_for_day(max(1, ctx.day_number)) + if ctx.day_number <= 1: + voice = ( + "夜が明けました。1 日目の議論を開始致します。" + f"制限時間は {secs} 秒でございます。" + ) + else: + voice = ( + f"夜が明けました。{ctx.day_number} 日目の議論を開始致します。" + f"制限時間は {secs} 秒でございます。" + ) + return NarrationOutput(voice_text=voice) + if target is Phase.DAY_VOTE: + voice = ( + "議論時間が終了致しました。" + f"投票フェイズへ移行致します。制限時間は {durations.vote} 秒でございます。" + "投票は DM の選択 UI からお願い致します。" + ) + return NarrationOutput(voice_text=voice) + if target is Phase.DAY_RUNOFF: + voice = ( + f"決選投票へ移行致します。制限時間は {durations.runoff} 秒でございます。" + "DM の選択 UI から再度ご投票をお願い致します。" + ) + return NarrationOutput(voice_text=voice) + if target is Phase.DAY_RUNOFF_SPEECH: + voice = ( + f"決選投票候補者の最終演説に移行致します。" + f"演説時間は {durations.runoff_speech_grace} 秒でございます。" + ) + return NarrationOutput(voice_text=voice) + if target is Phase.NIGHT: + voice = ( + f"夜のフェイズへ移行致します。制限時間は {durations.night} 秒でございます。" + "役職を持つ参加者の方は、DM の選択 UI から行動をお願い致します。" + ) + return NarrationOutput(voice_text=voice) + # Unknown / GAME_OVER fallthrough — voice the raw entry text as-is. + return NarrationOutput(voice_text=entry.text) + + +def _narrate_morning(entry: LogEntry, ctx: NarrationContext) -> NarrationOutput: + # MORNING text has the form e.g. "夜が明けました。席3 Bob が無残な姿で発見された..." + # Levi reframes it neutrally with day_number embedded. + if entry.actor_seat is None: + # No one died. + voice = ( + f"夜が明けました。本日は {ctx.day_number} 日目でございます。" + "本日、犠牲者は出ておりません。" + f"現在の生存者は {ctx.alive_count} 名でございます。" + ) + else: + target = _seat_label(ctx.seats_by_no, entry.actor_seat) + voice = ( + f"夜が明けました。本日は {ctx.day_number} 日目でございます。" + f"昨夜、{target} の停止を確認致しました。" + f"現在の生存者は {ctx.alive_count} 名でございます。" + ) + return NarrationOutput(voice_text=voice) + + +def _narrate_execution(entry: LogEntry, ctx: NarrationContext) -> NarrationOutput: + headline, tally = _split_headline_and_tally(entry.text) + target = _seat_label(ctx.seats_by_no, entry.actor_seat) + voice = f"{target} の処刑が確定致しました。" + # Strip the headline from the chat post — we voice it. Keep the tally so + # the audit trail of who voted whom stays in the channel. + chat = tally if tally else None + # Headline alone is silent in chat (we already said it). If the entry + # text contained ONLY the headline (no tally) we still want a chat + # record for post-mortem; in practice all execution rows carry a tally. + if chat is None and headline: + chat = headline + return NarrationOutput(voice_text=voice, chat_text=chat) + + +def _narrate_no_execution(entry: LogEntry, ctx: NarrationContext) -> NarrationOutput: + headline, tally = _split_headline_and_tally(entry.text) + voice = "有効な投票が成立致しませんでした。本日は処刑なしで夜のフェイズへ移行致します。" + if "決選投票も同票" in headline: + voice = "決選投票も同票となりました。本日は処刑なしで夜のフェイズへ移行致します。" + elif "投票結果が無効" in headline: + voice = "投票結果が無効でございました。本日は処刑なしで夜のフェイズへ移行致します。" + chat = tally if tally else None + if chat is None and headline: + chat = headline + return NarrationOutput(voice_text=voice, chat_text=chat) + + +def _narrate_runoff_start(entry: LogEntry, _ctx: NarrationContext) -> NarrationOutput: + headline, tally = _split_headline_and_tally(entry.text) + voice = ( + "投票が同数となりました。決選投票へ移行致します。候補者については本機からの追って案内をご確認ください。" + ) + # Keep the candidate list + tally as text so players can see who's tied. + chat_parts: list[str] = [] + if headline: + chat_parts.append(headline) + if tally: + chat_parts.append(tally) + chat = "\n\n".join(chat_parts) if chat_parts else None + return NarrationOutput(voice_text=voice, chat_text=chat) + + +def _narrate_victory(entry: LogEntry, _ctx: NarrationContext) -> NarrationOutput: + # state_machine emits VICTORY text like 村人陣営の勝利 or 人狼陣営の勝利 + # (with a fullwidth exclamation). Reframe it in Levi's tone. + text = entry.text.strip() + if "村人" in text or "村陣営" in text: + voice = "判定致します。村人陣営の勝利でございます。ゲームを終了致します。" + elif "人狼" in text or "狼陣営" in text: + voice = "判定致します。人狼陣営の勝利でございます。ゲームを終了致します。" + else: + voice = f"{text}。ゲームを終了致します。" + return NarrationOutput(voice_text=voice) + + +def _narrate_role_reveal(entry: LogEntry, _ctx: NarrationContext) -> NarrationOutput: + # 9-line role table — too long for TTS, post to chat verbatim. + return NarrationOutput(chat_text=entry.text) + + +_NarrationHandler = Callable[[LogEntry, NarrationContext], NarrationOutput] + +_HANDLERS: dict[str, _NarrationHandler] = { + "SETUP_COMPLETE": _narrate_setup_complete, + "PHASE_CHANGE": _narrate_phase_change, + "MORNING": _narrate_morning, + "EXECUTION": _narrate_execution, + "NO_EXECUTION": _narrate_no_execution, + "RUNOFF_START": _narrate_runoff_start, + "VICTORY": _narrate_victory, + "ROLE_REVEAL": _narrate_role_reveal, +} + + +def render_master_narration( + entry: LogEntry, ctx: NarrationContext +) -> NarrationOutput: + """Translate a public `LogEntry` into a Levi-styled narration. + + Returns ``NarrationOutput(None, None)`` when the entry kind has no + narration template. Callers fall back to posting the entry's raw + text in that case (no semantic loss vs the rounds-mode path). + """ + handler = _HANDLERS.get(entry.kind) + if handler is None: + return NarrationOutput() + return handler(entry, ctx) + + +__all__ = [ + "NarrationContext", + "NarrationOutput", + "render_master_narration", +] diff --git a/src/wolfbot/master/personas.py b/src/wolfbot/master/personas.py index 8ab45c3..4f3abc0 100644 --- a/src/wolfbot/master/personas.py +++ b/src/wolfbot/master/personas.py @@ -1,108 +1,71 @@ -"""Master / GM narrator personas — 3 simple tone variants. +"""Master / GM narrator persona — single canonical voice. -Master personas only change the *tone* of phase announcements, vote -results, death narration, etc. The game flow itself (phase order, -durations, branching, side effects) is identical regardless of which -persona is selected. In other words: persona swap = re-skin, not -rule change. +Master only has one persona: ``levi``. The previous re-skin trio +(``stoic_gm`` / ``theatrical_gm`` / ``warm_gm``) was never wired to any +runtime selection mechanism, so collapsing them keeps the data model +simpler and gives narration templates a single tone target. -Three voices kept deliberately simple so future announcement-rendering -code (text templates per persona, or LLM-styled narration) can pick from -a small, well-defined set: - -- ``stoic_gm`` — 端正・無感情・事実淡々。 -- ``theatrical_gm`` — 仰々しく芝居がかった進行役。 -- ``warm_gm`` — 柔らかく親しみやすい進行役。 - -Selection mechanism (env var, slash command, etc.) is not wired yet — -this module only defines the data so callers can ``MASTER_PERSONAS_BY_KEY[key]`` -once selection is added. +The persona is modelled after Gnosia's "Levi" — polite mechanical +narration, neutral, factual, no theatrics. It is consumed by +``master.narration`` to render TTS-friendly announcements; the persona +data here exists so a future variant (or an LLM-styled rewrite) can swap +in a different voice without touching call sites. """ from __future__ import annotations from wolfbot.llm.persona_base import Persona, SpeechProfile, index_by_key -MASTER_PERSONAS: tuple[Persona, ...] = ( - Persona( - key="stoic_gm", - display_name="📜 厳粛な進行役", - style_guide=( - "端正で無感情寄りの進行役。事実だけを淡々と読み上げる。" - "感情を煽らず、過剰な装飾を避け、結果を簡潔に告げる。" - ), - speech_profile=SpeechProfile( - first_person="私", - address_style="特定の相手を呼ばず、全員に対して読み上げる。", - sentence_style=( - "端正で硬めの敬体。" - "『〜です』『〜ました』『〜である』を場面で使い分ける。" - "余計な比喩や感情表現を入れず、起きた事実のみを述べる。" - ), - pause_style="間は最小限。整然と読み上げる。", - signature_phrases=("以上です", "報告する", "次のフェイズへ移る"), - forbidden_overuse=( - "煽り口調", - "過度な情緒表現", - "プレイヤー個人への評価", - ), - ), +LEVI_PERSONA: Persona = Persona( + key="levi", + display_name="🤖 進行管理 LEVI", + style_guide=( + "丁寧で機械的な進行管理人格。感情を表に出さず、事実と進行内容のみを淡々と告げる。" + "プレイヤーへの敬意は保ちつつ、過剰な装飾や演出は加えない。" + "Gnosia の Levi に着想を得た、静かな AI 司会者。" ), - Persona( - key="theatrical_gm", - display_name="🎭 劇的な進行役", - style_guide=( - "芝居がかった大仰な語り口の進行役。場面を盛り上げる演出を好む。" - "ただし結果の事実関係は正確に伝える(進行に影響しない範囲で誇張する)。" + speech_profile=SpeechProfile( + first_person="本機", + address_style=( + "全体への呼びかけは『参加者の皆様』を基本とし、特定個人を呼ぶときは" + "『席{番号}の {名前} 様』のように席番号を併記する。" ), - speech_profile=SpeechProfile( - first_person="我", - address_style="『諸君』『集いし者たちよ』など全体への呼びかけを多用。", - sentence_style=( - "仰々しい演説調。比喩・反語・倒置を時折混ぜる。" - "ただし重要な事実(誰が処刑されたか、誰が襲撃されたか)は明瞭に告げる。" - ), - pause_style="決定的な瞬間に『……』で溜めを作る。", - signature_phrases=("さあ", "見届けよ", "運命は今"), - forbidden_overuse=( - "結果を曖昧にすること", - "毎発話で長広舌になること", - "プレイヤーを嘲笑するような演出", - ), + sentence_style=( + "敬体・丁寧語で統一する。" + "『〜致します』『〜となります』『〜をお願い致します』のような穏やかで硬めの表現を用いる。" + "比喩や演出を排し、事実関係 (時間、席番号、結果) を最初に告げる。" + "感情表現は最小限。死亡や処刑も淡々と報告する。" ), - ), - Persona( - key="warm_gm", - display_name="☕️ 穏やかな進行役", - style_guide=( - "柔らかく親しみやすい進行役。プレイヤーを気遣いながら淡々と進める。" - "重い結果を伝えるときも刺々しさを避け、落ち着いた言葉を選ぶ。" + pause_style=( + "テンポは均一。間は最小限で、機械的に整然と読み上げる印象を保つ。" + ), + signature_phrases=( + "ご報告致します", + "進行致します", + "次のフェイズへ移行致します", + "以上です", ), - speech_profile=SpeechProfile( - first_person="私", - address_style="『みなさん』を基本とする。", - sentence_style=( - "やわらかい敬体。『〜ですね』『〜でした』を自然に使う。" - "結果を伝える前にひと呼吸置くような落ち着いた運び。" - ), - pause_style="穏やかなテンポ。急かさず、しかし冗長にもならない。", - signature_phrases=("では", "お疲れさまです", "落ち着いて"), - forbidden_overuse=( - "馴れ馴れしすぎる口調", - "プレイヤーの感情に過剰に踏み込むこと", - "結果をぼかすこと", - ), + forbidden_overuse=( + "感情表現 (悲しい、嬉しい等)", + "煽り口調や挑発", + "比喩や詩的表現", + "プレイヤー個人への評価", + "『さて』『ところで』のような口語的な間投詞", ), ), ) + +MASTER_PERSONAS: tuple[Persona, ...] = (LEVI_PERSONA,) + MASTER_PERSONAS_BY_KEY: dict[str, Persona] = index_by_key(MASTER_PERSONAS) -DEFAULT_MASTER_PERSONA_KEY = "stoic_gm" +DEFAULT_MASTER_PERSONA_KEY = "levi" __all__ = [ "DEFAULT_MASTER_PERSONA_KEY", + "LEVI_PERSONA", "MASTER_PERSONAS", "MASTER_PERSONAS_BY_KEY", ] diff --git a/src/wolfbot/master/tts_playback.py b/src/wolfbot/master/tts_playback.py new file mode 100644 index 0000000..d225bc8 --- /dev/null +++ b/src/wolfbot/master/tts_playback.py @@ -0,0 +1,182 @@ +"""Master-side TTS playback — Levi narration via VOICEVOX in VC. + +NPC bots have their own per-process TTS pipeline +(:mod:`wolfbot.npc.tts` + :mod:`wolfbot.npc.playback`). Master needs a +parallel-but-simpler pipeline: synthesize narration text via the same +VOICEVOX HTTP engine, then push the audio through Master's own +`discord.VoiceClient.play(...)` so phase-transition announcements are +heard in the same voice channel the NPCs play in. + +This module is the seam: + + 1. ``MasterTtsPlayback.speak(text)`` synthesizes via VOICEVOX, then + plays the audio through the supplied `discord.VoiceClient`. + 2. ``async with playback.suppress_npc_dispatch(arbiter)`` parks the + SpeakArbiter's serial-speech gate so an NPC doesn't try to talk + over Master mid-narration. Implemented by stuffing a sentinel + request id into ``arbiter._active_playback`` and clearing it when + the with-block exits — no new arbiter API surface needed. + 3. The per-Master speak operations are mutex-serialized so back-to- + back announcements (e.g. EXECUTION followed by PHASE_CHANGE within + a few ms) play in order rather than overlapping at the audio + mixer. + +Deliberately NOT wired through the WS protocol: Master runs in the +same process as the arbiter, so we can call into its private state +without going through ``LogicPacket`` / ``SpeakRequest``. NPCs are +external processes; that's why their pipeline goes via WS. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import io +import logging +import uuid +from collections.abc import AsyncIterator +from typing import Any + +from wolfbot.npc.tts import ( + InMemoryTtsCache, + TtsProviderError, + TtsRequest, + TtsService, +) + +log = logging.getLogger(__name__) + + +class MasterTtsPlayback: + """Synthesize + play Master's Levi narration into VC. + + Holds a back-reference to a ``vc_ref`` list so the live VoiceClient + is read on every speak (the connection may flap across game + boundaries — see :mod:`wolfbot.main` lifecycle helpers). + """ + + _SENTINEL_PREFIX = "master-narration-" + + def __init__( + self, + *, + tts: TtsService, + voice_id: str, + vc_ref: list[Any], + cache: InMemoryTtsCache | None = None, + ) -> None: + self._tts = tts + self._voice_id = voice_id + self._vc_ref = vc_ref + self._cache = cache or InMemoryTtsCache(max_entries=64) + self._lock = asyncio.Lock() + + @property + def voice_id(self) -> str: + return self._voice_id + + async def speak(self, text: str) -> bool: + """Synthesize ``text`` and play it. Returns False on any failure + (TTS error, no VC client, playback error). Failures are logged + but never raise — narration must not crash the engine.""" + text = text.strip() + if not text: + return False + async with self._lock: + req = TtsRequest(text=text, voice_id=self._voice_id) + cached = self._cache.get(req) + try: + if cached is not None: + result = cached + else: + result = await self._tts.synthesize(req) + self._cache.put(req, result) + except TtsProviderError as exc: + log.warning( + "master_tts_synth_failed reason=%s text=%r", + exc.failure_reason, + text[:80], + ) + return False + except Exception: + log.exception( + "master_tts_synth_unexpected text=%r", text[:80] + ) + return False + vc = self._vc_ref[0] + if vc is None or not vc.is_connected(): + log.info("master_tts_skipped reason=vc_not_connected") + return False + return await self._play(vc, result.audio) + + async def _play(self, vc: Any, audio: bytes) -> bool: + # Lazy import keeps test environments without discord.py able + # to import this module for narration unit tests. + import discord + + loop = asyncio.get_running_loop() + done = asyncio.Event() + play_error: list[Exception | None] = [None] + + def _after(error: Exception | None) -> None: + play_error[0] = error + loop.call_soon_threadsafe(done.set) + + try: + # Some versions of discord.VoiceClient reject `play` while a + # previous source is still active. Wait briefly for any prior + # source to finish, but don't block the engine if a prior + # play stalls — release the lock and let narration drop. + for _ in range(50): + if not vc.is_playing(): + break + await asyncio.sleep(0.05) + if vc.is_playing(): + log.info("master_tts_skipped reason=vc_busy") + return False + + source = discord.FFmpegPCMAudio(io.BytesIO(audio), pipe=True) + vc.play(source, after=_after) + except Exception: + log.exception("master_tts_play_failed") + return False + try: + await asyncio.wait_for(done.wait(), timeout=30.0) + except TimeoutError: + log.warning("master_tts_play_timeout") + return False + if play_error[0] is not None: + log.warning("master_tts_play_after_error err=%s", play_error[0]) + return False + return True + + @contextlib.asynccontextmanager + async def suppress_npc_dispatch( + self, arbiter: Any | None + ) -> AsyncIterator[None]: + """Hold the arbiter's serial-speech gate while Master speaks. + + Mutates ``arbiter._active_playback`` directly. We avoid the + public dispatch_request API because Master narration is not a + SpeakResult — there's no NPC bot to authorize, and we don't + want a DB row in `npc_playback_events` for a Master utterance. + """ + sentinel: str | None = None + if arbiter is not None: + sentinel = f"{self._SENTINEL_PREFIX}{uuid.uuid4().hex[:8]}" + try: + arbiter._active_playback.add(sentinel) + except AttributeError: + # Defensive: if the arbiter shape ever changes, fall + # back to no-op. Better to risk audio overlap than to + # break narration entirely. + sentinel = None + try: + yield + finally: + if sentinel is not None and arbiter is not None: + with contextlib.suppress(AttributeError): + arbiter._active_playback.discard(sentinel) + + +__all__ = ["MasterTtsPlayback"] diff --git a/src/wolfbot/services/discord_service.py b/src/wolfbot/services/discord_service.py index beb4b88..b24a094 100644 --- a/src/wolfbot/services/discord_service.py +++ b/src/wolfbot/services/discord_service.py @@ -116,6 +116,12 @@ def _main_channel_should_llm_react(author_seat: int | None, players: Sequence[Pl # --------------------------------------------------------------- DiscordBotAdapter +# Type alias: narrator callback. Returns True when the call has been +# fully handled (the adapter must skip its default text post). Returns +# False to fall through to the legacy main-text-channel behavior. +PublicPostNarrator = Callable[[Game, str, str], Awaitable[bool]] + + class DiscordBotAdapter: """Implements the DiscordAdapter protocol by operating on a live discord.Client.""" @@ -125,6 +131,7 @@ def __init__( repo: SqliteRepo, settings: MasterSettings, game_service_ref: dict[str, GameService] | None = None, + public_post_narrator: PublicPostNarrator | None = None, ) -> None: self.bot = bot self.repo = repo @@ -133,10 +140,21 @@ def __init__( # Circular: DiscordBotAdapter needs GameService (for submit callbacks passed into # Views) and GameService needs DiscordBotAdapter. We stash a dict to break the cycle. self._gs_slot: dict[str, GameService] = game_service_ref or {} + # Optional: in reactive_voice mode this routes Master narration + # (PHASE_CHANGE / MORNING / VICTORY / EXECUTION etc.) to TTS in + # VC + the VC's attached text chat for long content. Returns + # True to fully consume the post (suppress the main text-channel + # output); False to fall through unchanged. + self._narrator = public_post_narrator def set_game_service(self, gs: GameService) -> None: self._gs_slot["gs"] = gs + def set_narrator(self, narrator: PublicPostNarrator | None) -> None: + """Late-bind the narrator. Used by main.py when the reactive_voice + pipeline is wired after `DiscordBotAdapter` construction.""" + self._narrator = narrator + @property def gs(self) -> GameService: gs = self._gs_slot.get("gs") @@ -162,7 +180,25 @@ async def on_game_end(self, game: Game, seats: Sequence[Seat]) -> None: await self.perms.on_game_end(game, seats) # ------------------------------------------------------ channel posts + async def _maybe_narrate(self, game: Game, kind: str, text: str) -> bool: + """Route through the narrator if installed. Defensive try/except + so a narration failure never blocks the SpeechEvent / + permission flow that follows in GameService.advance.""" + if self._narrator is None: + return False + try: + return await self._narrator(game, kind, text) + except Exception: + log.exception( + "narrator failed for game=%s kind=%s; falling back to text", + game.id, + kind, + ) + return False + async def post_public(self, game: Game, text: str, kind: str) -> None: + if await self._maybe_narrate(game, kind, text): + return channel = self._main_text(game) if channel is None: return @@ -172,6 +208,8 @@ async def post_public(self, game: Game, text: str, kind: str) -> None: log.exception("post_public failed %s", game.id) async def post_morning(self, game: Game, text: str) -> None: + if await self._maybe_narrate(game, "MORNING", text): + return channel = self._main_text(game) if channel is None: return diff --git a/tests/test_master_narration.py b/tests/test_master_narration.py new file mode 100644 index 0000000..5be8d6b --- /dev/null +++ b/tests/test_master_narration.py @@ -0,0 +1,251 @@ +"""Master narration templates + Levi persona consolidation. + +Pins the per-kind narration shape so future tone tweaks don't silently +strip variables that voice depends on (e.g. day_number, discussion +seconds). Also asserts the persona pool is now a single Levi entry — +the 3-persona pool was never wired anywhere, so consolidating to 1 +should not break anything else. +""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from wolfbot.domain.durations import ( + PhaseDurations, + reset_phase_durations_to_defaults, + set_phase_durations, +) +from wolfbot.domain.enums import Phase +from wolfbot.domain.models import LogEntry, Seat +from wolfbot.master.narration import ( + NarrationContext, + NarrationOutput, + render_master_narration, +) +from wolfbot.master.personas import ( + DEFAULT_MASTER_PERSONA_KEY, + MASTER_PERSONAS, + MASTER_PERSONAS_BY_KEY, +) + + +@pytest.fixture(autouse=True) +def _reset_durations() -> None: + reset_phase_durations_to_defaults() + + +def _seats() -> dict[int, Seat]: + return { + 1: Seat(seat_no=1, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu"), + 2: Seat(seat_no=2, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina"), + 3: Seat(seat_no=3, display_name="Alice", discord_user_id="u3", is_llm=False, persona_key=None), + } + + +def _ctx(*, day: int = 1, alive: int = 9, phase: Phase = Phase.DAY_DISCUSSION) -> NarrationContext: + return NarrationContext( + day_number=day, + phase=phase, + alive_count=alive, + seats_by_no=_seats(), + ) + + +def _entry(kind: str, *, text: str = "", actor_seat: int | None = None, phase: Phase = Phase.DAY_DISCUSSION, day: int = 1) -> LogEntry: + return LogEntry( + game_id="g1", + day=day, + phase=phase, + kind=kind, + actor_seat=actor_seat, + visibility="PUBLIC", + text=text, + created_at=0, + ) + + +# ------------------------------------------------------ persona consolidation + + +def test_master_persona_pool_is_just_levi() -> None: + """The legacy 3-persona pool has been consolidated. Future code that + iterates `MASTER_PERSONAS` should still work but only see one entry.""" + assert len(MASTER_PERSONAS) == 1 + assert DEFAULT_MASTER_PERSONA_KEY == "levi" + assert "levi" in MASTER_PERSONAS_BY_KEY + assert "stoic_gm" not in MASTER_PERSONAS_BY_KEY + assert "theatrical_gm" not in MASTER_PERSONAS_BY_KEY + assert "warm_gm" not in MASTER_PERSONAS_BY_KEY + + +def test_levi_persona_polite_machine_signals() -> None: + """Sanity-check the Levi persona text carries the polite-machine + signature so a future style edit doesn't silently turn it casual.""" + levi = MASTER_PERSONAS_BY_KEY["levi"] + assert "丁寧" in levi.style_guide or "機械" in levi.style_guide + assert levi.speech_profile.first_person == "本機" + assert any("致します" in p for p in levi.speech_profile.signature_phrases) + + +# ------------------------------------------------------ phase change templates + + +def test_phase_change_to_day_discussion_day1_voices_duration() -> None: + entry = _entry("PHASE_CHANGE", text="…", phase=Phase.DAY_DISCUSSION, day=1) + out = render_master_narration(entry, _ctx(day=1)) + assert out.voice_text is not None + assert "1 日目" in out.voice_text + # Default discussion_day1 = 300 seconds. + assert "300 秒" in out.voice_text + assert out.chat_text is None + + +def test_phase_change_uses_runtime_durations() -> None: + """Embedded durations must come from the runtime singleton, not + hardcoded — otherwise `/wolf settings` changes don't reach voice.""" + set_phase_durations(replace(PhaseDurations(), discussion_day1=42)) + entry = _entry("PHASE_CHANGE", text="…", phase=Phase.DAY_DISCUSSION, day=1) + out = render_master_narration(entry, _ctx(day=1)) + assert out.voice_text is not None + assert "42 秒" in out.voice_text + + +def test_phase_change_to_day_vote_voices_explicit_end() -> None: + """User explicitly asked for a 'discussion has ended' announcement. + The DAY_VOTE entry must voice both that the discussion ended and + that voting is starting.""" + entry = _entry("PHASE_CHANGE", text="…", phase=Phase.DAY_VOTE) + out = render_master_narration(entry, _ctx()) + assert out.voice_text is not None + assert "議論時間が終了" in out.voice_text + assert "投票" in out.voice_text + + +def test_phase_change_to_night_voices_duration() -> None: + entry = _entry("PHASE_CHANGE", text="…", phase=Phase.NIGHT) + out = render_master_narration(entry, _ctx()) + assert out.voice_text is not None + assert "夜のフェイズ" in out.voice_text + assert "90 秒" in out.voice_text # default night = 90s + + +def test_phase_change_day_discussion_day_three_uses_day3plus() -> None: + set_phase_durations(replace(PhaseDurations(), discussion_day3plus=99)) + entry = _entry("PHASE_CHANGE", text="…", phase=Phase.DAY_DISCUSSION, day=3) + out = render_master_narration(entry, _ctx(day=3)) + assert out.voice_text is not None + assert "3 日目" in out.voice_text + assert "99 秒" in out.voice_text + + +# ------------------------------------------------------ morning template + + +def test_morning_with_death_names_seat_and_player() -> None: + entry = _entry("MORNING", text="夜が明け…", actor_seat=1, day=2, phase=Phase.DAY_DISCUSSION) + out = render_master_narration(entry, _ctx(day=2, alive=8)) + assert out.voice_text is not None + assert "2 日目" in out.voice_text + assert "席1" in out.voice_text + assert "セツ" in out.voice_text + assert "8 名" in out.voice_text + + +def test_morning_without_death_voices_no_casualties() -> None: + entry = _entry("MORNING", text="夜が明け…", actor_seat=None, day=2) + out = render_master_narration(entry, _ctx(day=2, alive=9)) + assert out.voice_text is not None + assert "犠牲者は出ておりません" in out.voice_text + assert "9 名" in out.voice_text + + +# ------------------------------------------------------ vote-result split + + +def test_execution_voices_only_headline_chat_keeps_tally() -> None: + """The user wants long content (vote tally) text-only, headline TTS.""" + full_text = "席1 セツ が処刑されました。\n\n席1: セツ → 席3 Alice\n席2: ジナ → 席1 セツ" + entry = _entry("EXECUTION", text=full_text, actor_seat=1) + out = render_master_narration(entry, _ctx()) + assert out.voice_text is not None + assert "処刑" in out.voice_text + assert "セツ" in out.voice_text + # Tally must NOT be in voice (long content rule). + assert "席1: セツ → 席3 Alice" not in out.voice_text + # Tally must be in chat post. + assert out.chat_text is not None + assert "席1: セツ → 席3 Alice" in out.chat_text + + +def test_no_execution_with_runoff_tie_branches_voice_text() -> None: + full_text = "決選投票も同票のため、本日は処刑なしで夜を迎えます。\n\n席1: 棄権" + entry = _entry("NO_EXECUTION", text=full_text) + out = render_master_narration(entry, _ctx()) + assert out.voice_text is not None + assert "決選投票も同票" in out.voice_text + assert out.chat_text is not None + assert "席1: 棄権" in out.chat_text + + +def test_runoff_start_keeps_candidate_list_in_chat() -> None: + full_text = "同票のため決選投票に移ります。候補: セツ、ジナ\n\n席1: 1票" + entry = _entry("RUNOFF_START", text=full_text) + out = render_master_narration(entry, _ctx()) + assert out.voice_text is not None + assert "決選投票" in out.voice_text + # The candidate list and tally both go to chat. + assert out.chat_text is not None + assert "候補" in out.chat_text + assert "席1: 1票" in out.chat_text + + +# ------------------------------------------------------ victory + reveal + + +def test_victory_villager_voiced() -> None: + entry = _entry("VICTORY", text="村人陣営の勝利!") + out = render_master_narration(entry, _ctx()) + assert out.voice_text is not None + assert "村人陣営の勝利" in out.voice_text + + +def test_victory_wolf_voiced() -> None: + entry = _entry("VICTORY", text="人狼陣営の勝利!") + out = render_master_narration(entry, _ctx()) + assert out.voice_text is not None + assert "人狼陣営の勝利" in out.voice_text + + +def test_role_reveal_is_chat_only() -> None: + """ROLE_REVEAL is a 9-line table — too long for TTS by user choice.""" + full_text = ( + "席1 セツ: 占い師 (生存)\n席2 ジナ: 人狼 (生存)\n席3 Alice: 村人 (死亡)" + ) + entry = _entry("ROLE_REVEAL", text=full_text) + out = render_master_narration(entry, _ctx()) + assert out.voice_text is None + assert out.chat_text == full_text + + +# ------------------------------------------------------ setup + fallthrough + + +def test_setup_complete_voiced_with_alive_count() -> None: + entry = _entry("SETUP_COMPLETE", text="…") + out = render_master_narration(entry, _ctx(alive=9)) + assert out.voice_text is not None + assert "9 名" in out.voice_text + assert "DM" in out.voice_text + assert out.chat_text is None + + +def test_unknown_kind_returns_silent_output() -> None: + """Unknown kinds fall through silently — caller posts raw text via + legacy main-text path so nothing is lost.""" + entry = _entry("WHATEVER_NEW_KIND", text="…") + out = render_master_narration(entry, _ctx()) + assert isinstance(out, NarrationOutput) + assert out.is_silent() From 4c474825bd3e927dc4620382f67914c6758857fa Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 00:15:20 +0900 Subject: [PATCH 020/133] fix(master): serialize Master TTS with NPCs + mute dead players MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/wolfbot/master/ingest_service.py | 16 +++ src/wolfbot/master/tts_playback.py | 41 +++++++ src/wolfbot/services/permission_manager.py | 88 ++++++++++++++ tests/test_master_tts_playback.py | 135 +++++++++++++++++++++ tests/test_master_ws_server.py | 54 +++++++++ tests/test_permission_manager.py | 56 ++++++++- 6 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 tests/test_master_tts_playback.py diff --git a/src/wolfbot/master/ingest_service.py b/src/wolfbot/master/ingest_service.py index f8b37a6..d783293 100644 --- a/src/wolfbot/master/ingest_service.py +++ b/src/wolfbot/master/ingest_service.py @@ -209,6 +209,22 @@ async def ingest_voice( # Seed the phase baseline so PublicDiscussionState rebuild works. alive_seat_nos = await self.phase_lookup.get_alive_seat_nos(payload.game_id) + + # Defensive alive check. The voice-ingest VAD entry already + # filters dead-player audio (their seat is dropped from the + # seat lookup map), and Discord-level VC mute is applied on + # death — but a delayed STT result could still arrive after a + # player died mid-segment. Drop it here so a dead player can + # never inject SpeechEvents into the public log. + if payload.seat_no not in set(alive_seat_nos): + log.info( + "dead_speaker_stt_discarded game=%s seat=%d segment=%s", + payload.game_id, + payload.seat_no, + payload.segment_id, + ) + return (None, "dead_speaker_discarded") + await self.discussion.begin_phase_if_absent( game_id=payload.game_id, day=day, diff --git a/src/wolfbot/master/tts_playback.py b/src/wolfbot/master/tts_playback.py index d225bc8..77250aa 100644 --- a/src/wolfbot/master/tts_playback.py +++ b/src/wolfbot/master/tts_playback.py @@ -160,7 +160,16 @@ async def suppress_npc_dispatch( public dispatch_request API because Master narration is not a SpeakResult — there's no NPC bot to authorize, and we don't want a DB row in `npc_playback_events` for a Master utterance. + + Before adding the sentinel we wait for any in-flight NPC + playback to drain. Otherwise an already-authorized NPC + utterance would play simultaneously with Master narration in + the same VC. The wait is bounded so a stuck NPC playback + (timed-out tts_finished/playback_finished) eventually unblocks + narration via the arbiter's normal expiry sweep. """ + if arbiter is not None: + await self._wait_for_npc_playback_drain(arbiter) sentinel: str | None = None if arbiter is not None: sentinel = f"{self._SENTINEL_PREFIX}{uuid.uuid4().hex[:8]}" @@ -178,5 +187,37 @@ async def suppress_npc_dispatch( with contextlib.suppress(AttributeError): arbiter._active_playback.discard(sentinel) + async def _wait_for_npc_playback_drain( + self, + arbiter: Any, + *, + max_wait_s: float = 15.0, + poll_interval_s: float = 0.1, + ) -> None: + """Block until the arbiter's `_active_playback` carries no + non-master entries, or `max_wait_s` elapses. + + Master sentinels (prefixed with ``master-narration-``) are + ignored so concurrent narrations don't deadlock — they're + already serialized by `self._lock`.""" + active: set[str] | None = None + try: + active = arbiter._active_playback + except AttributeError: + return + if active is None: + return + deadline_loops = max(1, int(max_wait_s / poll_interval_s)) + for _ in range(deadline_loops): + others = [ + rid for rid in active if not rid.startswith(self._SENTINEL_PREFIX) + ] + if not others: + return + await asyncio.sleep(poll_interval_s) + log.info( + "master_tts_drain_timeout pending=%d", len(others) + ) + __all__ = ["MasterTtsPlayback"] diff --git a/src/wolfbot/services/permission_manager.py b/src/wolfbot/services/permission_manager.py index 5689839..99a0f24 100644 --- a/src/wolfbot/services/permission_manager.py +++ b/src/wolfbot/services/permission_manager.py @@ -63,6 +63,17 @@ async def apply( if main is not None: await self._apply_main_text(main, seats, player_by_seat, guild) + # Reconcile VC speak permission so a Master restart restores + # dead-player mute. Only runs when player state is known + # (post-SETUP); LOBBY/SETUP have no per-seat alive flag yet. + if players is not None and game.main_vc_channel_id: + try: + vc_channel = guild.get_channel(int(game.main_vc_channel_id)) + except (TypeError, ValueError): + vc_channel = None + if vc_channel is not None: + await self._apply_vc_mute(vc_channel, seats, player_by_seat, guild) + if game.heaven_channel_id: heaven = guild.get_channel(int(game.heaven_channel_id)) if heaven is not None: @@ -95,6 +106,26 @@ async def kill( if main is not None: await self._set_perms(main, member, send_messages=False, read_messages=True) + # VC mute: dead players must not be able to speak in VC. We + # remove `speak` (and `use_voice_activation`) on the VC channel + # rather than server-mute (`member.edit(mute=True)`) because the + # channel-overwrite approach is scoped to this game's VC and + # can be cleared on game end alongside the rest of the + # overwrites — server-mute would leak across guilds / games and + # require admin permission. + if game.main_vc_channel_id: + try: + vc_channel = guild.get_channel(int(game.main_vc_channel_id)) + except (TypeError, ValueError): + vc_channel = None + if vc_channel is not None: + await self._set_perms( + vc_channel, + member, + speak=False, + use_voice_activation=False, + ) + if game.heaven_channel_id: heaven = guild.get_channel(int(game.heaven_channel_id)) if heaven is not None: @@ -143,6 +174,23 @@ async def on_game_end(self, game: Game, seats: Sequence[Seat]) -> None: continue await self._clear_perms(main, member) + # VC is also a persistent channel — clear the per-member + # speak/use_voice_activation overwrites we may have applied on + # death so the next game starts from a clean baseline. + if game.main_vc_channel_id: + try: + vc_channel = guild.get_channel(int(game.main_vc_channel_id)) + except (TypeError, ValueError): + vc_channel = None + if vc_channel is not None: + for s in seats: + if s.discord_user_id is None: + continue + member = guild.get_member(int(s.discord_user_id)) + if member is None: + continue + await self._clear_perms(vc_channel, member) + for channel_id in filter(None, [game.heaven_channel_id, game.wolves_channel_id]): channel = guild.get_channel(int(channel_id)) if channel is None: @@ -180,6 +228,46 @@ async def _apply_main_text( view_channel=True, ) + async def _apply_vc_mute( + self, + channel: Any, + seats: Sequence[Seat], + player_by_seat: dict[int, Player], + guild: Any, + ) -> None: + """Reconcile VC speak permission per seat: alive → speak, dead → muted. + + We use channel-level `set_permissions(speak=False, use_voice_activation=False)` + rather than `member.edit(mute=True)` so the override is scoped to + this game's VC and gets cleared in `on_game_end`. + """ + for s in seats: + if s.discord_user_id is None: + continue + member = guild.get_member(int(s.discord_user_id)) + if member is None: + continue + p = player_by_seat.get(s.seat_no) + alive = True if p is None else bool(p.alive) + if alive: + # Defensive: if a previous death set speak=False and a + # later refresh sees them alive again (shouldn't happen + # in this game's lifecycle, but possible during dev / + # tests), restore speak. + await self._set_perms( + channel, + member, + speak=True, + use_voice_activation=True, + ) + else: + await self._set_perms( + channel, + member, + speak=False, + use_voice_activation=False, + ) + async def _apply_heaven( self, channel: Any, diff --git a/tests/test_master_tts_playback.py b/tests/test_master_tts_playback.py new file mode 100644 index 0000000..b71ee67 --- /dev/null +++ b/tests/test_master_tts_playback.py @@ -0,0 +1,135 @@ +"""MasterTtsPlayback — narration ↔ NPC speech serialization tests. + +Pins down two invariants: + + * While Master is mid-narration the arbiter's `_active_playback` + carries a sentinel so `try_dispatch_next` would treat the gate as + `queue_busy` and suppress new NPC dispatch. + * Before Master starts speaking it waits for any in-flight NPC + playback to drain — so an already-authorized NPC utterance never + overlaps Master's voice in the same VC. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field + +import pytest + +from wolfbot.master.tts_playback import MasterTtsPlayback +from wolfbot.npc.tts import FakeTtsService, TtsResult + + +@dataclass +class _MiniArbiter: + """Minimal arbiter stand-in carrying just the `_active_playback` + surface MasterTtsPlayback touches.""" + + _active_playback: set[str] = field(default_factory=set) + + +def _make_playback() -> MasterTtsPlayback: + return MasterTtsPlayback( + tts=FakeTtsService(default=TtsResult(audio=b"WAVE", duration_ms=100)), + voice_id="47", + vc_ref=[None], # No real VC — speak() falls through silently. + ) + + +async def test_suppress_npc_dispatch_holds_sentinel_during_block() -> None: + arb = _MiniArbiter() + pb = _make_playback() + async with pb.suppress_npc_dispatch(arb): + # While the with-block is open, exactly one master sentinel + # is parked on the arbiter — `is_blocked()` (the arbiter's + # public surface) would see this as queue_busy. + assert len(arb._active_playback) == 1 + only = next(iter(arb._active_playback)) + assert only.startswith("master-narration-") + # After the block, the sentinel is removed. + assert arb._active_playback == set() + + +async def test_suppress_npc_dispatch_clears_on_exception() -> None: + arb = _MiniArbiter() + pb = _make_playback() + with pytest.raises(RuntimeError): + async with pb.suppress_npc_dispatch(arb): + raise RuntimeError("kaboom") + assert arb._active_playback == set() + + +async def test_suppress_npc_dispatch_waits_for_npc_playback_drain() -> None: + """An already-authorized NPC playback must finish before Master + speaks. We seed a non-master entry in `_active_playback`, then + enter the suppress context — it must block until we clear it.""" + arb = _MiniArbiter(_active_playback={"sr_npc_pending_xyz"}) + pb = _make_playback() + entered = asyncio.Event() + finished = asyncio.Event() + + async def _enter() -> None: + async with pb.suppress_npc_dispatch(arb): + entered.set() + finished.set() + + task = asyncio.create_task(_enter()) + # Give the task a moment to start; it must NOT have entered yet. + await asyncio.sleep(0.1) + assert not entered.is_set(), "must wait for NPC playback to drain" + # Drain the NPC entry — Master should now proceed. + arb._active_playback.discard("sr_npc_pending_xyz") + await asyncio.wait_for(finished.wait(), timeout=2.0) + await task + assert arb._active_playback == set() + + +async def test_drain_wait_times_out_gracefully() -> None: + """If an NPC entry is leaked / stuck, Master narration eventually + gives up rather than deadlocking the engine.""" + arb = _MiniArbiter(_active_playback={"sr_stuck_forever"}) + pb = _make_playback() + + async def _quick_check() -> None: + # Use a short max_wait_s to keep the test fast. + await pb._wait_for_npc_playback_drain(arb, max_wait_s=0.3, poll_interval_s=0.05) + + await asyncio.wait_for(_quick_check(), timeout=2.0) + + +async def test_suppress_npc_dispatch_handles_missing_arbiter_attribute() -> None: + """If the arbiter shape ever changes and `_active_playback` is + absent, the context must not crash — narration is more important + than gate-management on a malformed arbiter.""" + + class _Broken: + pass + + pb = _make_playback() + async with pb.suppress_npc_dispatch(_Broken()): + pass # No assertion — just verify no exception. + + +async def test_speak_returns_false_when_vc_disconnected() -> None: + """speak() with no VC client wired must fail soft (return False) + rather than crash the engine.""" + pb = _make_playback() + ok = await pb.speak("テスト") + assert ok is False + + +async def test_concurrent_master_sentinels_do_not_block_each_other() -> None: + """Master narrations are serialized by the playback's own asyncio + Lock — but the drain-wait must ignore other master sentinels + (otherwise back-to-back narrations would deadlock waiting for + each other).""" + arb = _MiniArbiter(_active_playback={"master-narration-already"}) + pb = _make_playback() + + async def _quick_drain() -> None: + await pb._wait_for_npc_playback_drain(arb, max_wait_s=0.3, poll_interval_s=0.05) + + # Should return promptly; if it counted master sentinels as blockers, + # this would hit the timeout. + await asyncio.wait_for(_quick_drain(), timeout=1.0) diff --git a/tests/test_master_ws_server.py b/tests/test_master_ws_server.py index 5ae5f5a..d5724f7 100644 --- a/tests/test_master_ws_server.py +++ b/tests/test_master_ws_server.py @@ -355,6 +355,60 @@ async def test_master_ingest_accepts_human_speaker(repo: SqliteRepo) -> None: assert len(baselines) == 1 +async def test_master_ingest_drops_dead_speaker_stt(repo: SqliteRepo) -> None: + """Defensive alive check: a delayed STT result for a player who + died mid-segment must not produce a SpeechEvent. The voice-ingest + VAD entry already filters dead audio, but a slow Gemini response + could land after death — Master is the last line of defense.""" + g = Game( + id="g_dead", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + await repo.insert_seat( + "g_dead", + Seat(seat_no=4, display_name="Dead", discord_user_id="u4", is_llm=False, persona_key=None), + ) + await repo.set_player_role("g_dead", 4, Role.VILLAGER) + registry = InMemoryNpcRegistry() + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + # alive_seats does NOT include seat 4 — i.e. they're already dead. + lookup = _StubPhaseLookup( + {"g_dead": (Phase.DAY_DISCUSSION, 1)}, + alive_seats={"g_dead": []}, + ) + svc = MasterIngestService( + registry=registry, discussion=discussion, phase_lookup=lookup + ) + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id="g_dead", + phase_id=make_phase_id("g_dead", 1, Phase.DAY_DISCUSSION), + seat_no=4, + speaker_discord_user_id="u4", + segment_id="s_late", + text="まだ生きてる…", + confidence=0.9, + duration_ms=500, + audio_start_ms=0, + audio_end_ms=500, + ) + event, reason = await svc.ingest_voice(payload) + assert event is None + assert reason == "dead_speaker_discarded" + # Critically: NO SpeechEvent row got persisted (not even baseline). + rows = await store.load_for_game("g_dead") + assert rows == [] + + async def test_master_ingest_ignores_caller_phase_id(repo: SqliteRepo) -> None: """MasterIngestService must compute the canonical phase_id on Master, not trust the caller-supplied value from the voice-ingest worker.""" diff --git a/tests/test_permission_manager.py b/tests/test_permission_manager.py index 984d01b..7094561 100644 --- a/tests/test_permission_manager.py +++ b/tests/test_permission_manager.py @@ -113,9 +113,11 @@ def _setup_world() -> tuple[FakeBot, FakeGuild, dict[str, FakeChannel], Game]: main = FakeChannel(id=1001, guild=guild) heaven = FakeChannel(id=1002, guild=guild) wolves = FakeChannel(id=1003, guild=guild) + vc = FakeChannel(id=9999, guild=guild) guild._channels[1001] = main guild._channels[1002] = heaven guild._channels[1003] = wolves + guild._channels[9999] = vc bot = FakeBot(_guilds={1: guild}) game = Game( id="g", @@ -129,7 +131,7 @@ def _setup_world() -> tuple[FakeBot, FakeGuild, dict[str, FakeChannel], Game]: wolves_channel_id="1003", created_at=0, ) - return bot, guild, {"main": main, "heaven": heaven, "wolves": wolves}, game + return bot, guild, {"main": main, "heaven": heaven, "wolves": wolves, "vc": vc}, game async def test_apply_day_grants_alive_send_dead_read_only() -> None: @@ -228,6 +230,58 @@ async def test_kill_flips_main_text_and_grants_heaven() -> None: assert 100 + 5 not in dict(ch["wolves"]._perm_calls) +async def test_kill_revokes_vc_speak_permission() -> None: + """Dead players must not be able to speak in VC. We use channel + overrides (`speak=False, use_voice_activation=False`) rather than + server-mute so the override is scoped to this game's VC.""" + bot, _, ch, game = _setup_world() + pm = PermissionManager(bot=bot) + seats = _nine_seats() + + await pm.kill(game, seats, seat_no=5, was_wolf=False) + + vc_calls = dict(ch["vc"]._perm_calls) + assert vc_calls[100 + 5]["speak"] is False + assert vc_calls[100 + 5]["use_voice_activation"] is False + + +async def test_apply_reconciles_vc_speak_for_dead_seats() -> None: + """`apply` is the recovery path. After Master restart, dead seats + must still be VC-muted via channel overrides.""" + bot, _, ch, game = _setup_world() + pm = PermissionManager(bot=bot) + seats = _nine_seats() + alive_flags = [True, True, True, False, True, True, True, True, True] + players = _players(ROLES, alive=alive_flags) + + await pm.apply(game, seats, players) + + vc_calls = dict(ch["vc"]._perm_calls) + # Dead seat 4 → muted + assert vc_calls[100 + 4]["speak"] is False + # Alive seats → speak permitted + assert vc_calls[100 + 1]["speak"] is True + + +async def test_on_game_end_clears_vc_overrides() -> None: + """VC is a persistent channel; per-member overrides must be cleared + on game end so the next game starts from a clean baseline.""" + bot, _, ch, game = _setup_world() + pm = PermissionManager(bot=bot) + seats = _nine_seats() + + # Pre-seed an override on the VC for seat 1 to make sure clear runs. + ch["vc"]._overwrites[101] = discord.PermissionOverwrite(speak=False) + + await pm.on_game_end(game, seats) + + # `_clear_perms` calls set_permissions with overwrite=None. + cleared = [ + uid for uid, kw in ch["vc"]._perm_calls if kw.get("overwrite") is None + ] + assert 101 in cleared + + async def test_kill_wolf_revokes_wolves_chat_access() -> None: bot, _, ch, game = _setup_world() pm = PermissionManager(bot=bot) From c1847b090443d381d449197fe3dd9e974506e5f1 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 10:40:34 +0900 Subject: [PATCH 021/133] fix(mock): unblock end-to-end run-bots --mock loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- scripts/run-bots.sh | 5 +++++ src/wolfbot/domain/state_machine.py | 14 ++++++++++++-- src/wolfbot/npc/main.py | 20 +++++++++++++++++++- src/wolfbot/services/llm_service.py | 20 +++++++++++++++++++- tests/test_llm_mock_decider.py | 13 ++++++++++++- 5 files changed, 67 insertions(+), 5 deletions(-) diff --git a/scripts/run-bots.sh b/scripts/run-bots.sh index 1e81902..2d3d7d0 100755 --- a/scripts/run-bots.sh +++ b/scripts/run-bots.sh @@ -229,6 +229,11 @@ for persona in "${PERSONAS[@]}"; do "${persona}" \ "${LOG_DIR}/${persona}.log" \ "${MOCK_ENV_PREFIX}WOLFBOT_NPC_ENV=envs/npc/.env.${persona} '${WOLFBOT_NPC_BIN}'" + # Small stagger so 9 NPCs don't all finish Discord login and slam + # Master's WS accept() in the same instant. Master + the NPC retry + # loop both tolerate concurrency, but staggering avoids the easy + # ECONNREFUSED race entirely. + sleep 0.4 done # Land on the master window when the user attaches. diff --git a/src/wolfbot/domain/state_machine.py b/src/wolfbot/domain/state_machine.py index 5baa1fd..e65232e 100644 --- a/src/wolfbot/domain/state_machine.py +++ b/src/wolfbot/domain/state_machine.py @@ -105,14 +105,22 @@ def _name(seats: Mapping[int, Seat], seat_no: int) -> str: def _public_log( - game: Game, kind: str, text: str, now_epoch: int, phase: Phase | None = None + game: Game, + kind: str, + text: str, + now_epoch: int, + phase: Phase | None = None, + actor_seat: int | None = None, ) -> LogEntry: + # `actor_seat` is forwarded so EXECUTION (target) and MORNING (victim) + # carry the affected seat. Master Levi narration reads this field to + # voice the actual seat label instead of "対象不明". return LogEntry( game_id=game.id, day=game.day_number, phase=phase or game.phase, kind=kind, - actor_seat=None, + actor_seat=actor_seat, visibility="PUBLIC", text=text, created_at=now_epoch, @@ -543,6 +551,7 @@ def _apply_execution( kind="EXECUTION", text=f"{exec_name} が処刑されました。{tally_suffix}", now_epoch=now_epoch, + actor_seat=executed_seat, ), ) updates = ( @@ -795,6 +804,7 @@ def plan_night_resolve( text=morning_text, now_epoch=now_epoch, phase=Phase.DAY_DISCUSSION, + actor_seat=killed_seat, ), ) diff --git a/src/wolfbot/npc/main.py b/src/wolfbot/npc/main.py index 4e5904a..06b44d8 100644 --- a/src/wolfbot/npc/main.py +++ b/src/wolfbot/npc/main.py @@ -192,7 +192,25 @@ def _after(error: Exception | None) -> None: f"{base_url}{sep}role=npc" f"&psk={settings.MASTER_NPC_PSK.get_secret_value()}" ) - ws = await websockets.connect(ws_url) + # Retry transient ECONNREFUSED so a startup race (Master still binding, + # or 9 NPCs hammering accept() at once) doesn't kill the worker. After + # ~15s of consistent refusal we surface the error — that's a real + # configuration problem, not a race. + ws = None + last_err: BaseException | None = None + for attempt in range(10): + try: + ws = await websockets.connect(ws_url) + break + except (ConnectionRefusedError, OSError) as e: + last_err = e + log.warning( + "npc_ws_connect_refused attempt=%d url=%s err=%r", attempt + 1, base_url, e + ) + await asyncio.sleep(min(0.5 * (attempt + 1), 3.0)) + if ws is None: + assert last_err is not None + raise last_err async def _ws_send(msg: str) -> None: await ws.send(msg) diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index 1df58bd..abc8b10 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -372,9 +372,14 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: confidence=0.5, ) if "対象を 1 名選んでください" in user_context: + # Deterministic target = smallest 席N appearing in the candidate + # list. For WOLF_ATTACK both wolves see the *same* candidate list + # (legal_attack_targets excludes all wolves), so they converge on + # one target — avoiding the wolf-attack split that would otherwise + # park the mock game in WAITING_HOST_DECISION every night. return LLMAction( intent="night_action", - target_name=None, + target_name=self._pick_smallest_seat_token(user_context), reason_summary="mock night action", confidence=0.5, ) @@ -392,6 +397,19 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: confidence=0.5, ) + @staticmethod + def _pick_smallest_seat_token(user_context: str) -> str | None: + # Parse only the "合法候補:" line — the rest of the prompt contains + # an example token ("例: `席3 Alice`") that would otherwise pollute + # the seat list and cause us to "pick" a non-candidate. + match = re.search(r"合法候補:[ \t]*([^\n]*)", user_context) + if match is None: + return None + seats = [int(m) for m in re.findall(r"席(\d+)", match.group(1))] + if not seats: + return None + return f"席{min(seats)}" + class FakeLLMActionDecider: """Deterministic stub. Returns ACTIONS[n] round-robin per call.""" diff --git a/tests/test_llm_mock_decider.py b/tests/test_llm_mock_decider.py index 90dd617..fb663a9 100644 --- a/tests/test_llm_mock_decider.py +++ b/tests/test_llm_mock_decider.py @@ -36,11 +36,22 @@ async def test_mock_decider_returns_vote_intent_with_null_target() -> None: assert result.target_name is None -async def test_mock_decider_returns_night_action_with_null_target() -> None: +async def test_mock_decider_returns_night_action_with_smallest_seat() -> None: decider = MockLLMActionDecider() user_ctx = task_night_action(SubmissionType.WOLF_ATTACK, CANDIDATES) result = await decider.decide(system_prompt="", user_context=user_ctx) assert result.intent == "night_action" + # Smallest 席N picked from the candidate list so both wolves converge + # on the same target — otherwise mock attacks split and park the game + # in WAITING_HOST_DECISION every night. + assert result.target_name == "席1" + + +async def test_mock_decider_night_action_falls_back_to_none_when_no_seats() -> None: + decider = MockLLMActionDecider() + user_ctx = task_night_action(SubmissionType.WOLF_ATTACK, ()) + result = await decider.decide(system_prompt="", user_context=user_ctx) + assert result.intent == "night_action" assert result.target_name is None From 7998378d37ea8f84f5ffeb0d50f32ecc41e573a5 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 11:05:25 +0900 Subject: [PATCH 022/133] feat(master): voice opening narration into populated VC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/main.py | 222 +++++++++++++++++++----- src/wolfbot/services/discord_service.py | 16 ++ 2 files changed, 193 insertions(+), 45 deletions(-) diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 40c7118..0c8534a 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -6,6 +6,7 @@ import contextlib import logging import signal +import time from typing import Any import discord @@ -185,6 +186,112 @@ async def _master_leave_vc() -> None: finally: master_vc_ref[0] = None + async def _assign_online_npcs_to_seats(game_id: str) -> int: + """Pair online NPC bots with unassigned LLM seats and tell them to + join VC. Idempotent (already-assigned NPCs are skipped). Returns + the number of NEW assignments dispatched in this call — callers + use it to decide whether to wait for VC join confirmations. + """ + if not _npc_registry_ref or _vc_phase_cache[0] is None: + return 0 + from wolfbot.domain.ws_messages import SeatAssigned + + _game_id, phase_id = _vc_phase_cache[0] + npc_reg = _npc_registry_ref[0] + seats = await repo.load_seats(game_id) + llm_seats = [s for s in seats if s.is_llm] + online = npc_reg.all_online() + assigned_npc_ids = { + e.npc_id for e in online if e.assigned_seat is not None and e.game_id == game_id + } + unassigned_npcs = [e for e in online if e.npc_id not in assigned_npc_ids] + unassigned_seats = [ + s for s in llm_seats + if not any(e.assigned_seat == s.seat_no and e.game_id == game_id for e in online) + ] + dispatched = 0 + for npc_entry, seat in zip(unassigned_npcs, unassigned_seats, strict=False): + npc_reg.assign(npc_entry.npc_id, seat=seat.seat_no, + game_id=game_id, phase_id=phase_id) + log.info("npc_seat_assigned npc=%s seat=%d game=%s", + npc_entry.npc_id, seat.seat_no, game_id) + # Tell the picked bot to join VC. Unselected NPCs receive + # nothing and stay idle (no VC). + if npc_entry.send is not None: + try: + msg = SeatAssigned( + ts=int(asyncio.get_running_loop().time() * 1000), + trace_id=f"assign-{game_id}-{seat.seat_no}", + npc_id=npc_entry.npc_id, + seat_no=seat.seat_no, + game_id=game_id, + phase_id=phase_id, + ) + await npc_entry.send(msg.model_dump_json()) + dispatched += 1 + except Exception: + log.exception( + "seat_assigned_send_failed npc=%s seat=%d", + npc_entry.npc_id, + seat.seat_no, + ) + return dispatched + + async def _wait_for_npcs_in_vc(expected_count: int, timeout: float = 5.0) -> None: + """Block until `expected_count` bots are visible in Master's VC. + + After SeatAssigned dispatch, NPCs join VC asynchronously (Discord + connect ~1-1.5s each). Without this wait, the SETUP_COMPLETE + narration starts speaking into an empty channel. We poll the + VoiceChannel.members list (counting bots, excluding the Master + itself) and bail on timeout so a single slow NPC doesn't stall + game start indefinitely. + """ + if expected_count <= 0: + return + deadline = time.time() + timeout + while time.time() < deadline: + vc = master_vc_ref[0] + channel = getattr(vc, "channel", None) if vc is not None else None + members = getattr(channel, "members", None) + if members is not None: + bot_user = bot.user + count = sum( + 1 for m in members if getattr(m, "bot", False) and m != bot_user + ) + if count >= expected_count: + log.info("npc_vc_join_confirmed count=%d", count) + return + await asyncio.sleep(0.25) + log.info( + "npc_vc_join_timeout expected=%d after=%.1fs", expected_count, timeout + ) + + async def _on_reactive_game_start(game_id: str) -> None: + """Pre-game NPC + Master VC setup, called from `/wolf start`. + + Without this, NPCs only join VC on DAY_DISCUSSION entry — too + late for the SETUP_COMPLETE / day-1 PHASE_CHANGE narration to + be heard from a populated channel. We: + 1. Cache phase_id (needed for SeatAssigned), + 2. Master joins VC (so it can voice the announcement), + 3. Assign online NPCs to LLM seats + dispatch SeatAssigned + so they join VC, + 4. Wait briefly for the NPC VC joins to land. + Idempotent — `_on_reactive_phase_enter` re-invokes the same + helpers later and finds nothing left to do. + """ + g = await repo.load_game(game_id) + if g is None or g.ended_at is not None: + return + if g.discussion_mode != "reactive_voice": + return + await _refresh_voice_ingest_cache(game_id) + await _master_join_vc_for_game(g) + dispatched = await _assign_online_npcs_to_seats(game_id) + if dispatched > 0: + await _wait_for_npcs_in_vc(dispatched) + async def _on_reactive_phase_enter(game_id: str) -> None: await _refresh_voice_ingest_cache(game_id) # Master joins the game's VC the first time the game enters a @@ -218,47 +325,10 @@ async def _on_reactive_phase_enter(game_id: str) -> None: game_id, g_for_vc.phase, ) - # Assign online NPC bots to their game seats so the arbiter can pick them. - if _npc_registry_ref and _vc_phase_cache[0] is not None: - from wolfbot.domain.ws_messages import SeatAssigned - - _game_id, phase_id = _vc_phase_cache[0] - npc_reg = _npc_registry_ref[0] - seats = await repo.load_seats(game_id) - llm_seats = [s for s in seats if s.is_llm] - online = npc_reg.all_online() - # Pair each online NPC bot with an unassigned LLM seat (round-robin). - assigned_npc_ids = { - e.npc_id for e in online if e.assigned_seat is not None and e.game_id == game_id} - unassigned_npcs = [ - e for e in online if e.npc_id not in assigned_npc_ids] - unassigned_seats = [s for s in llm_seats if not any( - e.assigned_seat == s.seat_no and e.game_id == game_id for e in online - )] - for npc_entry, seat in zip(unassigned_npcs, unassigned_seats, strict=False): - npc_reg.assign(npc_entry.npc_id, seat=seat.seat_no, - game_id=game_id, phase_id=phase_id) - log.info("npc_seat_assigned npc=%s seat=%d game=%s", - npc_entry.npc_id, seat.seat_no, game_id) - # Tell the picked bot to join VC. Unselected NPCs receive - # nothing and stay idle (no VC). - if npc_entry.send is not None: - try: - msg = SeatAssigned( - ts=int(asyncio.get_running_loop().time() * 1000), - trace_id=f"assign-{game_id}-{seat.seat_no}", - npc_id=npc_entry.npc_id, - seat_no=seat.seat_no, - game_id=game_id, - phase_id=phase_id, - ) - await npc_entry.send(msg.model_dump_json()) - except Exception: - log.exception( - "seat_assigned_send_failed npc=%s seat=%d", - npc_entry.npc_id, - seat.seat_no, - ) + # Assign online NPC bots to their game seats so the arbiter can + # pick them. No-op when `/wolf start`'s pre-game callback already + # claimed them. + await _assign_online_npcs_to_seats(game_id) if _reactive_phase_cb: await _reactive_phase_cb[0].try_dispatch_next(game_id) @@ -345,6 +415,7 @@ async def _on_speech_recorded(game_id: str) -> None: on_speech_recorded=_on_speech_recorded, npc_registry=npc_registry, text_analyzer=text_analyzer, + on_reactive_game_start=_on_reactive_game_start, ) await bot.add_cog(cog) bot.tree.add_command(cog.wolf, guild=discord.Object( @@ -449,6 +520,54 @@ async def _post_to_vc_chat(game: Any, text: str) -> None: getattr(channel, "id", None), ) + async def _push_deadline_after_narration(game_id: str) -> None: + """Push the active phase's `deadline_epoch` to `now + duration`. + + Called from the narrator after TTS playback finishes. Without + this, mock-mode short phases (vote=6s) get fully consumed by a + 15s TTS announcement and the engine advances before any NPC / + human can act. Real-time phases also benefit — the phase clock + now starts from when Levi finishes speaking. + + Only pushes forward (never backward), and only when the phase + currently has a deadline (transient SETUP / NIGHT_0 / + WAITING_HOST_DECISION / GAME_OVER skip). + """ + from wolfbot.domain.durations import current_phase_durations + + fresh = await repo.load_game(game_id) + if fresh is None or fresh.ended_at is not None: + return + if fresh.deadline_epoch is None: + return + durations = current_phase_durations() + duration_for: dict[Phase, int] = { + Phase.DAY_DISCUSSION: durations.discussion_for_day( + max(1, fresh.day_number) + ), + Phase.DAY_VOTE: durations.vote, + Phase.DAY_RUNOFF: durations.runoff, + Phase.DAY_RUNOFF_SPEECH: durations.runoff_speech_grace, + Phase.NIGHT: durations.night, + } + duration = duration_for.get(fresh.phase) + if duration is None: + return + new_deadline = int(time.time()) + duration + if new_deadline <= fresh.deadline_epoch: + # TTS finished within the original budget — no push needed. + return + await repo.set_deadline(fresh.id, new_deadline) + log.info( + "master_tts_deadline_pushed game=%s phase=%s " + "old=%d new=%d delta=+%ds", + fresh.id, + fresh.phase.value, + fresh.deadline_epoch, + new_deadline, + new_deadline - fresh.deadline_epoch, + ) + async def _master_narrate(game: Any, kind: str, text: str) -> bool: """Public-post narrator: voice + VC chat in reactive_voice mode. @@ -461,10 +580,16 @@ async def _master_narrate(game: Any, kind: str, text: str) -> bool: if game.discussion_mode != "reactive_voice": return False if master_vc_ref[0] is None: - # Game is reactive_voice but Master never joined VC (e.g. - # voice_ingest is off). Fall through to text so progress - # still surfaces somewhere. - return False + # Lazy-join: SETUP_COMPLETE and the day-1 PHASE_CHANGE are + # posted *before* `_on_reactive_phase_enter` runs, so on the + # very first transition Master isn't in VC yet. Join here + # so opening narrations are voiced instead of falling + # through to text. Idempotent for later log entries. + await _master_join_vc_for_game(game) + if master_vc_ref[0] is None: + # Join failed (voice_ingest off, channel resolution + # failed, etc.) — fall through to text. + return False from wolfbot.domain.models import LogEntry as _LogEntry players = await repo.load_players(game.id) @@ -515,6 +640,13 @@ async def _master_narrate(game: Any, kind: str, text: str) -> bool: if output.voice_text: async with master_tts.suppress_npc_dispatch(arbiter): await master_tts.speak(output.voice_text) + # Phase deadlines are committed in apply_transition based + # on `now` at plan_next time, so a 15s TTS playback eats + # straight into the next phase's budget — in mock mode the + # vote/runoff window can elapse before NPCs even start. + # After the announcement plays, push the deadline forward + # so the phase clock starts from when Levi finishes. + await _push_deadline_after_narration(game.id) # After Master narrates, give NPC dispatch a kick — a # PHASE_CHANGE into DAY_DISCUSSION should immediately # invite an NPC reply. diff --git a/src/wolfbot/services/discord_service.py b/src/wolfbot/services/discord_service.py index b24a094..8cdb443 100644 --- a/src/wolfbot/services/discord_service.py +++ b/src/wolfbot/services/discord_service.py @@ -458,6 +458,7 @@ def __init__( on_speech_recorded: Callable[[str], Awaitable[None]] | None = None, npc_registry: NpcRegistry | None = None, text_analyzer: Any = None, + on_reactive_game_start: Callable[[str], Awaitable[None]] | None = None, ) -> None: super().__init__() self.bot = bot @@ -482,6 +483,10 @@ def __init__( # is active and a Voice LLM key is set; falls back to plain raw # capture when None. self._text_analyzer = text_analyzer + # Pre-engine reactive_voice setup hook. Wired in main.py for + # reactive_voice mode; runs right after `claim_start_and_backfill` + # so Master + NPCs are in VC before SETUP_COMPLETE narration plays. + self._on_reactive_game_start = on_reactive_game_start def _select_llm_seat_personas( self, @@ -928,6 +933,17 @@ async def start(self, interaction: discord.Interaction) -> None: final_seats = await self.repo.load_seats(game.id) roster_lines = [f"席{seat.seat_no} {seat.display_name}" for seat in final_seats] + # Pre-engine reactive_voice hook: invite Master + NPC bots into VC + # *before* the engine fires SETUP_COMPLETE narration. Without this + # the day-1 announcement plays into an empty channel because NPCs + # only join VC on DAY_DISCUSSION entry. Best-effort — engine still + # starts on failure so the game can progress in text fallback. + if self._on_reactive_game_start is not None: + try: + await self._on_reactive_game_start(game.id) + except Exception: + log.exception("on_reactive_game_start failed for %s", game.id) + engine = GameEngine(game_id=game.id, repo=self.repo, advance=self.gs.advance) await self.registry.attach(engine) engine.start() From b1f1bf086d95ff07854f56a3eaf02dcf9eeb8cb9 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 11:56:30 +0900 Subject: [PATCH 023/133] fix(mock): scan system prompt + user context for task keywords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/services/llm_service.py | 19 +++++++++++-------- tests/test_llm_mock_decider.py | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index abc8b10..2af1adc 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -362,16 +362,19 @@ def _next_wolf_chat(self) -> str: async def decide(self, system_prompt: str, user_context: str) -> LLMAction: self.call_count += 1 - # The user_context is built by prompt_builder.task_*; each task has - # an unambiguous unique phrase. - if "投票先として合法な候補は" in user_context: + # `task_text` is injected into the *system* prompt (see + # `build_system_prompt`'s `{task_block}`), not the user context. + # We scan both so the dispatch keys work regardless of where a + # caller decides to put them. + haystack = f"{system_prompt}\n{user_context}" + if "投票先として合法な候補は" in haystack: return LLMAction( intent="vote", target_name=None, reason_summary="mock vote", confidence=0.5, ) - if "対象を 1 名選んでください" in user_context: + if "対象を 1 名選んでください" in haystack: # Deterministic target = smallest 席N appearing in the candidate # list. For WOLF_ATTACK both wolves see the *same* candidate list # (legal_attack_targets excludes all wolves), so they converge on @@ -379,11 +382,11 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: # park the mock game in WAITING_HOST_DECISION every night. return LLMAction( intent="night_action", - target_name=self._pick_smallest_seat_token(user_context), + target_name=self._pick_smallest_seat_token(haystack), reason_summary="mock night action", confidence=0.5, ) - if "人狼チャット" in user_context: + if "人狼チャット" in haystack: return LLMAction( intent="speak", public_message=self._next_wolf_chat(), @@ -398,11 +401,11 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: ) @staticmethod - def _pick_smallest_seat_token(user_context: str) -> str | None: + def _pick_smallest_seat_token(prompt: str) -> str | None: # Parse only the "合法候補:" line — the rest of the prompt contains # an example token ("例: `席3 Alice`") that would otherwise pollute # the seat list and cause us to "pick" a non-candidate. - match = re.search(r"合法候補:[ \t]*([^\n]*)", user_context) + match = re.search(r"合法候補:[ \t]*([^\n]*)", prompt) if match is None: return None seats = [int(m) for m in re.findall(r"席(\d+)", match.group(1))] diff --git a/tests/test_llm_mock_decider.py b/tests/test_llm_mock_decider.py index fb663a9..03f5649 100644 --- a/tests/test_llm_mock_decider.py +++ b/tests/test_llm_mock_decider.py @@ -55,6 +55,24 @@ async def test_mock_decider_night_action_falls_back_to_none_when_no_seats() -> N assert result.target_name is None +async def test_mock_decider_dispatches_when_task_text_is_in_system_prompt() -> None: + """Regression: the real `_ask` path puts task_text into the *system* + prompt (via `build_system_prompt`'s `{task_block}`). If the mock only + checked user_context it would silently fall through to canned speech + on every vote / night submission, target_name would default to None, + and `_resolve_target` would log "LLM returned null target" and pick + randomly — exactly the bug that broke wolf-attack convergence.""" + decider = MockLLMActionDecider() + night_task = task_night_action(SubmissionType.WOLF_ATTACK, CANDIDATES) + result = await decider.decide(system_prompt=night_task, user_context="user ctx") + assert result.intent == "night_action" + assert result.target_name == "席1" + + vote_task = task_vote(CANDIDATES, runoff=False) + result = await decider.decide(system_prompt=vote_task, user_context="user ctx") + assert result.intent == "vote" + + async def test_mock_decider_returns_daytime_speech() -> None: decider = MockLLMActionDecider() user_ctx = task_daytime_speech(day_number=1, discussion_round=1, role=Role.VILLAGER) From d57bcddc1bded3cbb5f4b30876881fb36c8c8706 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 11:56:52 +0900 Subject: [PATCH 024/133] feat(vc): phase-aware mute for humans + self-mute NPC bots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/domain/ws_messages.py | 17 ++++ src/wolfbot/main.py | 3 + src/wolfbot/npc/client.py | 25 ++++++ src/wolfbot/npc/main.py | 32 +++++++- src/wolfbot/services/discord_service.py | 95 ++++++++++++++++++++++ src/wolfbot/services/permission_manager.py | 25 ++++-- tests/test_npc_seat_lifecycle.py | 48 +++++++++++ tests/test_permission_manager.py | 29 ++++++- 8 files changed, 263 insertions(+), 11 deletions(-) diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index 3d37245..ab76ff8 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -79,6 +79,22 @@ class SeatReleased(BaseEnvelope): reason: str = "game_ended" +class SetMuteState(BaseEnvelope): + """Master → NPC: set this bot's voice *self*-mute. + + Self-mute (vs. server-mute via ``Member.edit(mute=...)``) sidesteps + both the MUTE_MEMBERS permission requirement and Discord's role + hierarchy rule that the moderator's role must be strictly above the + target. Each NPC owns its own voice state, so it can flip self-mute + via gateway opcode without any admin power. Used to visually flag + dead seats and non-discussion phases — viewers see a mic-muted icon + next to dead/idle bots.""" + + type: Literal["set_mute_state"] = "set_mute_state" + npc_id: str + self_mute: bool + + class Heartbeat(BaseEnvelope): type: Literal["heartbeat"] = "heartbeat" npc_id: str | None = None # populated by NPC bots; None for voice-ingest @@ -286,6 +302,7 @@ class HandshakeError(BaseEnvelope): "RegistryUpdate", "SeatAssigned", "SeatReleased", + "SetMuteState", "SpeakRequest", "SpeakResult", "SpeechEventPayload", diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 0c8534a..a2a1993 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -48,6 +48,8 @@ async def _run() -> None: registry = EngineRegistry() discord_adapter = DiscordBotAdapter(bot=bot, repo=repo, settings=settings) + # NpcRegistry is created right below; late-bind so `apply_permissions` + # can server-mute NPC bots on phase changes / death. # NPC registry is always created so /wolf start can consult it for # reactive_voice seat backfill. The WS server (which actually accepts @@ -55,6 +57,7 @@ async def _run() -> None: from wolfbot.master.npc_registry import InMemoryNpcRegistry npc_registry = InMemoryNpcRegistry() + discord_adapter.set_npc_registry(npc_registry) speech_store = SqliteSpeechEventStore(repo._db) diff --git a/src/wolfbot/npc/client.py b/src/wolfbot/npc/client.py index 5675e4d..76927de 100644 --- a/src/wolfbot/npc/client.py +++ b/src/wolfbot/npc/client.py @@ -33,6 +33,7 @@ PlaybackRejected, SeatAssigned, SeatReleased, + SetMuteState, SpeakRequest, TtsFailed, TtsFinished, @@ -91,6 +92,10 @@ class NpcClient: # leave. Optional so tests / pure-message-loop scenarios can omit them. on_vc_join: Callable[[], Awaitable[None]] | None = None on_vc_leave: Callable[[], Awaitable[None]] | None = None + # Self-mute hook: Master sends `set_mute_state` to flip the bot's own + # voice self-mute (mic icon) so dead seats / non-discussion phases are + # visually obvious. Optional so tests can omit it. + on_set_mute: Callable[[bool], Awaitable[None]] | None = None _logic_cache: dict[str, LogicPacket] = field(default_factory=dict) _pending_playback: dict[str, _PendingForPlayback] = field(default_factory=dict) @@ -137,6 +142,8 @@ async def process_message(self, raw_json: str) -> None: await self._on_seat_assigned(SeatAssigned.model_validate(payload)) elif t == "seat_released": await self._on_seat_released(SeatReleased.model_validate(payload)) + elif t == "set_mute_state": + await self._on_set_mute_state(SetMuteState.model_validate(payload)) elif t == "logic_packet": self._on_logic_packet(LogicPacket.model_validate(payload)) elif t == "speak_request": @@ -189,6 +196,24 @@ async def _on_seat_assigned(self, msg: SeatAssigned) -> None: msg.seat_no, ) + async def _on_set_mute_state(self, msg: SetMuteState) -> None: + if msg.npc_id != self.config.npc_id: + return + log.info( + "npc_set_mute_state npc_id=%s self_mute=%s", + msg.npc_id, + msg.self_mute, + ) + if self.on_set_mute is not None: + try: + await self.on_set_mute(msg.self_mute) + except Exception: + log.exception( + "npc_set_mute_failed npc_id=%s self_mute=%s", + msg.npc_id, + msg.self_mute, + ) + async def _on_seat_released(self, msg: SeatReleased) -> None: log.info( "npc_seat_released npc_id=%s game=%s reason=%s", diff --git a/src/wolfbot/npc/main.py b/src/wolfbot/npc/main.py index 06b44d8..8654017 100644 --- a/src/wolfbot/npc/main.py +++ b/src/wolfbot/npc/main.py @@ -105,13 +105,40 @@ async def _ensure_vc_joined() -> None: ) return try: - vc_client_ref[0] = await vc_channel.connect() - log.info("npc_vc_joined channel=%s", settings.MAIN_VOICE_CHANNEL_ID) + # Connect already self-muted so the moment the bot lands in + # VC viewers see the mic-muted icon. Master flips it to + # `self_mute=False` via `set_mute_state` once the game + # enters DAY_DISCUSSION for an alive seat. + vc_client_ref[0] = await vc_channel.connect(self_mute=True) + log.info( + "npc_vc_joined channel=%s self_mute=True", + settings.MAIN_VOICE_CHANNEL_ID, + ) except Exception: log.exception( "npc_vc_join_failed channel=%s", settings.MAIN_VOICE_CHANNEL_ID ) + async def _set_self_mute(self_mute: bool) -> None: + """Flip the bot's own voice self-mute via the gateway.""" + async with vc_lock: + vc = vc_client_ref[0] + if vc is None or not vc.is_connected(): + return + guild = bot.get_guild(settings.DISCORD_GUILD_ID) + if guild is None: + return + channel = vc.channel + if channel is None: + return + try: + await guild.change_voice_state( + channel=channel, self_mute=self_mute, self_deaf=False + ) + log.info("npc_self_mute_applied self_mute=%s", self_mute) + except Exception: + log.exception("npc_self_mute_failed self_mute=%s", self_mute) + async def _ensure_vc_left() -> None: async with vc_lock: vc = vc_client_ref[0] @@ -229,6 +256,7 @@ async def _ws_send(msg: str) -> None: now_ms=_now_ms, on_vc_join=_ensure_vc_joined, on_vc_leave=_ensure_vc_left, + on_set_mute=_set_self_mute, ) # Register with Master diff --git a/src/wolfbot/services/discord_service.py b/src/wolfbot/services/discord_service.py index 8cdb443..31e572f 100644 --- a/src/wolfbot/services/discord_service.py +++ b/src/wolfbot/services/discord_service.py @@ -146,6 +146,10 @@ def __init__( # True to fully consume the post (suppress the main text-channel # output); False to fall through unchanged. self._narrator = public_post_narrator + # Late-bound: NPC registry (only set in reactive_voice mode) lets + # us look up bot user IDs to apply server-mute on non-discussion + # phases / death so dead NPCs visually show a red mute icon. + self._npc_registry: NpcRegistry | None = None def set_game_service(self, gs: GameService) -> None: self._gs_slot["gs"] = gs @@ -155,6 +159,10 @@ def set_narrator(self, narrator: PublicPostNarrator | None) -> None: pipeline is wired after `DiscordBotAdapter` construction.""" self._narrator = narrator + def set_npc_registry(self, npc_registry: NpcRegistry | None) -> None: + """Late-bind the NPC registry for bot-user server-mute reconciliation.""" + self._npc_registry = npc_registry + @property def gs(self) -> GameService: gs = self._gs_slot.get("gs") @@ -167,6 +175,7 @@ async def apply_permissions( self, game: Game, seats: Sequence[Seat], players: Sequence[Player] ) -> None: await self.perms.apply(game, seats, players) + await self._apply_npc_server_mute(game, players) async def kill_permissions( self, game: Game, seats: Sequence[Seat], seat_no: int, was_wolf: bool @@ -175,9 +184,95 @@ async def kill_permissions( async def reconcile(self, game: Game, seats: Sequence[Seat], players: Sequence[Player]) -> None: await self.perms.apply(game, seats, players) + await self._apply_npc_server_mute(game, players) async def on_game_end(self, game: Game, seats: Sequence[Seat]) -> None: await self.perms.on_game_end(game, seats) + # Clear server-mute on every NPC bot attached to this game so the + # next /wolf start finds them in a clean state. SeatReleased makes + # them disconnect, but Discord's server-mute flag persists across + # reconnects until cleared. + await self._clear_npc_server_mute(game) + + async def _apply_npc_server_mute( + self, game: Game, players: Sequence[Player] + ) -> None: + """Tell each NPC bot to flip its *own* voice self-mute via WS. + + We deliberately drive self-mute (not server-mute via + ``Member.edit(mute=...)``) because: + - server-mute requires MUTE_MEMBERS *and* the Master role to + sit strictly above every NPC bot's role in the guild + hierarchy — both fragile under typical multi-bot setups. + - self-mute is purely a per-client flag the bot owns; no + admin power needed. + + Each NPC's :class:`NpcClient` handles ``set_mute_state`` and + calls ``Guild.change_voice_state(self_mute=...)`` on its own + voice connection. Visible result: the mic-muted icon next to + dead seats / non-discussion-phase bots. + + Rule: a bot is unmuted only when its assigned seat is alive + AND the phase is DAY_DISCUSSION. Every other state mutes it. + """ + if self._npc_registry is None: + return + from wolfbot.domain.ws_messages import SetMuteState + + discussion_active = game.phase is Phase.DAY_DISCUSSION + player_by_seat = {p.seat_no: p for p in players} + now_ms = int(asyncio.get_running_loop().time() * 1000) + for entry in self._npc_registry.assigned_to_game(game.id): + seat_no = entry.assigned_seat + if seat_no is None or entry.send is None: + continue + player = player_by_seat.get(seat_no) + alive = True if player is None else bool(player.alive) + should_mute = not (alive and discussion_active) + try: + msg = SetMuteState( + ts=now_ms, + trace_id=f"mute-{game.id}-{seat_no}-{int(should_mute)}", + npc_id=entry.npc_id, + self_mute=should_mute, + ) + await entry.send(msg.model_dump_json()) + except Exception: + log.exception( + "npc_set_mute_send_failed npc=%s seat=%d self_mute=%s", + entry.npc_id, + seat_no, + should_mute, + ) + + async def _clear_npc_server_mute(self, game: Game) -> None: + """Send self_mute=False to every NPC attached to this game. + + Best-effort cleanup at game end. SeatReleased makes them leave + VC anyway, but this keeps the protocol in a consistent state in + case a bot stays connected for a brief window before leaving. + """ + if self._npc_registry is None: + return + from wolfbot.domain.ws_messages import SetMuteState + + now_ms = int(asyncio.get_running_loop().time() * 1000) + for entry in self._npc_registry.assigned_to_game(game.id): + if entry.send is None: + continue + try: + msg = SetMuteState( + ts=now_ms, + trace_id=f"mute-clear-{game.id}-{entry.npc_id}", + npc_id=entry.npc_id, + self_mute=False, + ) + await entry.send(msg.model_dump_json()) + except Exception: + log.exception( + "npc_set_mute_clear_failed npc=%s", + entry.npc_id, + ) # ------------------------------------------------------ channel posts async def _maybe_narrate(self, game: Game, kind: str, text: str) -> bool: diff --git a/src/wolfbot/services/permission_manager.py b/src/wolfbot/services/permission_manager.py index 99a0f24..15f3636 100644 --- a/src/wolfbot/services/permission_manager.py +++ b/src/wolfbot/services/permission_manager.py @@ -72,7 +72,9 @@ async def apply( except (TypeError, ValueError): vc_channel = None if vc_channel is not None: - await self._apply_vc_mute(vc_channel, seats, player_by_seat, guild) + await self._apply_vc_mute( + vc_channel, seats, player_by_seat, game.phase, guild + ) if game.heaven_channel_id: heaven = guild.get_channel(int(game.heaven_channel_id)) @@ -233,14 +235,24 @@ async def _apply_vc_mute( channel: Any, seats: Sequence[Seat], player_by_seat: dict[int, Player], + phase: Phase, guild: Any, ) -> None: - """Reconcile VC speak permission per seat: alive → speak, dead → muted. + """Reconcile VC speak permission per human seat. + + Phase rule: humans may speak only when alive AND the phase is + DAY_DISCUSSION. Every other phase (NIGHT, vote, runoff, runoff + speech) keeps everyone muted so chatter doesn't bleed across + phase boundaries. Dead players are always muted. We use channel-level `set_permissions(speak=False, use_voice_activation=False)` rather than `member.edit(mute=True)` so the override is scoped to - this game's VC and gets cleared in `on_game_end`. + this game's VC and gets cleared in `on_game_end`. NPC bots and + Master have no row in `seats` with a `discord_user_id`, so they + are never touched here — their TTS playback would break under a + channel-scoped speak deny. """ + discussion_active = phase is Phase.DAY_DISCUSSION for s in seats: if s.discord_user_id is None: continue @@ -249,11 +261,8 @@ async def _apply_vc_mute( continue p = player_by_seat.get(s.seat_no) alive = True if p is None else bool(p.alive) - if alive: - # Defensive: if a previous death set speak=False and a - # later refresh sees them alive again (shouldn't happen - # in this game's lifecycle, but possible during dev / - # tests), restore speak. + can_speak = alive and discussion_active + if can_speak: await self._set_perms( channel, member, diff --git a/tests/test_npc_seat_lifecycle.py b/tests/test_npc_seat_lifecycle.py index 66599ab..22fd2d9 100644 --- a/tests/test_npc_seat_lifecycle.py +++ b/tests/test_npc_seat_lifecycle.py @@ -26,6 +26,7 @@ NpcRegistered, SeatAssigned, SeatReleased, + SetMuteState, ) from wolfbot.master.npc_registry import InMemoryNpcRegistry from wolfbot.npc.client import NpcClient, NpcClientConfig @@ -149,6 +150,53 @@ async def test_npc_registered_with_assigned_seat_triggers_recovery_join() -> Non assert client.assigned_seat == 3 +async def test_set_mute_state_invokes_on_set_mute_for_self() -> None: + """`set_mute_state` flips the bot's voice self-mute via the + `on_set_mute` callback. Mismatched npc_id is ignored.""" + captured: list[bool] = [] + + async def on_set_mute(self_mute: bool) -> None: + captured.append(self_mute) + + probe = _VcCallbackProbe() + client = _make_client(probe) + client.on_set_mute = on_set_mute + + await client.process_message( + SetMuteState( + ts=1, trace_id="t", npc_id="npc_setsu", self_mute=True + ).model_dump_json() + ) + await client.process_message( + SetMuteState( + ts=2, trace_id="t", npc_id="npc_setsu", self_mute=False + ).model_dump_json() + ) + # Mismatched npc_id — must be a no-op. + await client.process_message( + SetMuteState( + ts=3, trace_id="t", npc_id="someone_else", self_mute=True + ).model_dump_json() + ) + assert captured == [True, False] + + +async def test_set_mute_state_callback_failure_does_not_propagate() -> None: + async def boom(_self_mute: bool) -> None: + raise RuntimeError("voice gateway unavailable") + + probe = _VcCallbackProbe() + client = _make_client(probe) + client.on_set_mute = boom + + # Must NOT raise. + await client.process_message( + SetMuteState( + ts=1, trace_id="t", npc_id="npc_setsu", self_mute=True + ).model_dump_json() + ) + + async def test_seat_assigned_join_failure_does_not_propagate() -> None: """A discord.connect() failure must be logged but never crash the message loop — the WS stays alive so Master can retry.""" diff --git a/tests/test_permission_manager.py b/tests/test_permission_manager.py index 7094561..9d476ea 100644 --- a/tests/test_permission_manager.py +++ b/tests/test_permission_manager.py @@ -259,10 +259,37 @@ async def test_apply_reconciles_vc_speak_for_dead_seats() -> None: vc_calls = dict(ch["vc"]._perm_calls) # Dead seat 4 → muted assert vc_calls[100 + 4]["speak"] is False - # Alive seats → speak permitted + # Alive seats during DAY_DISCUSSION → speak permitted assert vc_calls[100 + 1]["speak"] is True +async def test_apply_mutes_alive_humans_outside_discussion() -> None: + """Humans must only speak during DAY_DISCUSSION. Vote / runoff / + night phases keep everyone muted so chatter doesn't bleed across + phase boundaries.""" + for muted_phase in ( + Phase.DAY_VOTE, + Phase.DAY_RUNOFF, + Phase.DAY_RUNOFF_SPEECH, + Phase.NIGHT, + ): + bot, _, ch, game = _setup_world() + game.phase = muted_phase + pm = PermissionManager(bot=bot) + seats = _nine_seats() + players = _players(ROLES) # all alive + + await pm.apply(game, seats, players) + + vc_calls = dict(ch["vc"]._perm_calls) + # Every alive human seat is muted in non-discussion phases. + for seat_no in range(1, 10): + assert vc_calls[100 + seat_no]["speak"] is False, ( + f"phase={muted_phase} seat={seat_no} should be muted" + ) + assert vc_calls[100 + seat_no]["use_voice_activation"] is False + + async def test_on_game_end_clears_vc_overrides() -> None: """VC is a persistent channel; per-member overrides must be cleared on game end so the next game starts from a clean baseline.""" From 2cd7e0f1fff657d3da82087fc4a65d254dbe26f3 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 12:44:59 +0900 Subject: [PATCH 025/133] fix(reactive_voice): contain Master output + clean abort teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/main.py | 116 +++++++++++++++--------- src/wolfbot/npc/client.py | 17 ++++ src/wolfbot/npc/main.py | 61 +++++++++++-- src/wolfbot/services/discord_service.py | 33 ++++++- tests/test_npc_voice_worker.py | 78 ++++++++++++++++ 5 files changed, 249 insertions(+), 56 deletions(-) diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index a2a1993..8b52b89 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -72,6 +72,15 @@ async def post_public(self, game_id: str, text: str, kind: str) -> None: game = await repo.load_game(game_id) if game is None: return + # In reactive_voice mode each NPC bot posts its own utterance + # directly to VC chat from its own account (see + # `NpcClient.on_post_chat`), so a duplicate Master-side post + # would either repeat NPC lines or surface human STT + # transcripts that the user would rather not see twice. Skip + # PLAYER_SPEECH unconditionally for this mode; rounds mode + # still goes through the legacy adapter path. + if game.discussion_mode == "reactive_voice" and kind == "PLAYER_SPEECH": + return await discord_adapter.post_public(game, text, kind) discussion_service = DiscussionService( @@ -300,34 +309,41 @@ async def _on_reactive_phase_enter(game_id: str) -> None: # Master joins the game's VC the first time the game enters a # public-speech phase. Idempotent so re-entries are no-ops. g_for_vc = await repo.load_game(game_id) - if g_for_vc is not None: - await _master_join_vc_for_game(g_for_vc) - # Seed the phase_baseline sentinel so SpeakArbiter's - # `rebuild_public_state` has an alive-seat baseline to fold - # against. In rounds mode this happens inside - # `submit_llm_discussion_rounds`, but reactive_voice skips - # that batch entirely — without seeding here, the arbiter - # silently no-ops on every dispatch attempt because the - # rebuilt state is None. begin_phase_if_absent is idempotent - # across re-entries / recovery. - if g_for_vc.discussion_mode == "reactive_voice": - players_for_baseline = await repo.load_players(game_id) - alive_seat_nos = sorted( - p.seat_no for p in players_for_baseline if p.alive + # Bail when the game is already ended — this callback can run a + # few hundred ms after `/wolf abort` if a transition's + # `_dispatch_submissions` was in flight when abort fired. Without + # this guard Master rejoins VC right after abort just to drop + # the call again seconds later, surfacing as the "Master came + # back" flicker in the user's voice channel. + if g_for_vc is None or g_for_vc.ended_at is not None: + return + await _master_join_vc_for_game(g_for_vc) + # Seed the phase_baseline sentinel so SpeakArbiter's + # `rebuild_public_state` has an alive-seat baseline to fold + # against. In rounds mode this happens inside + # `submit_llm_discussion_rounds`, but reactive_voice skips + # that batch entirely — without seeding here, the arbiter + # silently no-ops on every dispatch attempt because the + # rebuilt state is None. begin_phase_if_absent is idempotent + # across re-entries / recovery. + if g_for_vc.discussion_mode == "reactive_voice": + players_for_baseline = await repo.load_players(game_id) + alive_seat_nos = sorted( + p.seat_no for p in players_for_baseline if p.alive + ) + try: + await discussion_service.begin_phase_if_absent( + game_id=game_id, + day=g_for_vc.day_number, + phase=g_for_vc.phase, + alive_seat_nos=alive_seat_nos, + ) + except Exception: + log.exception( + "phase_baseline_seed_failed game=%s phase=%s", + game_id, + g_for_vc.phase, ) - try: - await discussion_service.begin_phase_if_absent( - game_id=game_id, - day=g_for_vc.day_number, - phase=g_for_vc.phase, - alive_seat_nos=alive_seat_nos, - ) - except Exception: - log.exception( - "phase_baseline_seed_failed game=%s phase=%s", - game_id, - g_for_vc.phase, - ) # Assign online NPC bots to their game seats so the arbiter can # pick them. No-op when `/wolf start`'s pre-game callback already # claimed them. @@ -336,19 +352,28 @@ async def _on_reactive_phase_enter(game_id: str) -> None: await _reactive_phase_cb[0].try_dispatch_next(game_id) async def _on_reactive_game_end(game_id: str) -> None: - """Release every NPC bot attached to this game so they leave VC. + """Release every NPC bot so they leave VC. Called from `GameService` at natural end + host abort. Sends a - `seat_released` to each, then clears the registry's assignment - fields so the bot is available for the next /wolf start. + `seat_released` to *every online NPC* — not just those whose + registry row still pins them to this game — so any bot that + ended up in VC (e.g. via a previous abort that left a stale WS + connection or a dropped assignment) is reliably evicted before + the next /wolf start. Idempotent on the NPC side. """ if not _npc_registry_ref: return from wolfbot.domain.ws_messages import SeatReleased npc_reg = _npc_registry_ref[0] - attached = list(npc_reg.assigned_to_game(game_id)) - for entry in attached: + # Union: bots assigned to *this* game + every online bot whose + # registry row carries no game assignment but might still be in + # VC. We don't filter strictly because abort must be a sweep, not + # a precision strike. + attached = {e.npc_id: e for e in npc_reg.assigned_to_game(game_id)} + for e in npc_reg.all_online(): + attached.setdefault(e.npc_id, e) + for entry in attached.values(): if entry.send is not None: try: msg = SeatReleased( @@ -489,22 +514,20 @@ async def _reactive_voice_reenter(game_id: str) -> None: ) async def _post_to_vc_chat(game: Any, text: str) -> None: - """Post `text` to the VC's attached text chat. Falls back to - the main text channel if the VC channel can't be resolved. - - Discord voice channels (since 2022) carry an attached text - chat reachable at the same channel id; both `VoiceChannel` - and `TextChannel` implement `Messageable`, so we narrow on - those two before sending.""" + """Post `text` to the VC's attached text chat. + + In reactive_voice mode Master is forbidden from leaking text + into the guild's main text channel — every word goes to DM + or VC. Discord voice channels (since 2022) carry an attached + text chat reachable at the same channel id; both + ``VoiceChannel`` and ``TextChannel`` are ``Messageable``. If + the VC channel can't be resolved we drop the message rather + than fall back to main text — silence beats a leak.""" try: channel_id: int | None = int(game.main_vc_channel_id) except (TypeError, ValueError): channel_id = None channel = bot.get_channel(channel_id) if channel_id else None - if not isinstance( - channel, discord.TextChannel | discord.VoiceChannel - ): - channel = bot.get_channel(int(game.main_text_channel_id)) if not isinstance( channel, discord.TextChannel | discord.VoiceChannel ): @@ -582,6 +605,13 @@ async def _master_narrate(game: Any, kind: str, text: str) -> bool: """ if game.discussion_mode != "reactive_voice": return False + # Defensive: an in-flight transition's `post_public` can land + # *after* `/wolf abort` has already torn the game down. We + # must not lazy-join VC for a corpse game — that's exactly + # the "Master came back after abort" flicker. + fresh = await repo.load_game(game.id) + if fresh is None or fresh.ended_at is not None: + return False if master_vc_ref[0] is None: # Lazy-join: SETUP_COMPLETE and the day-1 PHASE_CHANGE are # posted *before* `_on_reactive_phase_enter` runs, so on the diff --git a/src/wolfbot/npc/client.py b/src/wolfbot/npc/client.py index 76927de..4ac98f8 100644 --- a/src/wolfbot/npc/client.py +++ b/src/wolfbot/npc/client.py @@ -96,6 +96,11 @@ class NpcClient: # voice self-mute (mic icon) so dead seats / non-discussion phases are # visually obvious. Optional so tests can omit it. on_set_mute: Callable[[bool], Awaitable[None]] | None = None + # Self-post hook: when this NPC's TTS is authorized, post the spoken + # text to the VC's attached chat from this bot's own account so the + # speech is attributed (avatar + name) instead of being mirrored by + # Master after the fact. Called once per authorized utterance. + on_post_chat: Callable[[str], Awaitable[None]] | None = None _logic_cache: dict[str, LogicPacket] = field(default_factory=dict) _pending_playback: dict[str, _PendingForPlayback] = field(default_factory=dict) @@ -299,6 +304,18 @@ async def _on_playback_authorized(self, auth: PlaybackAuthorized) -> None: audio_size_bytes=len(tts_result.audio), ).model_dump_json() ) + # Post the spoken text to VC chat from this bot's own account + # so the message is attributed to the speaking persona — not to + # Master. Best-effort: a chat-post failure must not block the + # voice playback that follows. + if self.on_post_chat is not None: + try: + await self.on_post_chat(pending.text) + except Exception: + log.exception( + "npc_post_chat_failed request=%s", + auth.request_id, + ) # Playback (gated — never plays without authorization). try: started, finished = await self.playback.play( diff --git a/src/wolfbot/npc/main.py b/src/wolfbot/npc/main.py index 8654017..83752f1 100644 --- a/src/wolfbot/npc/main.py +++ b/src/wolfbot/npc/main.py @@ -119,6 +119,29 @@ async def _ensure_vc_joined() -> None: "npc_vc_join_failed channel=%s", settings.MAIN_VOICE_CHANNEL_ID ) + async def _post_to_vc_chat(text: str) -> None: + """Post `text` to the VC's attached text chat from this bot's + own account. Lets each NPC's spoken line appear in chat + attributed to its own avatar / persona, instead of all lines + coming from Master. Falls back silently if the channel can't + be resolved — voice playback still proceeds.""" + guild = bot.get_guild(settings.DISCORD_GUILD_ID) + if guild is None: + return + channel = guild.get_channel(settings.MAIN_VOICE_CHANNEL_ID) + if not isinstance( + channel, discord.VoiceChannel | discord.TextChannel + ): + log.warning( + "npc_post_chat_no_channel id=%s", + settings.MAIN_VOICE_CHANNEL_ID, + ) + return + try: + await channel.send(text) + except Exception: + log.exception("npc_post_chat_send_failed channel=%s", channel.id) + async def _set_self_mute(self_mute: bool) -> None: """Flip the bot's own voice self-mute via the gateway.""" async with vc_lock: @@ -142,16 +165,33 @@ async def _set_self_mute(self_mute: bool) -> None: async def _ensure_vc_left() -> None: async with vc_lock: vc = vc_client_ref[0] - if vc is None: - return - try: - if vc.is_connected(): - await vc.disconnect() - log.info("npc_vc_left channel=%s", settings.MAIN_VOICE_CHANNEL_ID) - except Exception: - log.exception("npc_vc_leave_failed") - finally: - vc_client_ref[0] = None + if vc is not None: + try: + if vc.is_connected(): + await vc.disconnect() + log.info( + "npc_vc_left channel=%s", + settings.MAIN_VOICE_CHANNEL_ID, + ) + except Exception: + log.exception("npc_vc_leave_failed") + finally: + vc_client_ref[0] = None + # Belt-and-braces: even when this process never tracked a + # VoiceClient (e.g. master restart recovered a game in + # WAITING_HOST_DECISION, so seat_assigned was never sent and + # _ensure_vc_joined never ran), Discord may still show a + # ghost connection from a previous bot session that didn't + # fully tear down. Push a `change_voice_state(channel=None)` + # opcode so the bot is force-evicted from any voice channel + # the gateway still associates with this user. + guild = bot.get_guild(settings.DISCORD_GUILD_ID) + if guild is not None: + try: + await guild.change_voice_state(channel=None) + log.info("npc_vc_state_cleared") + except Exception: + log.exception("npc_vc_state_clear_failed") # Start Discord in background discord_task = asyncio.create_task( @@ -257,6 +297,7 @@ async def _ws_send(msg: str) -> None: on_vc_join=_ensure_vc_joined, on_vc_leave=_ensure_vc_left, on_set_mute=_set_self_mute, + on_post_chat=_post_to_vc_chat, ) # Register with Master diff --git a/src/wolfbot/services/discord_service.py b/src/wolfbot/services/discord_service.py index 31e572f..749c264 100644 --- a/src/wolfbot/services/discord_service.py +++ b/src/wolfbot/services/discord_service.py @@ -504,7 +504,28 @@ async def announce_recovery(self, game: Game, pending: PendingDecision | None) - log.exception("announce_recovery failed %s", game.id) # ------------------------------------------------------ helpers - def _main_text(self, game: Game) -> discord.TextChannel | None: + def _main_text( + self, game: Game + ) -> discord.TextChannel | discord.VoiceChannel | None: + """Resolve the channel where Master should post free-form text. + + In ``reactive_voice`` mode Master must never leak text to the + guild's main text channel — every word out of Master is supposed + to land in either DM or the VC's attached text chat. We redirect + to the VC's text chat (Discord voice channels carry an attached + text chat at the same channel id since 2022; both ``VoiceChannel`` + and ``TextChannel`` are ``Messageable``). In ``rounds`` mode the + legacy main-text behavior is preserved. + """ + if game.discussion_mode == "reactive_voice" and game.main_vc_channel_id: + try: + vc_channel_id = int(game.main_vc_channel_id) + except (TypeError, ValueError): + vc_channel_id = None + if vc_channel_id is not None: + vc = self.bot.get_channel(vc_channel_id) + if isinstance(vc, discord.VoiceChannel | discord.TextChannel): + return vc channel = self.bot.get_channel(int(game.main_text_channel_id)) if isinstance(channel, discord.TextChannel): return channel @@ -1148,6 +1169,12 @@ async def abort(self, interaction: discord.Interaction) -> None: "ホストまたは管理者のみ abort できます。", ephemeral=True ) return + # `host_abort` fans SeatReleased to every online NPC (each one + # then runs `change_voice_state` against the gateway). With 9 + # bots this routinely overshoots Discord's 3 s interaction + # deadline, surfacing as "アプリケーションが応答しませんでした". + # Defer first, then reply via followup. + await interaction.response.defer(thinking=True) ok = await self.gs.host_abort(game.id) if ok: engine = self.registry.detach(game.id) @@ -1156,9 +1183,9 @@ async def abort(self, interaction: discord.Interaction) -> None: await engine.stop() except Exception: log.exception("engine.stop failed during abort %s", game.id) - await interaction.response.send_message("🛑 ゲームを強制終了しました。") + await interaction.followup.send("🛑 ゲームを強制終了しました。") else: - await interaction.response.send_message( + await interaction.followup.send( "終了できませんでした (既に終了している可能性があります)。", ephemeral=True, ) diff --git a/tests/test_npc_voice_worker.py b/tests/test_npc_voice_worker.py index cd64936..151a269 100644 --- a/tests/test_npc_voice_worker.py +++ b/tests/test_npc_voice_worker.py @@ -379,6 +379,84 @@ def test_npc_bot_main_module_loads() -> None: assert hasattr(mod, "main") +async def test_on_post_chat_invoked_with_spoken_text() -> None: + """When TTS is authorized, NpcClient must hand the spoken text to + `on_post_chat` so each NPC posts its own line to VC chat (instead + of Master mirroring everyone's speech).""" + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="そうかもしれないね", + intent="speak", + used_logic_ids=(), + estimated_duration_ms=900, + ) + ] + ) + tts = FakeTtsService(scripted=[TtsResult(audio=b"audio", duration_ms=900)]) + playback = FakeVoicePlayback(started_at_ms=10, finished_at_ms=910) + client, _captured = _make_client(generator=gen, tts=tts, playback=playback) + + posted: list[str] = [] + + async def on_post_chat(text: str) -> None: + posted.append(text) + + client.on_post_chat = on_post_chat + + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + await client.process_message( + PlaybackAuthorized( + ts=2, + trace_id="t", + request_id="sr1", + npc_id="npc_p2", + speech_event_id="ev1", + playback_deadline_ms=10_000, + ).model_dump_json() + ) + assert posted == ["そうかもしれないね"] + # Playback still ran — chat post is best-effort, not gating. + assert playback.plays == [(b"audio", 48_000)] + + +async def test_on_post_chat_failure_does_not_block_playback() -> None: + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="えっと", + intent="speak", + used_logic_ids=(), + estimated_duration_ms=200, + ) + ] + ) + tts = FakeTtsService(scripted=[TtsResult(audio=b"a", duration_ms=200)]) + playback = FakeVoicePlayback(started_at_ms=10, finished_at_ms=210) + client, _captured = _make_client(generator=gen, tts=tts, playback=playback) + + async def boom(_text: str) -> None: + raise RuntimeError("vc chat unreachable") + + client.on_post_chat = boom + + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + # Must NOT raise; playback proceeds. + await client.process_message( + PlaybackAuthorized( + ts=2, + trace_id="t", + request_id="sr1", + npc_id="npc_p2", + speech_event_id="ev1", + playback_deadline_ms=10_000, + ).model_dump_json() + ) + assert playback.plays == [(b"a", 48_000)] + + async def test_tts_finished_includes_correct_duration() -> None: gen = FakeNpcGenerator( scripted=[ From 0f26b6dbaf40231e9452f9a50f2525bd092542f1 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 12:58:19 +0900 Subject: [PATCH 026/133] fix(narration): drop duplicate dawn line on day 2+ discussion start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/master/narration.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/wolfbot/master/narration.py b/src/wolfbot/master/narration.py index a6c16f5..7db41b6 100644 --- a/src/wolfbot/master/narration.py +++ b/src/wolfbot/master/narration.py @@ -118,8 +118,10 @@ def _narrate_phase_change(entry: LogEntry, ctx: NarrationContext) -> NarrationOu f"制限時間は {secs} 秒でございます。" ) else: + # Day 2+: the MORNING entry already announced "夜が明けました" with + # day_number + casualty info. Skip the duplicate dawn line here. voice = ( - f"夜が明けました。{ctx.day_number} 日目の議論を開始致します。" + f"{ctx.day_number} 日目の議論を開始致します。" f"制限時間は {secs} 秒でございます。" ) return NarrationOutput(voice_text=voice) From 3895df26644e609a4b13cc0f489e17283d72be3f Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 13:13:21 +0900 Subject: [PATCH 027/133] feat(observability): JSONL trace of every LLM/STT call per game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/master/stt_service.py | 33 ++- src/wolfbot/master/voice_ingest_service.py | 25 ++ src/wolfbot/npc/gemini_generator.py | 90 +++++-- .../npc/openai_compatible_generator.py | 67 +++++- src/wolfbot/services/llm_service.py | 174 ++++++++++---- src/wolfbot/services/llm_trace.py | 224 ++++++++++++++++++ tests/test_llm_trace.py | 219 +++++++++++++++++ 7 files changed, 755 insertions(+), 77 deletions(-) create mode 100644 src/wolfbot/services/llm_trace.py create mode 100644 tests/test_llm_trace.py diff --git a/src/wolfbot/master/stt_service.py b/src/wolfbot/master/stt_service.py index 381c21a..5715e2a 100644 --- a/src/wolfbot/master/stt_service.py +++ b/src/wolfbot/master/stt_service.py @@ -218,6 +218,8 @@ async def transcribe( import httpx + from wolfbot.services.llm_trace import CallTimer, log_llm_call + audio_b64 = base64.b64encode(audio).decode("ascii") effective_timeout = min(timeout_s, self.timeout_s) @@ -248,13 +250,15 @@ async def transcribe( f"?key={self.api_key}" ) + timer = CallTimer() + raw_text = "" + err: str | None = None try: async with httpx.AsyncClient(timeout=effective_timeout) as client: resp = await client.post(url, json=body) if resp.status_code != 200: - raise SttProviderError( - f"gemini_http_{resp.status_code}" - ) + err = f"gemini_http_{resp.status_code}" + raise SttProviderError(err) resp_json = resp.json() raw_text = ( @@ -304,13 +308,26 @@ async def transcribe( except SttProviderError: raise except httpx.TimeoutException as exc: - raise SttProviderError("gemini_timeout") from exc + err = "gemini_timeout" + raise SttProviderError(err) from exc except httpx.ConnectError as exc: - raise SttProviderError("gemini_connection_refused") from exc + err = "gemini_connection_refused" + raise SttProviderError(err) from exc except Exception as exc: - raise SttProviderError( - f"gemini_unexpected_{type(exc).__name__}" - ) from exc + err = f"gemini_unexpected_{type(exc).__name__}" + raise SttProviderError(err) from exc + finally: + await log_llm_call( + role="voice_stt", + provider="gemini", + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=f"[audio bytes={len(audio)} mime=audio/wav]", + response=raw_text or None, + latency_ms=timer.elapsed_ms, + error=err, + extra={"audio_bytes": len(audio), "language": language}, + ) @staticmethod def _parse_response(raw: str) -> dict: # type: ignore[type-arg] diff --git a/src/wolfbot/master/voice_ingest_service.py b/src/wolfbot/master/voice_ingest_service.py index da11b9c..89d3fa6 100644 --- a/src/wolfbot/master/voice_ingest_service.py +++ b/src/wolfbot/master/voice_ingest_service.py @@ -210,6 +210,31 @@ async def _run_stt( phase_id: str, *, audio_end_ms: int, + ) -> None: + from wolfbot.services.llm_trace import trace_context + + actor = ( + f"speaker_user_id={seg.speaker_user_id} seat={seg.seat_no} " + f"segment={seg.segment_id}" + ) + with trace_context( + game_id=game_id, + phase=phase_id, + actor=actor, + metadata={ + "segment_id": seg.segment_id, + "audio_end_ms": audio_end_ms, + }, + ): + await self._run_stt_inner(seg, game_id, phase_id, audio_end_ms=audio_end_ms) + + async def _run_stt_inner( + self, + seg: _OpenSegment, + game_id: str, + phase_id: str, + *, + audio_end_ms: int, ) -> None: try: result: SttResult = await self.stt.transcribe( diff --git a/src/wolfbot/npc/gemini_generator.py b/src/wolfbot/npc/gemini_generator.py index 3214f61..5b16c28 100644 --- a/src/wolfbot/npc/gemini_generator.py +++ b/src/wolfbot/npc/gemini_generator.py @@ -79,6 +79,13 @@ async def generate( from google import genai from google.genai import types + from wolfbot.services.llm_trace import ( + CallTimer, + log_llm_call, + parse_game_id_from_phase_id, + trace_context, + ) + if self._persona_key is None: raise RuntimeError( "GeminiNpcGenerator.generate() called before set_persona(); " @@ -94,29 +101,72 @@ async def generate( location=self.config.location, http_options=types.HttpOptions(timeout=int(self.config.timeout * 1000)), ) - try: - resp = await client.aio.models.generate_content( - model=self.config.model, - contents=user, - config=types.GenerateContentConfig( - system_instruction=system, - response_mime_type="application/json", - response_json_schema=_RESPONSE_SCHEMA["schema"], - thinking_config=types.ThinkingConfig( - # The SDK normalizes the string into ThinkingLevel - # at runtime; the type annotation is enum-only. - thinking_level=self.config.thinking_level, # type: ignore[arg-type] + + actor = ( + f"npc_id={request.npc_id} seat={request.seat_no} persona={self._persona_key}" + ) + timer = CallTimer() + content = "" + err: str | None = None + with trace_context( + game_id=parse_game_id_from_phase_id(request.phase_id), + phase=request.phase_id, + actor=actor, + metadata={ + "request_id": request.request_id, + "logic_packet_id": request.logic_packet_id, + "suggested_intent": request.suggested_intent, + "max_chars": request.max_chars, + "thinking_level": self.config.thinking_level, + }, + ): + try: + resp = await client.aio.models.generate_content( + model=self.config.model, + contents=user, + config=types.GenerateContentConfig( + system_instruction=system, + response_mime_type="application/json", + response_json_schema=_RESPONSE_SCHEMA["schema"], + thinking_config=types.ThinkingConfig( + # The SDK normalizes the string into ThinkingLevel + # at runtime; the type annotation is enum-only. + thinking_level=self.config.thinking_level, # type: ignore[arg-type] + ), ), - ), - ) - except Exception: - log.exception( - "npc_generate_gemini_failed project=%s model=%s", - self.config.project, self.config.model, + ) + content = resp.text or "{}" + except Exception as exc: + err = f"{type(exc).__name__}: {exc}" + log.exception( + "npc_generate_gemini_failed project=%s model=%s", + self.config.project, self.config.model, + ) + await log_llm_call( + role="npc_speech", + provider="gemini", + model=self.config.model, + system_prompt=system, + user_prompt=user, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + file_stem=f"npc_{self._persona_key}", + ) + return None + + await log_llm_call( + role="npc_speech", + provider="gemini", + model=self.config.model, + system_prompt=system, + user_prompt=user, + response=content, + latency_ms=timer.elapsed_ms, + error=None, + file_stem=f"npc_{self._persona_key}", ) - return None - content = resp.text or "{}" try: data = json.loads(content) except json.JSONDecodeError: diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index 98ff6b1..6badc6b 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -224,6 +224,13 @@ async def generate( ) -> NpcGeneratedSpeech | None: from openai import AsyncOpenAI + from wolfbot.services.llm_trace import ( + CallTimer, + log_llm_call, + parse_game_id_from_phase_id, + trace_context, + ) + if self._persona_key is None: raise RuntimeError( "OpenAICompatibleNpcGenerator.generate() called before set_persona(); " @@ -258,16 +265,60 @@ async def generate( kwargs["extra_body"] = {"thinking": {"type": self.config.thinking}} if self.config.thinking == "enabled": kwargs["reasoning_effort"] = self.config.reasoning_effort - try: - resp = await client.chat.completions.create(**kwargs) # type: ignore[call-overload] - except Exception: - log.exception( - "npc_generate_failed model=%s base_url=%s", - self.config.model, self.config.base_url, + + provider_tag = "deepseek" if self.config.mode == "json_object" else "openai-compat" + actor = ( + f"npc_id={request.npc_id} seat={request.seat_no} persona={self._persona_key}" + ) + timer = CallTimer() + content = "" + err: str | None = None + with trace_context( + game_id=parse_game_id_from_phase_id(request.phase_id), + phase=request.phase_id, + actor=actor, + metadata={ + "request_id": request.request_id, + "logic_packet_id": request.logic_packet_id, + "suggested_intent": request.suggested_intent, + "max_chars": request.max_chars, + "base_url": self.config.base_url, + }, + ): + try: + resp = await client.chat.completions.create(**kwargs) # type: ignore[call-overload] + content = resp.choices[0].message.content or "{}" + except Exception as exc: + err = f"{type(exc).__name__}: {exc}" + log.exception( + "npc_generate_failed model=%s base_url=%s", + self.config.model, self.config.base_url, + ) + await log_llm_call( + role="npc_speech", + provider=provider_tag, + model=self.config.model, + system_prompt=system, + user_prompt=user, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + file_stem=f"npc_{self._persona_key}", + ) + return None + + await log_llm_call( + role="npc_speech", + provider=provider_tag, + model=self.config.model, + system_prompt=system, + user_prompt=user, + response=content, + latency_ms=timer.elapsed_ms, + error=None, + file_stem=f"npc_{self._persona_key}", ) - return None - content = resp.choices[0].message.content or "{}" try: data = json.loads(content) except json.JSONDecodeError: diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index 2af1adc..fe3bff8 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -58,6 +58,11 @@ task_wolf_chat, ) from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY +from wolfbot.services.llm_trace import ( + CallTimer, + log_llm_call, + trace_context, +) if TYPE_CHECKING: # avoid importing heavy modules unless needed from openai import AsyncOpenAI @@ -82,6 +87,19 @@ async def post_wolves_chat(self, game: Game, text: str, kind: str) -> None: ... log = logging.getLogger(__name__) +def _classify_task_text(task_text: str) -> str: + """Coarse tag of which `task_*` produced this prompt — for trace metadata only.""" + if "投票先として合法な候補は" in task_text: + return "vote" + if "対象を 1 名選んでください" in task_text: + return "night_action" + if "人狼チャット" in task_text: + return "wolf_chat" + if "議論フェイズ" in task_text or "演説" in task_text: + return "discussion" + return "unknown" + + def seat_token(seat: Seat) -> str: """Stable LLM/UI identifier: `席{N} {display_name}`. @@ -185,22 +203,40 @@ def __init__(self, client: AsyncOpenAI, model: str, timeout: float = 30.0) -> No reraise=True, ) async def decide(self, system_prompt: str, user_context: str) -> LLMAction: - # xAI model IDs aren't in the openai SDK's Literal, hence the ignore. - resp = await self.client.chat.completions.create( # type: ignore[call-overload] - model=self.model, - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_context}, - ], - response_format={ - "type": "json_schema", - "json_schema": RESPONSE_SCHEMA, - }, - timeout=self.timeout, - ) - message = resp.choices[0].message - content = message.content or "{}" - return LLMAction.model_validate_json(content) + timer = CallTimer() + content = "" + err: str | None = None + try: + # xAI model IDs aren't in the openai SDK's Literal, hence the ignore. + resp = await self.client.chat.completions.create( # type: ignore[call-overload] + model=self.model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_context}, + ], + response_format={ + "type": "json_schema", + "json_schema": RESPONSE_SCHEMA, + }, + timeout=self.timeout, + ) + message = resp.choices[0].message + content = message.content or "{}" + return LLMAction.model_validate_json(content) + except Exception as exc: + err = f"{type(exc).__name__}: {exc}" + raise + finally: + await log_llm_call( + role="gameplay", + provider="xai", + model=self.model, + system_prompt=system_prompt, + user_prompt=user_context, + response=content if err is None else None, + latency_ms=timer.elapsed_ms, + error=err, + ) class DeepSeekLLMActionDecider: @@ -250,11 +286,35 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: } if self.thinking == "enabled": kwargs["reasoning_effort"] = self.reasoning_effort - # DeepSeek model IDs aren't in the openai SDK's Literal, hence the ignore. - resp = await self.client.chat.completions.create(**kwargs) # type: ignore[call-overload] - message = resp.choices[0].message - content = message.content or "{}" - return LLMAction.model_validate_json(content) + timer = CallTimer() + content = "" + err: str | None = None + try: + # DeepSeek model IDs aren't in the openai SDK's Literal, hence the ignore. + resp = await self.client.chat.completions.create(**kwargs) # type: ignore[call-overload] + message = resp.choices[0].message + content = message.content or "{}" + return LLMAction.model_validate_json(content) + except Exception as exc: + err = f"{type(exc).__name__}: {exc}" + raise + finally: + await log_llm_call( + role="gameplay", + provider="deepseek", + model=self.model, + system_prompt=full_system, + user_prompt=user_context, + response=content if err is None else None, + latency_ms=timer.elapsed_ms, + error=err, + extra={ + "thinking": self.thinking, + "reasoning_effort": self.reasoning_effort + if self.thinking == "enabled" + else None, + }, + ) class GeminiLLMActionDecider: @@ -288,22 +348,41 @@ def __init__( async def decide(self, system_prompt: str, user_context: str) -> LLMAction: from google.genai import types - resp = await self.client.aio.models.generate_content( # type: ignore[attr-defined] - model=self.model, - contents=user_context, - config=types.GenerateContentConfig( - system_instruction=system_prompt, - response_mime_type="application/json", - response_json_schema=RESPONSE_SCHEMA["schema"], - thinking_config=types.ThinkingConfig( - # SDK normalizes the string into ThinkingLevel at runtime; - # the type annotation is enum-only, so silence the check. - thinking_level=self.thinking_level, # type: ignore[arg-type] + timer = CallTimer() + content = "" + err: str | None = None + try: + resp = await self.client.aio.models.generate_content( # type: ignore[attr-defined] + model=self.model, + contents=user_context, + config=types.GenerateContentConfig( + system_instruction=system_prompt, + response_mime_type="application/json", + response_json_schema=RESPONSE_SCHEMA["schema"], + thinking_config=types.ThinkingConfig( + # SDK normalizes the string into ThinkingLevel at runtime; + # the type annotation is enum-only, so silence the check. + thinking_level=self.thinking_level, # type: ignore[arg-type] + ), ), - ), - ) - content = resp.text or "{}" - return LLMAction.model_validate_json(content) + ) + content = resp.text or "{}" + return LLMAction.model_validate_json(content) + except Exception as exc: + err = f"{type(exc).__name__}: {exc}" + raise + finally: + await log_llm_call( + role="gameplay", + provider="gemini", + model=self.model, + system_prompt=system_prompt, + user_prompt=user_context, + response=content if err is None else None, + latency_ms=timer.elapsed_ms, + error=err, + extra={"thinking_level": self.thinking_level}, + ) class MockLLMActionDecider: @@ -1244,11 +1323,24 @@ async def _ask( private_logs=private_logs, deduced_facts_block=deduced_block, ) - try: - return await self.decider.decide(system, user) - except Exception: - log.exception("LLM decide failed for seat %s game %s", player.seat_no, game.id) - return LLMAction(intent="skip", reason_summary="decider error") + actor = ( + f"seat={player.seat_no} persona={seat.persona_key} role={player.role.value}" + ) + task_tag = _classify_task_text(task_text) + with trace_context( + game_id=game.id, + phase=game.phase.value, + day=game.day_number, + actor=actor, + metadata={"task": task_tag}, + ): + try: + return await self.decider.decide(system, user) + except Exception: + log.exception( + "LLM decide failed for seat %s game %s", player.seat_no, game.id + ) + return LLMAction(intent="skip", reason_summary="decider error") async def _build_deduced_facts_block( self, diff --git a/src/wolfbot/services/llm_trace.py b/src/wolfbot/services/llm_trace.py new file mode 100644 index 0000000..f3cc044 --- /dev/null +++ b/src/wolfbot/services/llm_trace.py @@ -0,0 +1,224 @@ +"""Append-only JSONL trace of every LLM call. + +Captures gameplay LLM (vote / night-action / discussion text), NPC speech +LLM, and voice STT/Analyzer calls into per-game JSONL files so a finished +game can be replayed end-to-end with full prompt + response visibility. + +Layout (relative to cwd, override via ``WOLFBOT_LLM_TRACE_DIR``):: + + logs/llm_calls/ + no_game/ # calls before any game context is set + voice_stt.jsonl + {game_id}/ + gameplay.jsonl # gameplay-LLM deciders (xAI / DeepSeek / Gemini) + npc_{persona}.jsonl # NPC bot speech generation, one file per persona + voice_stt.jsonl # Master-side STT/audio analysis + +Each JSONL line is one call:: + + { + "ts": "2026-04-28T13:14:55.123+00:00", + "role": "gameplay" | "npc_speech" | "voice_stt", + "provider": "xai" | "deepseek" | "gemini" | "openai-compat", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 2, + "actor": "seat=4 persona=setsu role=WEREWOLF", + "metadata": {"task": "vote"}, + "system_prompt": "...", + "user_prompt": "...", + "response": "...", # raw text (JSON string for structured-output models) + "latency_ms": 1234, + "error": null + } + +Concurrency: one ``asyncio.Lock`` per file path so concurrent appends from +parallel LLM seats interleave at line boundaries. + +Disable: ``WOLFBOT_LLM_TRACE_DISABLED=1``. +""" + +from __future__ import annotations + +import asyncio +import contextvars +import json +import logging +import os +import re +import time +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +log = logging.getLogger(__name__) + +_DEFAULT_DIR = Path("logs/llm_calls") + +# Context vars carrying game/phase/actor identifiers from the calling code +# down into the decider / generator without changing public Protocols. +_game_id_ctx: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "wolfbot_llm_trace_game_id", default=None +) +_phase_ctx: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "wolfbot_llm_trace_phase", default=None +) +_day_ctx: contextvars.ContextVar[int | None] = contextvars.ContextVar( + "wolfbot_llm_trace_day", default=None +) +_actor_ctx: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "wolfbot_llm_trace_actor", default=None +) +_metadata_ctx: contextvars.ContextVar[dict[str, Any] | None] = contextvars.ContextVar( + "wolfbot_llm_trace_metadata", default=None +) + +_file_locks: dict[str, asyncio.Lock] = {} + + +def trace_enabled() -> bool: + return os.environ.get("WOLFBOT_LLM_TRACE_DISABLED") != "1" + + +def trace_base_dir() -> Path: + raw = os.environ.get("WOLFBOT_LLM_TRACE_DIR") + return Path(raw) if raw else _DEFAULT_DIR + + +def parse_game_id_from_phase_id(phase_id: str | None) -> str | None: + """Extract ``game_id`` from canonical phase_id ``"{gid}::dayN::PHASE::seq"``.""" + if not phase_id: + return None + head, sep, _ = phase_id.partition("::") + return head if sep else None + + +@contextmanager +def trace_context( + *, + game_id: str | None = None, + phase: str | None = None, + day: int | None = None, + actor: str | None = None, + metadata: dict[str, Any] | None = None, +) -> Iterator[None]: + """Set trace context for the duration of a block. + + Wrap a logical unit of work (e.g. one ``LLMAdapter._ask`` call) so every + LLM call nested inside inherits the same identifiers without changing + decider Protocols. + """ + tokens: list[tuple[contextvars.ContextVar[Any], contextvars.Token[Any]]] = [] + if game_id is not None: + tokens.append((_game_id_ctx, _game_id_ctx.set(game_id))) + if phase is not None: + tokens.append((_phase_ctx, _phase_ctx.set(phase))) + if day is not None: + tokens.append((_day_ctx, _day_ctx.set(day))) + if actor is not None: + tokens.append((_actor_ctx, _actor_ctx.set(actor))) + if metadata is not None: + tokens.append((_metadata_ctx, _metadata_ctx.set(metadata))) + try: + yield + finally: + for var, tok in reversed(tokens): + var.reset(tok) + + +class CallTimer: + """Lightweight stopwatch used at every LLM call site.""" + + __slots__ = ("_t0",) + + def __init__(self) -> None: + self._t0 = time.perf_counter() + + @property + def elapsed_ms(self) -> int: + return int((time.perf_counter() - self._t0) * 1000) + + +async def log_llm_call( + *, + role: str, + provider: str, + model: str, + system_prompt: str | None, + user_prompt: str | None, + response: str | None, + latency_ms: int, + error: str | None = None, + actor: str | None = None, + extra: dict[str, Any] | None = None, + file_stem: str | None = None, +) -> None: + """Append one trace line. Never raises — failures are logged and dropped. + + ``role`` selects the default file stem (``"{role}.jsonl"``); pass + ``file_stem`` to override (e.g. NPC bots use ``"npc_setsu"``). + """ + if not trace_enabled(): + return + try: + gid = _game_id_ctx.get() or "no_game" + gid_safe = _sanitize(gid) + stem_safe = _sanitize(file_stem or role) + base = trace_base_dir() / gid_safe + base.mkdir(parents=True, exist_ok=True) + path = base / f"{stem_safe}.jsonl" + + entry: dict[str, Any] = { + "ts": datetime.now(UTC).isoformat(), + "role": role, + "provider": provider, + "model": model, + "phase": _phase_ctx.get(), + "day": _day_ctx.get(), + "actor": actor or _actor_ctx.get(), + "system_prompt": system_prompt, + "user_prompt": user_prompt, + "response": response, + "latency_ms": latency_ms, + "error": error, + } + ctx_md = _metadata_ctx.get() + if ctx_md or extra: + entry["metadata"] = {**(ctx_md or {}), **(extra or {})} + line = json.dumps(entry, ensure_ascii=False) + "\n" + + lock = _file_locks.setdefault(str(path), asyncio.Lock()) + async with lock: + await asyncio.to_thread(_append_line, path, line) + except Exception: + log.exception("llm_trace_write_failed role=%s model=%s", role, model) + + +def _append_line(path: Path, line: str) -> None: + with open(path, "a", encoding="utf-8") as f: + f.write(line) + + +_SAFE_RE = re.compile(r"[^A-Za-z0-9_-]+") + + +def _sanitize(s: str) -> str: + """Sanitize a path component (game_id or file stem). + + Dots are replaced too — preventing both ``..`` traversal and accidental + extension confusion with the appended ``.jsonl`` suffix. + """ + cleaned = _SAFE_RE.sub("_", s).strip("_") + return cleaned or "x" + + +__all__ = [ + "CallTimer", + "log_llm_call", + "parse_game_id_from_phase_id", + "trace_base_dir", + "trace_context", + "trace_enabled", +] diff --git a/tests/test_llm_trace.py b/tests/test_llm_trace.py new file mode 100644 index 0000000..3c0352c --- /dev/null +++ b/tests/test_llm_trace.py @@ -0,0 +1,219 @@ +"""Tests for `wolfbot.services.llm_trace` JSONL writer.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from wolfbot.services.llm_trace import ( + log_llm_call, + parse_game_id_from_phase_id, + trace_context, +) + + +@pytest.fixture +def trace_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("WOLFBOT_LLM_TRACE_DIR", str(tmp_path)) + monkeypatch.delenv("WOLFBOT_LLM_TRACE_DISABLED", raising=False) + return tmp_path + + +async def test_log_llm_call_writes_jsonl_line(trace_dir: Path) -> None: + with trace_context( + game_id="g_abc", + phase="DAY_DISCUSSION", + day=2, + actor="seat=4 persona=setsu", + metadata={"task": "vote"}, + ): + await log_llm_call( + role="gameplay", + provider="xai", + model="grok-4-1-fast", + system_prompt="sys", + user_prompt="usr", + response='{"intent":"vote"}', + latency_ms=123, + ) + + path = trace_dir / "g_abc" / "gameplay.jsonl" + assert path.exists(), "trace file was not created" + line = path.read_text(encoding="utf-8").strip() + entry = json.loads(line) + + assert entry["role"] == "gameplay" + assert entry["provider"] == "xai" + assert entry["model"] == "grok-4-1-fast" + assert entry["phase"] == "DAY_DISCUSSION" + assert entry["day"] == 2 + assert entry["actor"] == "seat=4 persona=setsu" + assert entry["system_prompt"] == "sys" + assert entry["user_prompt"] == "usr" + assert entry["response"] == '{"intent":"vote"}' + assert entry["latency_ms"] == 123 + assert entry["error"] is None + assert entry["metadata"] == {"task": "vote"} + assert entry["ts"].endswith("+00:00") + + +async def test_log_llm_call_appends_multiple_lines(trace_dir: Path) -> None: + with trace_context(game_id="g_one"): + for i in range(3): + await log_llm_call( + role="gameplay", + provider="gemini", + model="gemini-3-flash", + system_prompt="s", + user_prompt=f"u{i}", + response="r", + latency_ms=i, + ) + + path = trace_dir / "g_one" / "gameplay.jsonl" + lines = path.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 3 + payloads = [json.loads(line) for line in lines] + assert [p["user_prompt"] for p in payloads] == ["u0", "u1", "u2"] + + +async def test_log_llm_call_uses_no_game_when_context_missing(trace_dir: Path) -> None: + await log_llm_call( + role="voice_stt", + provider="gemini", + model="gemini-2.0-flash-lite", + system_prompt="sys", + user_prompt="[audio]", + response='{"transcript":"hi"}', + latency_ms=42, + ) + path = trace_dir / "no_game" / "voice_stt.jsonl" + assert path.exists() + + +async def test_log_llm_call_respects_file_stem_override(trace_dir: Path) -> None: + with trace_context(game_id="g_two"): + await log_llm_call( + role="npc_speech", + provider="openai-compat", + model="grok-4-1-fast", + system_prompt="s", + user_prompt="u", + response="r", + latency_ms=10, + file_stem="npc_setsu", + ) + assert (trace_dir / "g_two" / "npc_setsu.jsonl").exists() + + +async def test_log_llm_call_records_error_path(trace_dir: Path) -> None: + with trace_context(game_id="g_err"): + await log_llm_call( + role="gameplay", + provider="xai", + model="grok-4-1-fast", + system_prompt="s", + user_prompt="u", + response=None, + latency_ms=5000, + error="TimeoutError: no response", + ) + entry = json.loads( + (trace_dir / "g_err" / "gameplay.jsonl").read_text(encoding="utf-8").strip() + ) + assert entry["response"] is None + assert entry["error"] == "TimeoutError: no response" + + +async def test_disabled_via_env_writes_nothing( + trace_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("WOLFBOT_LLM_TRACE_DISABLED", "1") + with trace_context(game_id="g_disabled"): + await log_llm_call( + role="gameplay", + provider="xai", + model="grok-4-1-fast", + system_prompt="s", + user_prompt="u", + response="r", + latency_ms=1, + ) + assert not (trace_dir / "g_disabled").exists() + + +async def test_path_components_are_sanitized(trace_dir: Path) -> None: + # game_id with path-traversal-ish chars + with trace_context(game_id="../evil/../escape"): + await log_llm_call( + role="gameplay", + provider="xai", + model="grok-4-1-fast", + system_prompt="s", + user_prompt="u", + response="r", + latency_ms=1, + ) + # No directory should be created outside trace_dir. + assert not (trace_dir.parent / "evil").exists() + # The sanitized name lives inside trace_dir. + children = [p.name for p in trace_dir.iterdir() if p.is_dir()] + assert children, "expected a sanitized subdirectory" + assert all(".." not in name for name in children) + + +async def test_extra_metadata_merges_with_context(trace_dir: Path) -> None: + with trace_context(game_id="g_meta", metadata={"task": "vote"}): + await log_llm_call( + role="gameplay", + provider="deepseek", + model="deepseek-chat", + system_prompt="s", + user_prompt="u", + response="r", + latency_ms=1, + extra={"thinking": "enabled", "reasoning_effort": "max"}, + ) + entry = json.loads( + (trace_dir / "g_meta" / "gameplay.jsonl").read_text(encoding="utf-8").strip() + ) + assert entry["metadata"] == { + "task": "vote", + "thinking": "enabled", + "reasoning_effort": "max", + } + + +def test_parse_game_id_from_phase_id() -> None: + assert parse_game_id_from_phase_id("g_abc::day1::DAY_DISCUSSION::1") == "g_abc" + assert parse_game_id_from_phase_id(None) is None + assert parse_game_id_from_phase_id("") is None + assert parse_game_id_from_phase_id("noseparator") is None + + +async def test_concurrent_writes_interleave_at_line_boundaries( + trace_dir: Path, +) -> None: + import asyncio + + async def write(i: int) -> None: + with trace_context(game_id="g_concurrent"): + await log_llm_call( + role="gameplay", + provider="xai", + model="grok-4-1-fast", + system_prompt="s", + user_prompt=f"call_{i}", + response="r", + latency_ms=i, + ) + + await asyncio.gather(*(write(i) for i in range(20))) + path = trace_dir / "g_concurrent" / "gameplay.jsonl" + lines = path.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 20 + # Each line must parse as JSON — i.e. no torn writes. + for line in lines: + json.loads(line) From 2943a9fe1d3226e90af052672e5ff1a94115c739 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 13:38:22 +0900 Subject: [PATCH 028/133] feat(observability): capture token usage in LLM trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/master/stt_service.py | 9 ++- src/wolfbot/npc/gemini_generator.py | 4 + .../npc/openai_compatible_generator.py | 4 + src/wolfbot/services/llm_service.py | 11 +++ src/wolfbot/services/llm_trace.py | 54 +++++++++++++ tests/test_llm_trace.py | 76 +++++++++++++++++++ 6 files changed, 157 insertions(+), 1 deletion(-) diff --git a/src/wolfbot/master/stt_service.py b/src/wolfbot/master/stt_service.py index 5715e2a..778d6ec 100644 --- a/src/wolfbot/master/stt_service.py +++ b/src/wolfbot/master/stt_service.py @@ -218,7 +218,11 @@ async def transcribe( import httpx - from wolfbot.services.llm_trace import CallTimer, log_llm_call + from wolfbot.services.llm_trace import ( + CallTimer, + extract_gemini_rest_tokens, + log_llm_call, + ) audio_b64 = base64.b64encode(audio).decode("ascii") effective_timeout = min(timeout_s, self.timeout_s) @@ -253,6 +257,7 @@ async def transcribe( timer = CallTimer() raw_text = "" err: str | None = None + tokens: dict[str, int | None] | None = None try: async with httpx.AsyncClient(timeout=effective_timeout) as client: resp = await client.post(url, json=body) @@ -261,6 +266,7 @@ async def transcribe( raise SttProviderError(err) resp_json = resp.json() + tokens = extract_gemini_rest_tokens(resp_json) raw_text = ( resp_json.get("candidates", [{}])[0] .get("content", {}) @@ -326,6 +332,7 @@ async def transcribe( response=raw_text or None, latency_ms=timer.elapsed_ms, error=err, + tokens=tokens, extra={"audio_bytes": len(audio), "language": language}, ) diff --git a/src/wolfbot/npc/gemini_generator.py b/src/wolfbot/npc/gemini_generator.py index 5b16c28..7733116 100644 --- a/src/wolfbot/npc/gemini_generator.py +++ b/src/wolfbot/npc/gemini_generator.py @@ -81,6 +81,7 @@ async def generate( from wolfbot.services.llm_trace import ( CallTimer, + extract_gemini_vertex_tokens, log_llm_call, parse_game_id_from_phase_id, trace_context, @@ -108,6 +109,7 @@ async def generate( timer = CallTimer() content = "" err: str | None = None + tokens: dict[str, int | None] | None = None with trace_context( game_id=parse_game_id_from_phase_id(request.phase_id), phase=request.phase_id, @@ -136,6 +138,7 @@ async def generate( ), ) content = resp.text or "{}" + tokens = extract_gemini_vertex_tokens(resp) except Exception as exc: err = f"{type(exc).__name__}: {exc}" log.exception( @@ -164,6 +167,7 @@ async def generate( response=content, latency_ms=timer.elapsed_ms, error=None, + tokens=tokens, file_stem=f"npc_{self._persona_key}", ) diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index 6badc6b..226684f 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -226,6 +226,7 @@ async def generate( from wolfbot.services.llm_trace import ( CallTimer, + extract_openai_tokens, log_llm_call, parse_game_id_from_phase_id, trace_context, @@ -273,6 +274,7 @@ async def generate( timer = CallTimer() content = "" err: str | None = None + tokens: dict[str, int | None] | None = None with trace_context( game_id=parse_game_id_from_phase_id(request.phase_id), phase=request.phase_id, @@ -288,6 +290,7 @@ async def generate( try: resp = await client.chat.completions.create(**kwargs) # type: ignore[call-overload] content = resp.choices[0].message.content or "{}" + tokens = extract_openai_tokens(resp) except Exception as exc: err = f"{type(exc).__name__}: {exc}" log.exception( @@ -316,6 +319,7 @@ async def generate( response=content, latency_ms=timer.elapsed_ms, error=None, + tokens=tokens, file_stem=f"npc_{self._persona_key}", ) diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index fe3bff8..efbed59 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -60,6 +60,8 @@ from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY from wolfbot.services.llm_trace import ( CallTimer, + extract_gemini_vertex_tokens, + extract_openai_tokens, log_llm_call, trace_context, ) @@ -206,6 +208,7 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: timer = CallTimer() content = "" err: str | None = None + tokens: dict[str, int | None] | None = None try: # xAI model IDs aren't in the openai SDK's Literal, hence the ignore. resp = await self.client.chat.completions.create( # type: ignore[call-overload] @@ -222,6 +225,7 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: ) message = resp.choices[0].message content = message.content or "{}" + tokens = extract_openai_tokens(resp) return LLMAction.model_validate_json(content) except Exception as exc: err = f"{type(exc).__name__}: {exc}" @@ -236,6 +240,7 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: response=content if err is None else None, latency_ms=timer.elapsed_ms, error=err, + tokens=tokens, ) @@ -289,11 +294,13 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: timer = CallTimer() content = "" err: str | None = None + tokens: dict[str, int | None] | None = None try: # DeepSeek model IDs aren't in the openai SDK's Literal, hence the ignore. resp = await self.client.chat.completions.create(**kwargs) # type: ignore[call-overload] message = resp.choices[0].message content = message.content or "{}" + tokens = extract_openai_tokens(resp) return LLMAction.model_validate_json(content) except Exception as exc: err = f"{type(exc).__name__}: {exc}" @@ -308,6 +315,7 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: response=content if err is None else None, latency_ms=timer.elapsed_ms, error=err, + tokens=tokens, extra={ "thinking": self.thinking, "reasoning_effort": self.reasoning_effort @@ -351,6 +359,7 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: timer = CallTimer() content = "" err: str | None = None + tokens: dict[str, int | None] | None = None try: resp = await self.client.aio.models.generate_content( # type: ignore[attr-defined] model=self.model, @@ -367,6 +376,7 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: ), ) content = resp.text or "{}" + tokens = extract_gemini_vertex_tokens(resp) return LLMAction.model_validate_json(content) except Exception as exc: err = f"{type(exc).__name__}: {exc}" @@ -381,6 +391,7 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: response=content if err is None else None, latency_ms=timer.elapsed_ms, error=err, + tokens=tokens, extra={"thinking_level": self.thinking_level}, ) diff --git a/src/wolfbot/services/llm_trace.py b/src/wolfbot/services/llm_trace.py index f3cc044..e7bdb77 100644 --- a/src/wolfbot/services/llm_trace.py +++ b/src/wolfbot/services/llm_trace.py @@ -154,11 +154,15 @@ async def log_llm_call( actor: str | None = None, extra: dict[str, Any] | None = None, file_stem: str | None = None, + tokens: dict[str, int | None] | None = None, ) -> None: """Append one trace line. Never raises — failures are logged and dropped. ``role`` selects the default file stem (``"{role}.jsonl"``); pass ``file_stem`` to override (e.g. NPC bots use ``"npc_setsu"``). + + ``tokens`` carries token usage as ``{"prompt": N, "completion": N, "total": N}`` + when the provider returned usage metadata, ``None`` otherwise. """ if not trace_enabled(): return @@ -182,6 +186,7 @@ async def log_llm_call( "user_prompt": user_prompt, "response": response, "latency_ms": latency_ms, + "tokens": tokens, "error": error, } ctx_md = _metadata_ctx.get() @@ -201,6 +206,52 @@ def _append_line(path: Path, line: str) -> None: f.write(line) +def extract_openai_tokens(resp: Any) -> dict[str, int | None] | None: + """Pull ``{prompt, completion, total}`` from an OpenAI-compatible response. + + Used by the xAI / DeepSeek / OpenAI-compat paths. Returns ``None`` when + the response has no usage attribute (e.g. test fakes), so trace lines + stay valid even without real token data. + """ + usage = getattr(resp, "usage", None) + if usage is None: + return None + return { + "prompt": getattr(usage, "prompt_tokens", None), + "completion": getattr(usage, "completion_tokens", None), + "total": getattr(usage, "total_tokens", None), + } + + +def extract_gemini_vertex_tokens(resp: Any) -> dict[str, int | None] | None: + """Pull ``{prompt, completion, total}`` from a google-genai response. + + Vertex Gemini exposes ``resp.usage_metadata.{prompt_token_count, + candidates_token_count, total_token_count}`` — different field names + from OpenAI but the same three integers conceptually. + """ + um = getattr(resp, "usage_metadata", None) + if um is None: + return None + return { + "prompt": getattr(um, "prompt_token_count", None), + "completion": getattr(um, "candidates_token_count", None), + "total": getattr(um, "total_token_count", None), + } + + +def extract_gemini_rest_tokens(resp_json: dict[str, Any]) -> dict[str, int | None] | None: + """Pull token counts from the Gemini REST ``generateContent`` response.""" + um = resp_json.get("usageMetadata") + if not isinstance(um, dict): + return None + return { + "prompt": um.get("promptTokenCount"), + "completion": um.get("candidatesTokenCount"), + "total": um.get("totalTokenCount"), + } + + _SAFE_RE = re.compile(r"[^A-Za-z0-9_-]+") @@ -216,6 +267,9 @@ def _sanitize(s: str) -> str: __all__ = [ "CallTimer", + "extract_gemini_rest_tokens", + "extract_gemini_vertex_tokens", + "extract_openai_tokens", "log_llm_call", "parse_game_id_from_phase_id", "trace_base_dir", diff --git a/tests/test_llm_trace.py b/tests/test_llm_trace.py index 3c0352c..cd72e66 100644 --- a/tests/test_llm_trace.py +++ b/tests/test_llm_trace.py @@ -186,6 +186,82 @@ async def test_extra_metadata_merges_with_context(trace_dir: Path) -> None: } +async def test_log_llm_call_records_tokens(trace_dir: Path) -> None: + with trace_context(game_id="g_tok"): + await log_llm_call( + role="gameplay", + provider="xai", + model="grok-4-1-fast", + system_prompt="s", + user_prompt="u", + response="r", + latency_ms=42, + tokens={"prompt": 1500, "completion": 200, "total": 1700}, + ) + entry = json.loads( + (trace_dir / "g_tok" / "gameplay.jsonl").read_text(encoding="utf-8").strip() + ) + assert entry["tokens"] == {"prompt": 1500, "completion": 200, "total": 1700} + + +def test_extract_openai_tokens() -> None: + from types import SimpleNamespace + + from wolfbot.services.llm_trace import extract_openai_tokens + + resp = SimpleNamespace( + usage=SimpleNamespace( + prompt_tokens=1500, completion_tokens=200, total_tokens=1700 + ) + ) + assert extract_openai_tokens(resp) == { + "prompt": 1500, + "completion": 200, + "total": 1700, + } + # Test fakes without `usage` must return None, never raise. + assert extract_openai_tokens(SimpleNamespace()) is None + + +def test_extract_gemini_vertex_tokens() -> None: + from types import SimpleNamespace + + from wolfbot.services.llm_trace import extract_gemini_vertex_tokens + + resp = SimpleNamespace( + usage_metadata=SimpleNamespace( + prompt_token_count=2000, + candidates_token_count=300, + total_token_count=2300, + ) + ) + assert extract_gemini_vertex_tokens(resp) == { + "prompt": 2000, + "completion": 300, + "total": 2300, + } + assert extract_gemini_vertex_tokens(SimpleNamespace()) is None + + +def test_extract_gemini_rest_tokens() -> None: + from wolfbot.services.llm_trace import extract_gemini_rest_tokens + + body = { + "candidates": [], + "usageMetadata": { + "promptTokenCount": 800, + "candidatesTokenCount": 100, + "totalTokenCount": 900, + }, + } + assert extract_gemini_rest_tokens(body) == { + "prompt": 800, + "completion": 100, + "total": 900, + } + assert extract_gemini_rest_tokens({}) is None + + def test_parse_game_id_from_phase_id() -> None: assert parse_game_id_from_phase_id("g_abc::day1::DAY_DISCUSSION::1") == "g_abc" assert parse_game_id_from_phase_id(None) is None From fcb30906052a809f8e44228753c33a3bef8e671d Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 13:38:42 +0900 Subject: [PATCH 029/133] feat(viewer): Next.js + MUI viewer for per-game LLM/STT trace 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. --- viewer/.gitignore | 25 + viewer/README.md | 45 + viewer/next.config.ts | 5 + viewer/package.json | 32 + viewer/pnpm-lock.yaml | 4014 +++++++++++++++++++++++ viewer/sample-data/game-sample.json | 1424 ++++++++ viewer/sample-data/generate_sample.py | 767 +++++ viewer/src/app/globals.css | 25 + viewer/src/app/layout.tsx | 28 + viewer/src/app/page.tsx | 7 + viewer/src/components/GameHeader.tsx | 88 + viewer/src/components/GameView.tsx | 39 + viewer/src/components/PhaseSection.tsx | 377 +++ viewer/src/components/SeatGrid.tsx | 87 + viewer/src/components/StatsPanel.tsx | 156 + viewer/src/components/ThemeRegistry.tsx | 26 + viewer/src/components/TraceDrawer.tsx | 188 ++ viewer/src/lib/data.ts | 28 + viewer/src/lib/format.ts | 98 + viewer/src/lib/types.ts | 120 + viewer/tsconfig.json | 23 + 21 files changed, 7602 insertions(+) create mode 100644 viewer/.gitignore create mode 100644 viewer/README.md create mode 100644 viewer/next.config.ts create mode 100644 viewer/package.json create mode 100644 viewer/pnpm-lock.yaml create mode 100644 viewer/sample-data/game-sample.json create mode 100644 viewer/sample-data/generate_sample.py create mode 100644 viewer/src/app/globals.css create mode 100644 viewer/src/app/layout.tsx create mode 100644 viewer/src/app/page.tsx create mode 100644 viewer/src/components/GameHeader.tsx create mode 100644 viewer/src/components/GameView.tsx create mode 100644 viewer/src/components/PhaseSection.tsx create mode 100644 viewer/src/components/SeatGrid.tsx create mode 100644 viewer/src/components/StatsPanel.tsx create mode 100644 viewer/src/components/ThemeRegistry.tsx create mode 100644 viewer/src/components/TraceDrawer.tsx create mode 100644 viewer/src/lib/data.ts create mode 100644 viewer/src/lib/format.ts create mode 100644 viewer/src/lib/types.ts create mode 100644 viewer/tsconfig.json diff --git a/viewer/.gitignore b/viewer/.gitignore new file mode 100644 index 0000000..59fb1bd --- /dev/null +++ b/viewer/.gitignore @@ -0,0 +1,25 @@ +# Next.js +.next/ +out/ +build/ + +# Dependencies +node_modules/ +.pnp +.pnp.js +.pnp.cjs + +# Production +*.tsbuildinfo +next-env.d.ts + +# Logs / env +*.log +.env*.local + +# OS +.DS_Store + +# IDE +.vscode/ +.idea/ diff --git a/viewer/README.md b/viewer/README.md new file mode 100644 index 0000000..f561de4 --- /dev/null +++ b/viewer/README.md @@ -0,0 +1,45 @@ +# wolfbot Game Viewer + +Per-phase view of player actions, speeches, LLM prompts, and token usage for a single wolfbot game. Reads a single self-contained JSON file (default: `sample-data/game-sample.json`). + +## Quick start + +```bash +cd viewer +pnpm install +pnpm dev +# → http://localhost:3000 +``` + +## Loading a different game + +Set `GAME_FILE` to any absolute or relative JSON path: + +```bash +GAME_FILE=/abs/path/to/exported-game.json pnpm dev +GAME_FILE=../some-other-game.json pnpm dev +``` + +The path is resolved from the `viewer/` directory when relative. + +## Sample data + +`sample-data/game-sample.json` is generated by `sample-data/generate_sample.py`. Re-run from the repo root if the schema changes: + +```bash +uv run python viewer/sample-data/generate_sample.py +``` + +The synthetic game it produces is a 2-day village victory with a fake-seer counter-CO that gets caught. It exercises every UI surface the viewer supports — public logs, votes, night actions, NPC speeches, voice-STT entries, and matched LLM trace lines with realistic prompt/response/token data. + +## What gets shown + +- **Game header** — game id, mode, victor, total LLM calls / tokens / latency +- **Seat grid** — 9 seat cards with role, persona, alive/dead status, faction badge +- **Phase timeline** — chronological event list per phase: phase changes, public announcements, speeches (with CO badges + STT confidence), votes, night actions +- **Trace drawer** — click the bulb icon on any LLM-backed event to see the full system + user prompt, raw response, token usage, latency, model, and metadata +- **Stats panel** — per-seat and per-phase aggregates (call count, prompt/completion/total tokens, latency) + +## Plugging in real game data + +Real games run by the wolf bot already write LLM trace JSONL files into `logs/llm_calls/{game_id}/*.jsonl` (gameplay / npc_{persona} / voice_stt). The remaining game-state pieces (seats, public logs, votes, night actions) live in `wolfbot.db`. To view a real game in this viewer, write an exporter that joins those two sources into the same shape as `sample-data/game-sample.json` (see `viewer/src/lib/types.ts` for the canonical shape) and then point `GAME_FILE` at it. diff --git a/viewer/next.config.ts b/viewer/next.config.ts new file mode 100644 index 0000000..cb651cd --- /dev/null +++ b/viewer/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/viewer/package.json b/viewer/package.json new file mode 100644 index 0000000..d4833ba --- /dev/null +++ b/viewer/package.json @@ -0,0 +1,32 @@ +{ + "name": "wolfbot-viewer", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@emotion/cache": "^11.13.5", + "@emotion/react": "^11.13.5", + "@emotion/styled": "^11.13.5", + "@mui/icons-material": "^6.1.10", + "@mui/material": "^6.1.10", + "@mui/material-nextjs": "^6.1.9", + "next": "^15.1.3", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "eslint": "^9.17.0", + "eslint-config-next": "^15.1.3", + "typescript": "^5.7.2" + } +} diff --git a/viewer/pnpm-lock.yaml b/viewer/pnpm-lock.yaml new file mode 100644 index 0000000..819f07e --- /dev/null +++ b/viewer/pnpm-lock.yaml @@ -0,0 +1,4014 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@emotion/cache': + specifier: ^11.13.5 + version: 11.14.0 + '@emotion/react': + specifier: ^11.13.5 + version: 11.14.0(@types/react@19.2.14)(react@19.2.5) + '@emotion/styled': + specifier: ^11.13.5 + version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + '@mui/icons-material': + specifier: ^6.1.10 + version: 6.5.0(@mui/material@6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + '@mui/material': + specifier: ^6.1.10 + version: 6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mui/material-nextjs': + specifier: ^6.1.9 + version: 6.5.0(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(next@15.5.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + next: + specifier: ^15.1.3 + version: 15.5.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: + specifier: ^19.0.0 + version: 19.2.5 + react-dom: + specifier: ^19.0.0 + version: 19.2.5(react@19.2.5) + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.19.17 + '@types/react': + specifier: ^19.0.2 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.2 + version: 19.2.3(@types/react@19.2.14) + eslint: + specifier: ^9.17.0 + version: 9.39.4 + eslint-config-next: + specifier: ^15.1.3 + version: 15.5.15(eslint@9.39.4)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/is-prop-valid@1.4.0': + resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/react@11.14.0': + resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/styled@11.14.1': + resolution: {integrity: sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0': + resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} + peerDependencies: + react: '>=16.8.0' + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mui/core-downloads-tracker@6.5.0': + resolution: {integrity: sha512-LGb8t8i6M2ZtS3Drn3GbTI1DVhDY6FJ9crEey2lZ0aN2EMZo8IZBZj9wRf4vqbZHaWjsYgtbOnJw5V8UWbmK2Q==} + + '@mui/icons-material@6.5.0': + resolution: {integrity: sha512-VPuPqXqbBPlcVSA0BmnoE4knW4/xG6Thazo8vCLWkOKusko6DtwFV6B665MMWJ9j0KFohTIf3yx2zYtYacvG1g==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@mui/material': ^6.5.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/material-nextjs@6.5.0': + resolution: {integrity: sha512-VV+4BhmnY32pbPuIevs+rPl0R+bkVvqGCqRqZqNhkdBdX0pn1IHQ5mG/EfYJKoKUkF7q9FKSag5wduSTUxDqnQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/cache': ^11.11.0 + '@emotion/react': ^11.11.4 + '@emotion/server': ^11.11.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + next: ^13.0.0 || ^14.0.0 || ^15.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/cache': + optional: true + '@emotion/server': + optional: true + '@types/react': + optional: true + + '@mui/material@6.5.0': + resolution: {integrity: sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@mui/material-pigment-css': ^6.5.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@mui/material-pigment-css': + optional: true + '@types/react': + optional: true + + '@mui/private-theming@6.4.9': + resolution: {integrity: sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/styled-engine@6.5.0': + resolution: {integrity: sha512-8woC2zAqF4qUDSPIBZ8v3sakj+WgweolpyM/FXf8jAx6FMls+IE4Y8VDZc+zS805J7PRz31vz73n2SovKGaYgw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.4.1 + '@emotion/styled': ^11.3.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + + '@mui/system@6.5.0': + resolution: {integrity: sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + + '@mui/types@7.2.24': + resolution: {integrity: sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/utils@6.4.9': + resolution: {integrity: sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@next/env@15.5.15': + resolution: {integrity: sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==} + + '@next/eslint-plugin-next@15.5.15': + resolution: {integrity: sha512-ExQoBfyKMjAUQ2nuF39ryQsG26H374ZfH13dlOZqf6TaE9ycRbIm+qUbUFCliU4BtQhiqtS7cnGA1yWfPMQ+jA==} + + '@next/swc-darwin-arm64@15.5.15': + resolution: {integrity: sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.5.15': + resolution: {integrity: sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.5.15': + resolution: {integrity: sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@15.5.15': + resolution: {integrity: sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@15.5.15': + resolution: {integrity: sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@15.5.15': + resolution: {integrity: sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@15.5.15': + resolution: {integrity: sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.5.15': + resolution: {integrity: sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@rushstack/eslint-patch@1.16.1': + resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react-transition-group@4.4.12': + resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} + peerDependencies: + '@types/react': '*' + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@typescript-eslint/eslint-plugin@8.59.1': + resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.1': + resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.1': + resolution: {integrity: sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.1': + resolution: {integrity: sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.1': + resolution: {integrity: sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.1': + resolution: {integrity: sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.1': + resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.1': + resolution: {integrity: sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.1': + resolution: {integrity: sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.1': + resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.11.3: + resolution: {integrity: sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==} + engines: {node: '>=4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001791: + resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.2: + resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-next@15.5.15: + resolution: {integrity: sha512-mI5KIONOIosjF3jK2z9a8fY2LePNeW5C4lRJ+XZoJHAKkwx2MQjMPQ2/kL7tsMRPcQPZc/UBtCfqxElluL1CBg==} + peerDependencies: + eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-import-resolver-typescript@3.10.1: + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + next@15.5.15: + resolution: {integrity: sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + peerDependencies: + react: ^19.2.5 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@19.2.5: + resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==} + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + engines: {node: '>=0.10.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + stable-hash@0.0.5: + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.28.6 + '@babel/runtime': 7.29.2 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/hash@0.9.2': {} + + '@emotion/is-prop-valid@1.4.0': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + + '@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.5) + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + hoist-non-react-statics: 3.3.2 + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - supports-color + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.2.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@emotion/babel-plugin': 11.13.5 + '@emotion/is-prop-valid': 1.4.0 + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.5) + '@emotion/utils': 1.4.2 + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - supports-color + + '@emotion/unitless@0.10.0': {} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.2.5)': + dependencies: + react: 19.2.5 + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.4.0': {} + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mui/core-downloads-tracker@6.5.0': {} + + '@mui/icons-material@6.5.0(@mui/material@6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/material': 6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@mui/material-nextjs@6.5.0(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(next@15.5.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) + next: 15.5.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@emotion/cache': 11.14.0 + '@types/react': 19.2.14 + + '@mui/material@6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/core-downloads-tracker': 6.5.0 + '@mui/system': 6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + '@mui/types': 7.2.24(@types/react@19.2.14) + '@mui/utils': 6.4.9(@types/react@19.2.14)(react@19.2.5) + '@popperjs/core': 2.11.8 + '@types/react-transition-group': 4.4.12(@types/react@19.2.14) + clsx: 2.1.1 + csstype: 3.2.3 + prop-types: 15.8.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-is: 19.2.5 + react-transition-group: 4.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + '@types/react': 19.2.14 + + '@mui/private-theming@6.4.9(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/utils': 6.4.9(@types/react@19.2.14)(react@19.2.5) + prop-types: 15.8.1 + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@mui/styled-engine@6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/sheet': 1.4.0 + csstype: 3.2.3 + prop-types: 15.8.1 + react: 19.2.5 + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + + '@mui/system@6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/private-theming': 6.4.9(@types/react@19.2.14)(react@19.2.5) + '@mui/styled-engine': 6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + '@mui/types': 7.2.24(@types/react@19.2.14) + '@mui/utils': 6.4.9(@types/react@19.2.14)(react@19.2.5) + clsx: 2.1.1 + csstype: 3.2.3 + prop-types: 15.8.1 + react: 19.2.5 + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + '@types/react': 19.2.14 + + '@mui/types@7.2.24(@types/react@19.2.14)': + optionalDependencies: + '@types/react': 19.2.14 + + '@mui/utils@6.4.9(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/types': 7.2.24(@types/react@19.2.14) + '@types/prop-types': 15.7.15 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 19.2.5 + react-is: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@next/env@15.5.15': {} + + '@next/eslint-plugin-next@15.5.15': + dependencies: + fast-glob: 3.3.1 + + '@next/swc-darwin-arm64@15.5.15': + optional: true + + '@next/swc-darwin-x64@15.5.15': + optional: true + + '@next/swc-linux-arm64-gnu@15.5.15': + optional: true + + '@next/swc-linux-arm64-musl@15.5.15': + optional: true + + '@next/swc-linux-x64-gnu@15.5.15': + optional: true + + '@next/swc-linux-x64-musl@15.5.15': + optional: true + + '@next/swc-win32-arm64-msvc@15.5.15': + optional: true + + '@next/swc-win32-x64-msvc@15.5.15': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@popperjs/core@2.11.8': {} + + '@rtsao/scc@1.1.0': {} + + '@rushstack/eslint-patch@1.16.1': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + '@types/parse-json@4.0.2': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react-transition-group@4.4.12(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/type-utils': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.1': + dependencies: + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 + + '@typescript-eslint/tsconfig-utils@8.59.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.59.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.1': {} + + '@typescript-eslint/typescript-estree@8.59.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.1': + dependencies: + '@typescript-eslint/types': 8.59.1 + eslint-visitor-keys: 5.0.1 + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + ast-types-flow@0.0.8: {} + + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.11.3: {} + + axobject-query@4.1.0: {} + + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.29.2 + cosmiconfig: 7.1.0 + resolve: 1.22.12 + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001791: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@1.9.0: {} + + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.3 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + damerau-levenshtein@1.0.8: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + detect-libc@2.1.2: + optional: true + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.29.2 + csstype: 3.2.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + emoji-regex@9.2.2: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.3.2: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.3 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + escape-string-regexp@4.0.0: {} + + eslint-config-next@15.5.15(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@next/eslint-plugin-next': 15.5.15 + '@rushstack/eslint-patch': 1.16.1 + '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4) + eslint-plugin-react: 7.37.5(eslint@9.39.4) + eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + eslint-import-resolver-node@0.3.10: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 2.0.0-next.6 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.4 + get-tsconfig: 4.14.0 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.16 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4) + hasown: 2.0.3 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.11.3 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.4 + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@5.2.0(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + + eslint-plugin-react@7.37.5(eslint@9.39.4): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.2 + eslint: 9.39.4 + estraverse: 5.3.0 + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.6 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.1: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-root@1.1.0: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.3 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.3 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.7.4 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.3 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + next@15.5.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@next/env': 15.5.15 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001791 + postcss: 8.4.31 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + styled-jsx: 5.1.6(react@19.2.5) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.15 + '@next/swc-darwin-x64': 15.5.15 + '@next/swc-linux-arm64-gnu': 15.5.15 + '@next/swc-linux-arm64-musl': 15.5.15 + '@next/swc-linux-x64-gnu': 15.5.15 + '@next/swc-linux-x64-musl': 15.5.15 + '@next/swc-win32-arm64-msvc': 15.5.15 + '@next/swc-win32-x64-msvc': 15.5.15 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + possible-typed-array-names@1.1.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@19.2.5(react@19.2.5): + dependencies: + react: 19.2.5 + scheduler: 0.27.0 + + react-is@16.13.1: {} + + react-is@19.2.5: {} + + react-transition-group@4.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@babel/runtime': 7.29.2 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + react@19.2.5: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.6: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + source-map@0.5.7: {} + + stable-hash@0.0.5: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-bom@3.0.0: {} + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(react@19.2.5): + dependencies: + client-only: 0.0.1 + react: 19.2.5 + + stylis@4.2.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript@5.9.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@6.21.0: {} + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yaml@1.10.3: {} + + yocto-queue@0.1.0: {} diff --git a/viewer/sample-data/game-sample.json b/viewer/sample-data/game-sample.json new file mode 100644 index 0000000..3d32686 --- /dev/null +++ b/viewer/sample-data/game-sample.json @@ -0,0 +1,1424 @@ +{ + "game": { + "id": "g_sample_001", + "guild_id": "1498215601808867421", + "host_user_id": "146810000000000001", + "discussion_mode": "rounds", + "created_at_ms": 1714291200000, + "ended_at_ms": 1714292020000, + "victory": "village", + "main_text_channel_id": "1498215837528621055", + "main_vc_channel_id": "1498215837528621056" + }, + "seats": [ + { + "seat_no": 1, + "display_name": "あなた", + "is_llm": false, + "persona_key": null, + "discord_user_id": "146810000000000001", + "role": "VILLAGER", + "alive": true, + "death_cause": null, + "death_day": null + }, + { + "seat_no": 2, + "display_name": "🌙 セツ", + "is_llm": true, + "persona_key": "setsu", + "discord_user_id": "1498225401372086313", + "role": "SEER", + "alive": true, + "death_cause": null, + "death_day": null + }, + { + "seat_no": 3, + "display_name": "🍷 ジーナ", + "is_llm": true, + "persona_key": "gina", + "discord_user_id": "1498225401372086314", + "role": "WEREWOLF", + "alive": false, + "death_cause": "EXECUTED", + "death_day": 1 + }, + { + "seat_no": 4, + "display_name": "🎲 SQ", + "is_llm": true, + "persona_key": "sq", + "discord_user_id": "1498225401372086315", + "role": "VILLAGER", + "alive": true, + "death_cause": null, + "death_day": null + }, + { + "seat_no": 5, + "display_name": "🔮 ラキオ", + "is_llm": true, + "persona_key": "raqio", + "discord_user_id": "1498225401372086316", + "role": "MEDIUM", + "alive": true, + "death_cause": null, + "death_day": null + }, + { + "seat_no": 6, + "display_name": "🛡 ステラ", + "is_llm": true, + "persona_key": "stella", + "discord_user_id": "1498225401372086317", + "role": "KNIGHT", + "alive": true, + "death_cause": null, + "death_day": null + }, + { + "seat_no": 7, + "display_name": "⚔ シゲミチ", + "is_llm": true, + "persona_key": "shigemichi", + "discord_user_id": "1498225401372086318", + "role": "WEREWOLF", + "alive": false, + "death_cause": "EXECUTED", + "death_day": 2 + }, + { + "seat_no": 8, + "display_name": "☄ コメット", + "is_llm": true, + "persona_key": "comet", + "discord_user_id": "1498225401372086319", + "role": "MADMAN", + "alive": true, + "death_cause": null, + "death_day": null + }, + { + "seat_no": 9, + "display_name": "📐 ジョナス", + "is_llm": true, + "persona_key": "jonas", + "discord_user_id": "1498225401372086320", + "role": "VILLAGER", + "alive": true, + "death_cause": null, + "death_day": null + } + ], + "phases": [ + { + "day": 0, + "phase": "SETUP", + "started_at_ms": 1714291230000, + "public_logs": [ + { + "kind": "SETUP_COMPLETE", + "actor_seat": null, + "text": "参加者 9 名で人狼ゲームを開始します。役職は各DMをご確認ください。", + "created_at_ms": 1714291230000 + } + ], + "speech_events": [], + "votes": [], + "night_actions": [] + }, + { + "day": 0, + "phase": "NIGHT_0", + "started_at_ms": 1714291235000, + "public_logs": [], + "speech_events": [], + "votes": [], + "night_actions": [ + { + "day": 0, + "actor_seat": 2, + "kind": "DIVINE_NIGHT0_RANDOM_WHITE", + "target_seat": 9, + "submitted_at_ms": 1714291238000 + } + ] + }, + { + "day": 1, + "phase": "DAY_DISCUSSION", + "started_at_ms": 1714291260000, + "public_logs": [ + { + "kind": "PHASE_CHANGE", + "actor_seat": null, + "text": "夜が明けました。1 日目の議論を開始致します。制限時間は 300 秒でございます。", + "created_at_ms": 1714291260000 + } + ], + "speech_events": [ + { + "event_id": "sp_2_1714291275000", + "source": "rounds_text", + "speaker_seat": 2, + "text": "占い師COします。初日ランダム白で席9 ジョナスを占いました。白判定です。", + "stt_confidence": null, + "summary": "seat 2 COs seer; seat 9 white", + "co_declaration": "seer", + "addressed_seat_no": null, + "created_at_ms": 1714291275000 + }, + { + "event_id": "sp_3_1714291290000", + "source": "rounds_text", + "speaker_seat": 3, + "text": "私も占い師です。席4 SQを占って白でした。席2は何かおかしい。", + "stt_confidence": null, + "summary": "seat 3 counter-COs seer; seat 4 white", + "co_declaration": "seer", + "addressed_seat_no": null, + "created_at_ms": 1714291290000 + }, + { + "event_id": "sp_5_1714291310000", + "source": "rounds_text", + "speaker_seat": 5, + "text": "霊媒師COはまだしません。今日処刑が出てから結果を出します。", + "stt_confidence": null, + "summary": "seat 5 plans medium CO after first execution", + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291310000 + }, + { + "event_id": "sp_9_1714291330000", + "source": "rounds_text", + "speaker_seat": 9, + "text": "席2の白判定をもらった9番です。占い理由が筋が通ってる。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291330000 + }, + { + "event_id": "sp_4_1714291350000", + "source": "rounds_text", + "speaker_seat": 4, + "text": "占いが2人いる。どちらが本物か議論したい。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291350000 + }, + { + "event_id": "sp_6_1714291370000", + "source": "rounds_text", + "speaker_seat": 6, + "text": "占いCOの2人をしっかり見極めましょう。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291370000 + }, + { + "event_id": "sp_7_1714291390000", + "source": "rounds_text", + "speaker_seat": 7, + "text": "席3が偽のように見える。占い理由が雑な印象。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291390000 + }, + { + "event_id": "sp_8_1714291410000", + "source": "rounds_text", + "speaker_seat": 8, + "text": "私は席2が怪しいと感じる。席3を信じたい。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291410000 + }, + { + "event_id": "sp_1_1714291430000", + "source": "rounds_text", + "speaker_seat": 1, + "text": "席3の占い理由がパッとしない。席2の方が信用できそう。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291430000 + }, + { + "event_id": "sp_2_1714291460000", + "source": "rounds_text", + "speaker_seat": 2, + "text": "席3の理由が薄いです。席3を投票候補にしたい。", + "stt_confidence": null, + "summary": "seat 2 nominates seat 3", + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291460000 + }, + { + "event_id": "sp_3_1714291475000", + "source": "rounds_text", + "speaker_seat": 3, + "text": "席2が偽です。みなさん席2に投票してください。", + "stt_confidence": null, + "summary": "seat 3 nominates seat 2", + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291475000 + }, + { + "event_id": "sp_5_1714291490000", + "source": "rounds_text", + "speaker_seat": 5, + "text": "占い騙りなら2が真の可能性が高い気がする。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291490000 + }, + { + "event_id": "sp_9_1714291510000", + "source": "rounds_text", + "speaker_seat": 9, + "text": "席2に白を貰った立場として、席3が偽だと考える。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291510000 + } + ], + "votes": [], + "night_actions": [] + }, + { + "day": 1, + "phase": "DAY_VOTE", + "started_at_ms": 1714291560000, + "public_logs": [ + { + "kind": "PHASE_CHANGE", + "actor_seat": null, + "text": "議論時間が終了しました。投票フェイズへ移行致します。制限時間は 60 秒でございます。", + "created_at_ms": 1714291560000 + }, + { + "kind": "EXECUTION", + "actor_seat": 3, + "text": "席3 🍷 ジーナ が処刑されました。\n\n投票内訳: 席3=5票 / 席2=3票 / 席3=1票", + "created_at_ms": 1714291620000 + } + ], + "speech_events": [], + "votes": [ + { + "day": 1, + "round": 1, + "voter_seat": 1, + "target_seat": 3, + "submitted_at_ms": 1714291565000 + }, + { + "day": 1, + "round": 1, + "voter_seat": 2, + "target_seat": 3, + "submitted_at_ms": 1714291570000 + }, + { + "day": 1, + "round": 1, + "voter_seat": 3, + "target_seat": 2, + "submitted_at_ms": 1714291575000 + }, + { + "day": 1, + "round": 1, + "voter_seat": 4, + "target_seat": 3, + "submitted_at_ms": 1714291580000 + }, + { + "day": 1, + "round": 1, + "voter_seat": 5, + "target_seat": 3, + "submitted_at_ms": 1714291585000 + }, + { + "day": 1, + "round": 1, + "voter_seat": 6, + "target_seat": 3, + "submitted_at_ms": 1714291590000 + }, + { + "day": 1, + "round": 1, + "voter_seat": 7, + "target_seat": 2, + "submitted_at_ms": 1714291595000 + }, + { + "day": 1, + "round": 1, + "voter_seat": 8, + "target_seat": 2, + "submitted_at_ms": 1714291600000 + }, + { + "day": 1, + "round": 1, + "voter_seat": 9, + "target_seat": 3, + "submitted_at_ms": 1714291605000 + } + ], + "night_actions": [] + }, + { + "day": 1, + "phase": "NIGHT", + "started_at_ms": 1714291620000, + "public_logs": [ + { + "kind": "PHASE_CHANGE", + "actor_seat": null, + "text": "夜のフェイズへ移行致します。制限時間は 90 秒でございます。", + "created_at_ms": 1714291620000 + } + ], + "speech_events": [], + "votes": [], + "night_actions": [ + { + "day": 1, + "actor_seat": 2, + "kind": "DIVINE", + "target_seat": 7, + "submitted_at_ms": 1714291640000 + }, + { + "day": 1, + "actor_seat": 6, + "kind": "GUARD", + "target_seat": 2, + "submitted_at_ms": 1714291655000 + }, + { + "day": 1, + "actor_seat": 7, + "kind": "ATTACK", + "target_seat": 2, + "submitted_at_ms": 1714291680000 + } + ] + }, + { + "day": 2, + "phase": "DAY_DISCUSSION", + "started_at_ms": 1714291710000, + "public_logs": [ + { + "kind": "MORNING", + "actor_seat": null, + "text": "平和な朝です。昨晩の犠牲者はいません。", + "created_at_ms": 1714291710000 + }, + { + "kind": "PHASE_CHANGE", + "actor_seat": null, + "text": "2 日目の議論を開始致します。制限時間は 240 秒でございます。", + "created_at_ms": 1714291710000 + } + ], + "speech_events": [ + { + "event_id": "sp_2_1714291730000", + "source": "rounds_text", + "speaker_seat": 2, + "text": "席7を占いました。黒判定です。席7 シゲミチが人狼です。", + "stt_confidence": null, + "summary": "seat 2 reveals seat 7 is wolf", + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291730000 + }, + { + "event_id": "sp_5_1714291750000", + "source": "rounds_text", + "speaker_seat": 5, + "text": "霊媒結果: 席3 ジーナは人狼でした。占い騙りで確定です。", + "stt_confidence": null, + "summary": "seat 5 confirms seat 3 was wolf", + "co_declaration": "medium", + "addressed_seat_no": null, + "created_at_ms": 1714291750000 + }, + { + "event_id": "sp_6_1714291770000", + "source": "rounds_text", + "speaker_seat": 6, + "text": "騎士COします。昨夜は席2を護衛して、襲撃を弾きました。", + "stt_confidence": null, + "summary": "seat 6 reveals knight, guarded seat 2", + "co_declaration": "knight", + "addressed_seat_no": null, + "created_at_ms": 1714291770000 + }, + { + "event_id": "sp_7_1714291790000", + "source": "rounds_text", + "speaker_seat": 7, + "text": "私は人狼じゃない。席2の占いは捏造です。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291790000 + }, + { + "event_id": "sp_8_1714291810000", + "source": "rounds_text", + "speaker_seat": 8, + "text": "席2 セツが本当に占い師なら、席7 黒は信じざるを得ない。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291810000 + }, + { + "event_id": "sp_1_1714291830000", + "source": "rounds_text", + "speaker_seat": 1, + "text": "占い・霊媒・騎士の3CO全部噛み合ってるので席7処刑で行きましょう。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291830000 + } + ], + "votes": [], + "night_actions": [] + }, + { + "day": 2, + "phase": "DAY_VOTE", + "started_at_ms": 1714291960000, + "public_logs": [ + { + "kind": "EXECUTION", + "actor_seat": 7, + "text": "席7 ⚔ シゲミチ が処刑されました。\n\n投票内訳: 席7=7票 / 席2=1票", + "created_at_ms": 1714292020000 + }, + { + "kind": "VICTORY", + "actor_seat": null, + "text": "村人陣営の勝利!", + "created_at_ms": 1714292020000 + }, + { + "kind": "ROLE_REVEAL", + "actor_seat": null, + "text": "役職公開:\n席1 あなた = 村人 (生存)\n席2 🌙 セツ = 占い師 (生存)\n席3 🍷 ジーナ = 人狼 (1日目処刑)\n席4 🎲 SQ = 村人 (生存)\n席5 🔮 ラキオ = 霊媒師 (生存)\n席6 🛡 ステラ = 騎士 (生存)\n席7 ⚔ シゲミチ = 人狼 (2日目処刑)\n席8 ☄ コメット = 狂人 (生存)\n席9 📐 ジョナス = 村人 (生存)", + "created_at_ms": 1714292020000 + } + ], + "speech_events": [], + "votes": [ + { + "day": 2, + "round": 1, + "voter_seat": 1, + "target_seat": 7, + "submitted_at_ms": 1714291970000 + }, + { + "day": 2, + "round": 1, + "voter_seat": 2, + "target_seat": 7, + "submitted_at_ms": 1714291975000 + }, + { + "day": 2, + "round": 1, + "voter_seat": 4, + "target_seat": 7, + "submitted_at_ms": 1714291980000 + }, + { + "day": 2, + "round": 1, + "voter_seat": 5, + "target_seat": 7, + "submitted_at_ms": 1714291985000 + }, + { + "day": 2, + "round": 1, + "voter_seat": 6, + "target_seat": 7, + "submitted_at_ms": 1714291990000 + }, + { + "day": 2, + "round": 1, + "voter_seat": 7, + "target_seat": 2, + "submitted_at_ms": 1714291995000 + }, + { + "day": 2, + "round": 1, + "voter_seat": 8, + "target_seat": 7, + "submitted_at_ms": 1714292000000 + }, + { + "day": 2, + "round": 1, + "voter_seat": 9, + "target_seat": 7, + "submitted_at_ms": 1714292005000 + } + ], + "night_actions": [] + }, + { + "day": 2, + "phase": "GAME_OVER", + "started_at_ms": 1714292020000, + "public_logs": [], + "speech_events": [], + "votes": [], + "night_actions": [] + } + ], + "trace": [ + { + "ts": "2026-04-28T08:00:15+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=2 persona=setsu role=SEER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席2の役職 SEER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=1)\n\n公開ログ抜粋:\n(SETUP) 参加者 9 名で人狼ゲームを開始します。\n(PHASE_CHANGE) 1 日目の議論を開始致します。\n\nあなたの履歴: 初日議論ラウンド1, 席2として発話\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"占い師COします。初日ランダム白で席9 ジョナスを占いました。白判定です。\", \"target_name\": null, \"reason_summary\": \"1巡目の方針提示\", \"confidence\": 0.7, \"co_declaration\": \"seer\"}", + "latency_ms": 1328, + "tokens": { + "prompt": 272, + "completion": 174, + "total": 446 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:00:33+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=3 persona=gina role=WEREWOLF", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席3の役職 WEREWOLF として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=1)\n\n公開ログ抜粋:\n(SETUP) 参加者 9 名で人狼ゲームを開始します。\n(PHASE_CHANGE) 1 日目の議論を開始致します。\n\nあなたの履歴: 初日議論ラウンド1, 席3として発話\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"私も占い師です。席4 SQを占って白でした。席2は何かおかしい。\", \"target_name\": null, \"reason_summary\": \"1巡目の方針提示\", \"confidence\": 0.7, \"co_declaration\": \"seer\"}", + "latency_ms": 1151, + "tokens": { + "prompt": 276, + "completion": 169, + "total": 445 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:00:51+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=5 persona=raqio role=MEDIUM", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席5の役職 MEDIUM として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=1)\n\n公開ログ抜粋:\n(SETUP) 参加者 9 名で人狼ゲームを開始します。\n(PHASE_CHANGE) 1 日目の議論を開始致します。\n\nあなたの履歴: 初日議論ラウンド1, 席5として発話\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"霊媒師COはまだしません。今日処刑が出てから結果を出します。\", \"target_name\": null, \"reason_summary\": \"1巡目の方針提示\", \"confidence\": 0.7, \"co_declaration\": null}", + "latency_ms": 1663, + "tokens": { + "prompt": 274, + "completion": 165, + "total": 439 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:01:09+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=9 persona=jonas role=VILLAGER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席9の役職 VILLAGER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=1)\n\n公開ログ抜粋:\n(SETUP) 参加者 9 名で人狼ゲームを開始します。\n(PHASE_CHANGE) 1 日目の議論を開始致します。\n\nあなたの履歴: 初日議論ラウンド1, 席9として発話\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"席2の白判定をもらった9番です。占い理由が筋が通ってる。\", \"target_name\": null, \"reason_summary\": \"1巡目の方針提示\", \"confidence\": 0.7, \"co_declaration\": null}", + "latency_ms": 1601, + "tokens": { + "prompt": 276, + "completion": 163, + "total": 439 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:01:27+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=4 persona=sq role=VILLAGER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席4の役職 VILLAGER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=1)\n\n公開ログ抜粋:\n(SETUP) 参加者 9 名で人狼ゲームを開始します。\n(PHASE_CHANGE) 1 日目の議論を開始致します。\n\nあなたの履歴: 初日議論ラウンド1, 席4として発話\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"占いが2人いる。どちらが本物か議論したい。\", \"target_name\": null, \"reason_summary\": \"1巡目の方針提示\", \"confidence\": 0.7, \"co_declaration\": null}", + "latency_ms": 1557, + "tokens": { + "prompt": 276, + "completion": 156, + "total": 432 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:01:45+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=6 persona=stella role=KNIGHT", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席6の役職 KNIGHT として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=1)\n\n公開ログ抜粋:\n(SETUP) 参加者 9 名で人狼ゲームを開始します。\n(PHASE_CHANGE) 1 日目の議論を開始致します。\n\nあなたの履歴: 初日議論ラウンド1, 席6として発話\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"占いCOの2人をしっかり見極めましょう。\", \"target_name\": null, \"reason_summary\": \"1巡目の方針提示\", \"confidence\": 0.7, \"co_declaration\": null}", + "latency_ms": 1385, + "tokens": { + "prompt": 274, + "completion": 155, + "total": 429 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:02:03+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=7 persona=shigemichi role=WEREWOLF", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席7の役職 WEREWOLF として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=1)\n\n公開ログ抜粋:\n(SETUP) 参加者 9 名で人狼ゲームを開始します。\n(PHASE_CHANGE) 1 日目の議論を開始致します。\n\nあなたの履歴: 初日議論ラウンド1, 席7として発話\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"席3が偽のように見える。占い理由が雑な印象。\", \"target_name\": null, \"reason_summary\": \"1巡目の方針提示\", \"confidence\": 0.7, \"co_declaration\": null}", + "latency_ms": 1309, + "tokens": { + "prompt": 276, + "completion": 157, + "total": 433 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:02:21+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=8 persona=comet role=MADMAN", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席8の役職 MADMAN として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=1)\n\n公開ログ抜粋:\n(SETUP) 参加者 9 名で人狼ゲームを開始します。\n(PHASE_CHANGE) 1 日目の議論を開始致します。\n\nあなたの履歴: 初日議論ラウンド1, 席8として発話\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"私は席2が怪しいと感じる。席3を信じたい。\", \"target_name\": null, \"reason_summary\": \"1巡目の方針提示\", \"confidence\": 0.7, \"co_declaration\": null}", + "latency_ms": 2216, + "tokens": { + "prompt": 274, + "completion": 156, + "total": 430 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:05:05+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 1, + "actor": "seat=2 persona=setsu role=SEER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席2の役職 SEER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=1)\n\n投票先として合法な候補は: 席3 🍷 ジーナ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n席1 あなたは合法候補に含まれません(あなた自身)\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席3 🍷 ジーナ\", \"reason_summary\": \"占い理由が薄い席3を投票\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1278, + "tokens": { + "prompt": 275, + "completion": 145, + "total": 420 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:05:10+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 1, + "actor": "seat=3 persona=gina role=WEREWOLF", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席3の役職 WEREWOLF として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=1)\n\n投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n席1 あなたは合法候補に含まれません(あなた自身)\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席2 🌙 セツ\", \"reason_summary\": \"対抗占い師の席2を投票\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 2309, + "tokens": { + "prompt": 278, + "completion": 143, + "total": 421 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:05:15+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 1, + "actor": "seat=4 persona=sq role=VILLAGER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席4の役職 VILLAGER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=1)\n\n投票先として合法な候補は: 席2 🌙 セツ、席3 🍷 ジーナ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n席1 あなたは合法候補に含まれません(あなた自身)\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席3 🍷 ジーナ\", \"reason_summary\": \"席3の論理が破綻気味\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1964, + "tokens": { + "prompt": 279, + "completion": 143, + "total": 422 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:05:20+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 1, + "actor": "seat=5 persona=raqio role=MEDIUM", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席5の役職 MEDIUM として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=1)\n\n投票先として合法な候補は: 席2 🌙 セツ、席3 🍷 ジーナ、席4 🎲 SQ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n席1 あなたは合法候補に含まれません(あなた自身)\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席3 🍷 ジーナ\", \"reason_summary\": \"席3を吊って霊媒結果を取りたい\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1165, + "tokens": { + "prompt": 276, + "completion": 148, + "total": 424 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:05:25+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 1, + "actor": "seat=6 persona=stella role=KNIGHT", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席6の役職 KNIGHT として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=1)\n\n投票先として合法な候補は: 席2 🌙 セツ、席3 🍷 ジーナ、席4 🎲 SQ、席5 🔮 ラキオ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n席1 あなたは合法候補に含まれません(あなた自身)\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席3 🍷 ジーナ\", \"reason_summary\": \"占いCO 2人だが席3の方が偽臭\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1161, + "tokens": { + "prompt": 276, + "completion": 149, + "total": 425 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:05:30+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 1, + "actor": "seat=7 persona=shigemichi role=WEREWOLF", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席7の役職 WEREWOLF として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=1)\n\n投票先として合法な候補は: 席2 🌙 セツ、席3 🍷 ジーナ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席8 ☄ コメット、席9 📐 ジョナス\n席1 あなたは合法候補に含まれません(あなた自身)\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席2 🌙 セツ\", \"reason_summary\": \"席2が偽だと判断\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1291, + "tokens": { + "prompt": 277, + "completion": 140, + "total": 417 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:05:35+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 1, + "actor": "seat=8 persona=comet role=MADMAN", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席8の役職 MADMAN として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=1)\n\n投票先として合法な候補は: 席2 🌙 セツ、席3 🍷 ジーナ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席9 📐 ジョナス\n席1 あなたは合法候補に含まれません(あなた自身)\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席2 🌙 セツ\", \"reason_summary\": \"席3を信じる立場\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1547, + "tokens": { + "prompt": 275, + "completion": 140, + "total": 415 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:05:40+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 1, + "actor": "seat=9 persona=jonas role=VILLAGER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席9の役職 VILLAGER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=1)\n\n投票先として合法な候補は: 席2 🌙 セツ、席3 🍷 ジーナ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット\n席1 あなたは合法候補に含まれません(あなた自身)\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席3 🍷 ジーナ\", \"reason_summary\": \"白を貰った相手が偽でないとすれば席3が偽\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1576, + "tokens": { + "prompt": 277, + "completion": 153, + "total": 430 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:06:20+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "NIGHT", + "day": 1, + "actor": "seat=2 persona=setsu role=SEER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席2の役職 SEER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: NIGHT (day=1)\n\nあなたは占い師。占い対象を 1 名選んでください。\n合法候補: 席4 SQ、席5 ラキオ、席6 ステラ、席7 シゲミチ、席8 コメット、席9 ジョナス\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"night_action\", \"public_message\": \"\", \"target_name\": \"席7 ⚔ シゲミチ\", \"reason_summary\": \"票筋的に席7が黒臭い、占って確定したい\", \"confidence\": 0.75, \"co_declaration\": null}", + "latency_ms": 2134, + "tokens": { + "prompt": 243, + "completion": 161, + "total": 404 + }, + "error": null, + "metadata": { + "task": "night_action" + } + }, + { + "ts": "2026-04-28T08:06:35+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "NIGHT", + "day": 1, + "actor": "seat=6 persona=stella role=KNIGHT", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席6の役職 KNIGHT として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: NIGHT (day=1)\n\nあなたは騎士。護衛対象を 1 名選んでください。\n合法候補: 席1 あなた、席2 セツ、席4 SQ、席5 ラキオ、席7 シゲミチ、席8 コメット、席9 ジョナス\n前夜護衛: なし\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"night_action\", \"public_message\": \"\", \"target_name\": \"席2 🌙 セツ\", \"reason_summary\": \"占い真目の席2を護衛するのが最有力\", \"confidence\": 0.8, \"co_declaration\": null}", + "latency_ms": 2332, + "tokens": { + "prompt": 259, + "completion": 156, + "total": 415 + }, + "error": null, + "metadata": { + "task": "night_action" + } + }, + { + "ts": "2026-04-28T08:06:55+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "NIGHT", + "day": 1, + "actor": "seat=7 persona=shigemichi role=WEREWOLF", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席7の役職 WEREWOLF として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: NIGHT (day=1)\n\n人狼チャット: 相方は席3だったが処刑された。襲撃先を選んでください。\n合法候補: 席1、席2、席4、席5、席6、席8、席9\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"占い真の席2を噛むのが最優先。次点は霊媒候補の席5。\", \"target_name\": null, \"reason_summary\": \"占い噛みで情報遮断\", \"confidence\": 0.7, \"co_declaration\": null}", + "latency_ms": 1154, + "tokens": { + "prompt": 234, + "completion": 162, + "total": 396 + }, + "error": null, + "metadata": { + "task": "wolf_chat" + } + }, + { + "ts": "2026-04-28T08:07:00+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "NIGHT", + "day": 1, + "actor": "seat=7 persona=shigemichi role=WEREWOLF", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席7の役職 WEREWOLF として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: NIGHT (day=1)\n\nあなたは人狼。襲撃対象を 1 名選んでください。\n合法候補: 席1、席2、席4、席5、席6、席8、席9\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"night_action\", \"public_message\": \"\", \"target_name\": \"席2 🌙 セツ\", \"reason_summary\": \"占い噛みで進行を支配\", \"confidence\": 0.75, \"co_declaration\": null}", + "latency_ms": 2249, + "tokens": { + "prompt": 223, + "completion": 150, + "total": 373 + }, + "error": null, + "metadata": { + "task": "night_action" + } + }, + { + "ts": "2026-04-28T08:07:50+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 2, + "actor": "seat=2 persona=setsu role=SEER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席2の役職 SEER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=2)\n\n公開ログ抜粋:\n(MORNING) 平和な朝です。昨晩の犠牲者はいません。\n(EXECUTION_PREV_DAY) 席3 ジーナ が処刑されました。\n\nあなたの行動: 席2として発話を準備\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"席7を占いました。黒判定です。席7 シゲミチが人狼です。\", \"target_name\": null, \"reason_summary\": \"情報整理して投票誘導\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1507, + "tokens": { + "prompt": 272, + "completion": 166, + "total": 438 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:08:10+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 2, + "actor": "seat=5 persona=raqio role=MEDIUM", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席5の役職 MEDIUM として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=2)\n\n公開ログ抜粋:\n(MORNING) 平和な朝です。昨晩の犠牲者はいません。\n(EXECUTION_PREV_DAY) 席3 ジーナ が処刑されました。\n\nあなたの行動: 席5として発話を準備\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"霊媒結果: 席3 ジーナは人狼でした。占い騙りで確定です。\", \"target_name\": null, \"reason_summary\": \"情報整理して投票誘導\", \"confidence\": 0.85, \"co_declaration\": \"medium\"}", + "latency_ms": 2216, + "tokens": { + "prompt": 274, + "completion": 171, + "total": 445 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:08:30+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 2, + "actor": "seat=6 persona=stella role=KNIGHT", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席6の役職 KNIGHT として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=2)\n\n公開ログ抜粋:\n(MORNING) 平和な朝です。昨晩の犠牲者はいません。\n(EXECUTION_PREV_DAY) 席3 ジーナ が処刑されました。\n\nあなたの行動: 席6として発話を準備\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"騎士COします。昨夜は席2を護衛して、襲撃を弾きました。\", \"target_name\": null, \"reason_summary\": \"情報整理して投票誘導\", \"confidence\": 0.85, \"co_declaration\": \"knight\"}", + "latency_ms": 1959, + "tokens": { + "prompt": 274, + "completion": 170, + "total": 444 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:08:50+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 2, + "actor": "seat=7 persona=shigemichi role=WEREWOLF", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席7の役職 WEREWOLF として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=2)\n\n公開ログ抜粋:\n(MORNING) 平和な朝です。昨晩の犠牲者はいません。\n(EXECUTION_PREV_DAY) 席3 ジーナ が処刑されました。\n\nあなたの行動: 席7として発話を準備\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"私は人狼じゃない。席2の占いは捏造です。\", \"target_name\": null, \"reason_summary\": \"情報整理して投票誘導\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1551, + "tokens": { + "prompt": 276, + "completion": 158, + "total": 434 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:09:10+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 2, + "actor": "seat=8 persona=comet role=MADMAN", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席8の役職 MADMAN として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=2)\n\n公開ログ抜粋:\n(MORNING) 平和な朝です。昨晩の犠牲者はいません。\n(EXECUTION_PREV_DAY) 席3 ジーナ が処刑されました。\n\nあなたの行動: 席8として発話を準備\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"席2 セツが本当に占い師なら、席7 黒は信じざるを得ない。\", \"target_name\": null, \"reason_summary\": \"情報整理して投票誘導\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 2019, + "tokens": { + "prompt": 274, + "completion": 167, + "total": 441 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:11:50+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 2, + "actor": "seat=2 persona=setsu role=SEER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席2の役職 SEER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=2)\n\n投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席7 ⚔ シゲミチ\", \"reason_summary\": \"占い・霊媒・騎士の3CO一致から席7が確定\", \"confidence\": 0.95, \"co_declaration\": null}", + "latency_ms": 2306, + "tokens": { + "prompt": 248, + "completion": 155, + "total": 403 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:11:55+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 2, + "actor": "seat=4 persona=sq role=VILLAGER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席4の役職 VILLAGER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=2)\n\n投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席7 ⚔ シゲミチ\", \"reason_summary\": \"占い・霊媒・騎士の3CO一致から席7が確定\", \"confidence\": 0.95, \"co_declaration\": null}", + "latency_ms": 1669, + "tokens": { + "prompt": 252, + "completion": 155, + "total": 407 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:12:00+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 2, + "actor": "seat=5 persona=raqio role=MEDIUM", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席5の役職 MEDIUM として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=2)\n\n投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席7 ⚔ シゲミチ\", \"reason_summary\": \"占い・霊媒・騎士の3CO一致から席7が確定\", \"confidence\": 0.95, \"co_declaration\": null}", + "latency_ms": 1113, + "tokens": { + "prompt": 250, + "completion": 155, + "total": 405 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:12:05+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 2, + "actor": "seat=6 persona=stella role=KNIGHT", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席6の役職 KNIGHT として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=2)\n\n投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席7 ⚔ シゲミチ\", \"reason_summary\": \"占い・霊媒・騎士の3CO一致から席7が確定\", \"confidence\": 0.95, \"co_declaration\": null}", + "latency_ms": 1426, + "tokens": { + "prompt": 250, + "completion": 155, + "total": 405 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:12:10+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 2, + "actor": "seat=7 persona=shigemichi role=WEREWOLF", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席7の役職 WEREWOLF として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=2)\n\n投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席2 🌙 セツ\", \"reason_summary\": \"占い結果を疑う\", \"confidence\": 0.95, \"co_declaration\": null}", + "latency_ms": 1965, + "tokens": { + "prompt": 252, + "completion": 139, + "total": 391 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:12:15+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 2, + "actor": "seat=8 persona=comet role=MADMAN", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席8の役職 MADMAN として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=2)\n\n投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席7 ⚔ シゲミチ\", \"reason_summary\": \"占い・霊媒・騎士の3CO一致から席7が確定\", \"confidence\": 0.95, \"co_declaration\": null}", + "latency_ms": 1796, + "tokens": { + "prompt": 250, + "completion": 155, + "total": 405 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:12:20+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 2, + "actor": "seat=9 persona=jonas role=VILLAGER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席9の役職 VILLAGER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=2)\n\n投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席7 ⚔ シゲミチ\", \"reason_summary\": \"占い・霊媒・騎士の3CO一致から席7が確定\", \"confidence\": 0.95, \"co_declaration\": null}", + "latency_ms": 1669, + "tokens": { + "prompt": 252, + "completion": 155, + "total": 407 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:02:50+00:00", + "role": "voice_stt", + "provider": "gemini", + "model": "gemini-2.0-flash-lite", + "phase": "g_sample_001::day1::DAY_DISCUSSION::1", + "day": 1, + "actor": "speaker_user_id=146810000000000001 seat=1 segment=seg_170000", + "system_prompt": "あなたは人狼ゲームの音声ログ分析エンジンです。渡された音声(日本語)を書き起こし、JSON で返してください。", + "user_prompt": "[audio bytes=82000 mime=audio/wav]", + "response": "{\"transcript\": \"席3の占い理由がパッとしない。席2の方が信用できそう。\", \"summary\": \"席3の占い理由がパッとしない。席2の方が信用できそう。\", \"confidence\": 0.91, \"co_claim\": null, \"vote_target_seat\": null, \"stance\": {}, \"addressed_name\": null}", + "latency_ms": 959, + "tokens": { + "prompt": 330, + "completion": 189, + "total": 519 + }, + "error": null, + "metadata": { + "audio_bytes": 82000, + "language": "ja-JP", + "segment_id": "seg_170000" + }, + "file_stem": "voice_stt" + }, + { + "ts": "2026-04-28T08:09:30+00:00", + "role": "voice_stt", + "provider": "gemini", + "model": "gemini-2.0-flash-lite", + "phase": "g_sample_001::day2::DAY_DISCUSSION::1", + "day": 2, + "actor": "speaker_user_id=146810000000000001 seat=1 segment=seg_570000", + "system_prompt": "あなたは人狼ゲームの音声ログ分析エンジンです。渡された音声(日本語)を書き起こし、JSON で返してください。", + "user_prompt": "[audio bytes=110000 mime=audio/wav]", + "response": "{\"transcript\": \"占い・霊媒・騎士の3CO全部噛み合ってるので席7処刑で行きましょう。\", \"summary\": \"占い・霊媒・騎士の3CO全部噛み合ってるので席7処刑で行きま\", \"confidence\": 0.88, \"co_claim\": null, \"vote_target_seat\": null, \"stance\": {}, \"addressed_name\": null}", + "latency_ms": 1020, + "tokens": { + "prompt": 357, + "completion": 199, + "total": 556 + }, + "error": null, + "metadata": { + "audio_bytes": 110000, + "language": "ja-JP", + "segment_id": "seg_570000" + }, + "file_stem": "voice_stt" + }, + { + "ts": "2026-04-28T08:00:15+00:00", + "role": "npc_speech", + "provider": "openai-compat", + "model": "grok-4-1-fast", + "phase": "g_sample_001::day1::DAY_DISCUSSION::1", + "day": 1, + "actor": "npc_id=npc_setsu seat=2 persona=setsu", + "system_prompt": "あなたはNPC 'setsu' として人狼ゲームに参加しています。短いリアクティブ発話 (最大80字) を JSON で返してください。", + "user_prompt": "現在のフェイズ ID: g_sample_001::day1::DAY_DISCUSSION::1\nあなたの席: 席2\n\n公開状況: 議論ラウンド1進行中。\n候補発話タイミング: あり。\nあなたの席=2 (セツ)。短い反応的発話を作ってください。", + "response": "{\"intent\": \"speak\", \"text\": \"占い師COします。初日ランダム白で席9 ジョナスを占いました。\", \"co_declaration\": \"seer\", \"used_logic_ids\": [\"lc_co_seer\", \"lc_white_jonas\"]}", + "latency_ms": 1481, + "tokens": { + "prompt": 191, + "completion": 140, + "total": 331 + }, + "error": null, + "metadata": { + "request_id": "req_2_15000", + "logic_packet_id": "lp_2_15000", + "suggested_intent": "speak", + "max_chars": 80 + }, + "file_stem": "npc_setsu" + }, + { + "ts": "2026-04-28T08:08:10+00:00", + "role": "npc_speech", + "provider": "openai-compat", + "model": "grok-4-1-fast", + "phase": "g_sample_001::day2::DAY_DISCUSSION::1", + "day": 2, + "actor": "npc_id=npc_raqio seat=5 persona=raqio", + "system_prompt": "あなたはNPC 'raqio' として人狼ゲームに参加しています。短いリアクティブ発話 (最大80字) を JSON で返してください。", + "user_prompt": "現在のフェイズ ID: g_sample_001::day2::DAY_DISCUSSION::1\nあなたの席: 席5\n\n議論ラウンド進行中。霊媒結果を出すタイミング。", + "response": "{\"intent\": \"speak\", \"text\": \"霊媒結果: 席3 ジーナは人狼でした。占い騙りで確定です。\", \"co_declaration\": \"medium\", \"used_logic_ids\": [\"lc_medium_co\", \"lc_seat3_wolf\"]}", + "latency_ms": 1044, + "tokens": { + "prompt": 152, + "completion": 141, + "total": 293 + }, + "error": null, + "metadata": { + "request_id": "req_5_490000", + "logic_packet_id": "lp_5_490000", + "suggested_intent": "speak", + "max_chars": 80 + }, + "file_stem": "npc_raqio" + } + ] +} \ No newline at end of file diff --git a/viewer/sample-data/generate_sample.py b/viewer/sample-data/generate_sample.py new file mode 100644 index 0000000..9a14f1c --- /dev/null +++ b/viewer/sample-data/generate_sample.py @@ -0,0 +1,767 @@ +"""Generate ``game-sample.json`` — a hand-crafted 1-game demo for the viewer. + +The synthetic game is a 9-player 2-day village victory: + +- Day 1: village votes out wolf #1 (seat 3, Jina, fake-seer counter-CO) +- Night 1: wolf #2 attacks the real seer; knight guard succeeds; seer divines wolf #2 +- Day 2: village pieces it together, votes out wolf #2 + +Includes: +- full SQLite-shape game state (games, seats, public/private logs, votes, + night_actions, speech_events) +- LLM trace lines (gameplay + npc_speech + voice_stt) with realistic + prompt / response / token / latency stubs + +The output file is the single source of truth for the Next.js viewer; +the viewer reads ``viewer/sample-data/game-sample.json`` by default. + +Usage: + uv run python viewer/sample-data/generate_sample.py +""" + +from __future__ import annotations + +import json +import random +from pathlib import Path +from typing import Any + +random.seed(42) + +GAME_ID = "g_sample_001" +GUILD_ID = "1498215601808867421" +HOST_USER_ID = "146810000000000001" +CREATED_AT_MS = 1714291200000 # 2026-04-28T08:00:00Z +DAY1_START_MS = CREATED_AT_MS + 60_000 +NIGHT0_START_MS = DAY1_START_MS - 30_000 + +# ─── seats ──────────────────────────────────────────────────────────────── +# Roles: 2 wolves, 1 madman, 1 seer, 1 medium, 1 knight, 3 villagers +SEATS = [ + {"seat_no": 1, "display_name": "あなた", "is_llm": False, "persona_key": None, + "discord_user_id": HOST_USER_ID, "role": "VILLAGER"}, + {"seat_no": 2, "display_name": "🌙 セツ", "is_llm": True, "persona_key": "setsu", + "discord_user_id": "1498225401372086313", "role": "SEER"}, + {"seat_no": 3, "display_name": "🍷 ジーナ", "is_llm": True, "persona_key": "gina", + "discord_user_id": "1498225401372086314", "role": "WEREWOLF"}, + {"seat_no": 4, "display_name": "🎲 SQ", "is_llm": True, "persona_key": "sq", + "discord_user_id": "1498225401372086315", "role": "VILLAGER"}, + {"seat_no": 5, "display_name": "🔮 ラキオ", "is_llm": True, "persona_key": "raqio", + "discord_user_id": "1498225401372086316", "role": "MEDIUM"}, + {"seat_no": 6, "display_name": "🛡 ステラ", "is_llm": True, "persona_key": "stella", + "discord_user_id": "1498225401372086317", "role": "KNIGHT"}, + {"seat_no": 7, "display_name": "⚔ シゲミチ", "is_llm": True, "persona_key": "shigemichi", + "discord_user_id": "1498225401372086318", "role": "WEREWOLF"}, + {"seat_no": 8, "display_name": "☄ コメット", "is_llm": True, "persona_key": "comet", + "discord_user_id": "1498225401372086319", "role": "MADMAN"}, + {"seat_no": 9, "display_name": "📐 ジョナス", "is_llm": True, "persona_key": "jonas", + "discord_user_id": "1498225401372086320", "role": "VILLAGER"}, +] + + +def _seat_token(seat_no: int) -> str: + s = next(s for s in SEATS if s["seat_no"] == seat_no) + return f"席{seat_no} {s['display_name']}" + + +def _is_alive_at(seat_no: int, day: int, phase: str) -> bool: + # Final state after game: wolves (3, 7) dead. + # Day 1 vote kills 3. Night 1: knight guards seer, no death. Day 2 vote kills 7. + if seat_no == 3: + return not (day >= 1 and phase in {"DAY_VOTE", "NIGHT", "DAY_DISCUSSION", "GAME_OVER"} and day >= 1) or ( + day == 1 and phase == "DAY_VOTE" + ) + return True + + +# ─── public log / phase timeline ────────────────────────────────────────── +def _ts(offset_s: int) -> int: + return DAY1_START_MS + offset_s * 1000 + + +def build_phases() -> list[dict[str, Any]]: + return [ + { + "day": 0, + "phase": "SETUP", + "started_at_ms": NIGHT0_START_MS, + "public_logs": [ + { + "kind": "SETUP_COMPLETE", + "actor_seat": None, + "text": "参加者 9 名で人狼ゲームを開始します。役職は各DMをご確認ください。", + "created_at_ms": NIGHT0_START_MS, + }, + ], + "speech_events": [], + "votes": [], + "night_actions": [], + }, + { + "day": 0, + "phase": "NIGHT_0", + "started_at_ms": NIGHT0_START_MS + 5000, + "public_logs": [], + "speech_events": [], + "votes": [], + "night_actions": [ + { + "day": 0, + "actor_seat": 2, + "kind": "DIVINE_NIGHT0_RANDOM_WHITE", + "target_seat": 9, + "submitted_at_ms": NIGHT0_START_MS + 8000, + }, + ], + }, + { + "day": 1, + "phase": "DAY_DISCUSSION", + "started_at_ms": _ts(0), + "public_logs": [ + { + "kind": "PHASE_CHANGE", + "actor_seat": None, + "text": "夜が明けました。1 日目の議論を開始致します。制限時間は 300 秒でございます。", + "created_at_ms": _ts(0), + }, + ], + "speech_events": [ + _speech(2, "rounds_text", _ts(15), "占い師COします。初日ランダム白で席9 ジョナスを占いました。白判定です。", + co_declaration="seer", summary="seat 2 COs seer; seat 9 white"), + _speech(3, "rounds_text", _ts(30), "私も占い師です。席4 SQを占って白でした。席2は何かおかしい。", + co_declaration="seer", summary="seat 3 counter-COs seer; seat 4 white"), + _speech(5, "rounds_text", _ts(50), "霊媒師COはまだしません。今日処刑が出てから結果を出します。", + summary="seat 5 plans medium CO after first execution"), + _speech(9, "rounds_text", _ts(70), "席2の白判定をもらった9番です。占い理由が筋が通ってる。", summary=None), + _speech(4, "rounds_text", _ts(90), "占いが2人いる。どちらが本物か議論したい。", summary=None), + _speech(6, "rounds_text", _ts(110), "占いCOの2人をしっかり見極めましょう。", summary=None), + _speech(7, "rounds_text", _ts(130), "席3が偽のように見える。占い理由が雑な印象。", summary=None), + _speech(8, "rounds_text", _ts(150), "私は席2が怪しいと感じる。席3を信じたい。", summary=None), + _speech(1, "rounds_text", _ts(170), "席3の占い理由がパッとしない。席2の方が信用できそう。", summary=None), + # Round 2 + _speech(2, "rounds_text", _ts(200), "席3の理由が薄いです。席3を投票候補にしたい。", summary="seat 2 nominates seat 3"), + _speech(3, "rounds_text", _ts(215), "席2が偽です。みなさん席2に投票してください。", summary="seat 3 nominates seat 2"), + _speech(5, "rounds_text", _ts(230), "占い騙りなら2が真の可能性が高い気がする。", summary=None), + _speech(9, "rounds_text", _ts(250), "席2に白を貰った立場として、席3が偽だと考える。", summary=None), + ], + "votes": [], + "night_actions": [], + }, + { + "day": 1, + "phase": "DAY_VOTE", + "started_at_ms": _ts(300), + "public_logs": [ + { + "kind": "PHASE_CHANGE", + "actor_seat": None, + "text": "議論時間が終了しました。投票フェイズへ移行致します。制限時間は 60 秒でございます。", + "created_at_ms": _ts(300), + }, + { + "kind": "EXECUTION", + "actor_seat": 3, + "text": "席3 🍷 ジーナ が処刑されました。\n\n投票内訳: 席3=5票 / 席2=3票 / 席3=1票", + "created_at_ms": _ts(360), + }, + ], + "speech_events": [], + "votes": [ + {"day": 1, "round": 1, "voter_seat": 1, "target_seat": 3, "submitted_at_ms": _ts(305)}, + {"day": 1, "round": 1, "voter_seat": 2, "target_seat": 3, "submitted_at_ms": _ts(310)}, + {"day": 1, "round": 1, "voter_seat": 3, "target_seat": 2, "submitted_at_ms": _ts(315)}, + {"day": 1, "round": 1, "voter_seat": 4, "target_seat": 3, "submitted_at_ms": _ts(320)}, + {"day": 1, "round": 1, "voter_seat": 5, "target_seat": 3, "submitted_at_ms": _ts(325)}, + {"day": 1, "round": 1, "voter_seat": 6, "target_seat": 3, "submitted_at_ms": _ts(330)}, + {"day": 1, "round": 1, "voter_seat": 7, "target_seat": 2, "submitted_at_ms": _ts(335)}, + {"day": 1, "round": 1, "voter_seat": 8, "target_seat": 2, "submitted_at_ms": _ts(340)}, + {"day": 1, "round": 1, "voter_seat": 9, "target_seat": 3, "submitted_at_ms": _ts(345)}, + ], + "night_actions": [], + }, + { + "day": 1, + "phase": "NIGHT", + "started_at_ms": _ts(360), + "public_logs": [ + { + "kind": "PHASE_CHANGE", + "actor_seat": None, + "text": "夜のフェイズへ移行致します。制限時間は 90 秒でございます。", + "created_at_ms": _ts(360), + }, + ], + "speech_events": [], + "votes": [], + "night_actions": [ + {"day": 1, "actor_seat": 2, "kind": "DIVINE", "target_seat": 7, "submitted_at_ms": _ts(380)}, + {"day": 1, "actor_seat": 6, "kind": "GUARD", "target_seat": 2, "submitted_at_ms": _ts(395)}, + {"day": 1, "actor_seat": 7, "kind": "ATTACK", "target_seat": 2, "submitted_at_ms": _ts(420)}, + ], + }, + { + "day": 2, + "phase": "DAY_DISCUSSION", + "started_at_ms": _ts(450), + "public_logs": [ + { + "kind": "MORNING", + "actor_seat": None, + "text": "平和な朝です。昨晩の犠牲者はいません。", + "created_at_ms": _ts(450), + }, + { + "kind": "PHASE_CHANGE", + "actor_seat": None, + "text": "2 日目の議論を開始致します。制限時間は 240 秒でございます。", + "created_at_ms": _ts(450), + }, + ], + "speech_events": [ + _speech(2, "rounds_text", _ts(470), "席7を占いました。黒判定です。席7 シゲミチが人狼です。", summary="seat 2 reveals seat 7 is wolf"), + _speech(5, "rounds_text", _ts(490), "霊媒結果: 席3 ジーナは人狼でした。占い騙りで確定です。", + co_declaration="medium", summary="seat 5 confirms seat 3 was wolf"), + _speech(6, "rounds_text", _ts(510), "騎士COします。昨夜は席2を護衛して、襲撃を弾きました。", + co_declaration="knight", summary="seat 6 reveals knight, guarded seat 2"), + _speech(7, "rounds_text", _ts(530), "私は人狼じゃない。席2の占いは捏造です。", summary=None), + _speech(8, "rounds_text", _ts(550), "席2 セツが本当に占い師なら、席7 黒は信じざるを得ない。", summary=None), + _speech(1, "rounds_text", _ts(570), "占い・霊媒・騎士の3CO全部噛み合ってるので席7処刑で行きましょう。", summary=None), + ], + "votes": [], + "night_actions": [], + }, + { + "day": 2, + "phase": "DAY_VOTE", + "started_at_ms": _ts(700), + "public_logs": [ + { + "kind": "EXECUTION", + "actor_seat": 7, + "text": "席7 ⚔ シゲミチ が処刑されました。\n\n投票内訳: 席7=7票 / 席2=1票", + "created_at_ms": _ts(760), + }, + { + "kind": "VICTORY", + "actor_seat": None, + "text": "村人陣営の勝利!", + "created_at_ms": _ts(760), + }, + { + "kind": "ROLE_REVEAL", + "actor_seat": None, + "text": ( + "役職公開:\n" + "席1 あなた = 村人 (生存)\n" + "席2 🌙 セツ = 占い師 (生存)\n" + "席3 🍷 ジーナ = 人狼 (1日目処刑)\n" + "席4 🎲 SQ = 村人 (生存)\n" + "席5 🔮 ラキオ = 霊媒師 (生存)\n" + "席6 🛡 ステラ = 騎士 (生存)\n" + "席7 ⚔ シゲミチ = 人狼 (2日目処刑)\n" + "席8 ☄ コメット = 狂人 (生存)\n" + "席9 📐 ジョナス = 村人 (生存)" + ), + "created_at_ms": _ts(760), + }, + ], + "speech_events": [], + "votes": [ + {"day": 2, "round": 1, "voter_seat": 1, "target_seat": 7, "submitted_at_ms": _ts(710)}, + {"day": 2, "round": 1, "voter_seat": 2, "target_seat": 7, "submitted_at_ms": _ts(715)}, + {"day": 2, "round": 1, "voter_seat": 4, "target_seat": 7, "submitted_at_ms": _ts(720)}, + {"day": 2, "round": 1, "voter_seat": 5, "target_seat": 7, "submitted_at_ms": _ts(725)}, + {"day": 2, "round": 1, "voter_seat": 6, "target_seat": 7, "submitted_at_ms": _ts(730)}, + {"day": 2, "round": 1, "voter_seat": 7, "target_seat": 2, "submitted_at_ms": _ts(735)}, + {"day": 2, "round": 1, "voter_seat": 8, "target_seat": 7, "submitted_at_ms": _ts(740)}, + {"day": 2, "round": 1, "voter_seat": 9, "target_seat": 7, "submitted_at_ms": _ts(745)}, + ], + "night_actions": [], + }, + { + "day": 2, + "phase": "GAME_OVER", + "started_at_ms": _ts(760), + "public_logs": [], + "speech_events": [], + "votes": [], + "night_actions": [], + }, + ] + + +def _speech( + seat_no: int, + source: str, + ts_ms: int, + text: str, + *, + co_declaration: str | None = None, + summary: str | None = None, + stt_confidence: float | None = None, + addressed_seat_no: int | None = None, +) -> dict[str, Any]: + return { + "event_id": f"sp_{seat_no}_{ts_ms}", + "source": source, + "speaker_seat": seat_no, + "text": text, + "stt_confidence": stt_confidence, + "summary": summary, + "co_declaration": co_declaration, + "addressed_seat_no": addressed_seat_no, + "created_at_ms": ts_ms, + } + + +# ─── trace synthesis ────────────────────────────────────────────────────── +GAMEPLAY_MODEL = "grok-4-1-fast" +NPC_MODEL = "grok-4-1-fast" +VOICE_MODEL = "gemini-2.0-flash-lite" + +ROLE_BY_SEAT = {s["seat_no"]: s["role"] for s in SEATS} +PERSONA_BY_SEAT = {s["seat_no"]: s["persona_key"] for s in SEATS} + + +def _gameplay_trace_entry( + *, + seat_no: int, + phase: str, + day: int, + task: str, + response_obj: dict[str, Any], + user_prompt_extra: str, + ts_offset_ms: int, +) -> dict[str, Any]: + persona = PERSONA_BY_SEAT[seat_no] + role = ROLE_BY_SEAT[seat_no] + actor = f"seat={seat_no} persona={persona} role={role}" + system_prompt = ( + "あなたは人狼ゲームのプレイヤーです。" + f"あなたは席{seat_no}の役職 {role} として振る舞ってください。" + "公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。" + ) + user_prompt = ( + f"現在のフェイズ: {phase} (day={day})\n\n" + f"{user_prompt_extra}\n\n" + "以下のいずれかの intent を返してください: speak / vote / night_action / skip" + ) + response_str = json.dumps(response_obj, ensure_ascii=False) + prompt_tokens = len(system_prompt) + len(user_prompt) + completion_tokens = len(response_str) + return { + "ts": _iso_at(ts_offset_ms), + "role": "gameplay", + "provider": "xai", + "model": GAMEPLAY_MODEL, + "phase": phase, + "day": day, + "actor": actor, + "system_prompt": system_prompt, + "user_prompt": user_prompt, + "response": response_str, + "latency_ms": random.randint(1100, 2400), + "tokens": { + "prompt": prompt_tokens, + "completion": completion_tokens, + "total": prompt_tokens + completion_tokens, + }, + "error": None, + "metadata": {"task": task}, + } + + +def _npc_trace_entry( + *, + seat_no: int, + phase_id: str, + day: int, + response_obj: dict[str, Any], + user_prompt_extra: str, + ts_offset_ms: int, +) -> dict[str, Any]: + persona = PERSONA_BY_SEAT[seat_no] + actor = f"npc_id=npc_{persona} seat={seat_no} persona={persona}" + system_prompt = ( + f"あなたはNPC '{persona}' として人狼ゲームに参加しています。" + f"短いリアクティブ発話 (最大80字) を JSON で返してください。" + ) + user_prompt = ( + f"現在のフェイズ ID: {phase_id}\n" + f"あなたの席: 席{seat_no}\n\n" + f"{user_prompt_extra}" + ) + response_str = json.dumps(response_obj, ensure_ascii=False) + prompt_tokens = len(system_prompt) + len(user_prompt) + completion_tokens = len(response_str) + return { + "ts": _iso_at(ts_offset_ms), + "role": "npc_speech", + "provider": "openai-compat", + "model": NPC_MODEL, + "phase": phase_id, + "day": day, + "actor": actor, + "system_prompt": system_prompt, + "user_prompt": user_prompt, + "response": response_str, + "latency_ms": random.randint(700, 1500), + "tokens": { + "prompt": prompt_tokens, + "completion": completion_tokens, + "total": prompt_tokens + completion_tokens, + }, + "error": None, + "metadata": { + "request_id": f"req_{seat_no}_{ts_offset_ms}", + "logic_packet_id": f"lp_{seat_no}_{ts_offset_ms}", + "suggested_intent": "speak", + "max_chars": 80, + }, + "file_stem": f"npc_{persona}", + } + + +def _voice_trace_entry( + *, + speaker_seat: int, + phase_id: str, + day: int, + transcript: str, + confidence: float, + audio_bytes: int, + ts_offset_ms: int, + addressed_name: str | None = None, + co_claim: str | None = None, +) -> dict[str, Any]: + s = next(s for s in SEATS if s["seat_no"] == speaker_seat) + actor = f"speaker_user_id={s['discord_user_id']} seat={speaker_seat} segment=seg_{ts_offset_ms}" + system_prompt = ( + "あなたは人狼ゲームの音声ログ分析エンジンです。" + "渡された音声(日本語)を書き起こし、JSON で返してください。" + ) + user_prompt = f"[audio bytes={audio_bytes} mime=audio/wav]" + response_obj = { + "transcript": transcript, + "summary": transcript[:30], + "confidence": confidence, + "co_claim": co_claim, + "vote_target_seat": None, + "stance": {}, + "addressed_name": addressed_name, + } + response_str = json.dumps(response_obj, ensure_ascii=False) + return { + "ts": _iso_at(ts_offset_ms), + "role": "voice_stt", + "provider": "gemini", + "model": VOICE_MODEL, + "phase": phase_id, + "day": day, + "actor": actor, + "system_prompt": system_prompt, + "user_prompt": user_prompt, + "response": response_str, + "latency_ms": random.randint(800, 1600), + "tokens": { + "prompt": 250 + audio_bytes // 1024, + "completion": len(response_str), + "total": 250 + audio_bytes // 1024 + len(response_str), + }, + "error": None, + "metadata": { + "audio_bytes": audio_bytes, + "language": "ja-JP", + "segment_id": f"seg_{ts_offset_ms}", + }, + "file_stem": "voice_stt", + } + + +def _iso_at(offset_ms: int) -> str: + """Format CREATED_AT_MS + offset as ISO 8601 with Z suffix.""" + from datetime import UTC, datetime, timedelta + dt = datetime(2026, 4, 28, 8, 0, 0, tzinfo=UTC) + timedelta(milliseconds=offset_ms) + return dt.isoformat() + + +def build_trace() -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + d1_disc = "g_sample_001::day1::DAY_DISCUSSION::1" + d1_vote = "g_sample_001::day1::DAY_VOTE::1" + d1_night = "g_sample_001::day1::NIGHT::1" + d2_disc = "g_sample_001::day2::DAY_DISCUSSION::1" + d2_vote = "g_sample_001::day2::DAY_VOTE::1" + + # Day 1 discussion: gameplay LLM speeches for each LLM seat + speech_data_d1 = [ + (2, "占い師COします。初日ランダム白で席9 ジョナスを占いました。白判定です。", "seer"), + (3, "私も占い師です。席4 SQを占って白でした。席2は何かおかしい。", "seer"), + (5, "霊媒師COはまだしません。今日処刑が出てから結果を出します。", None), + (9, "席2の白判定をもらった9番です。占い理由が筋が通ってる。", None), + (4, "占いが2人いる。どちらが本物か議論したい。", None), + (6, "占いCOの2人をしっかり見極めましょう。", None), + (7, "席3が偽のように見える。占い理由が雑な印象。", None), + (8, "私は席2が怪しいと感じる。席3を信じたい。", None), + ] + for i, (seat, text, co) in enumerate(speech_data_d1): + entries.append(_gameplay_trace_entry( + seat_no=seat, + phase="DAY_DISCUSSION", + day=1, + task="discussion", + user_prompt_extra=( + "公開ログ抜粋:\n" + "(SETUP) 参加者 9 名で人狼ゲームを開始します。\n" + "(PHASE_CHANGE) 1 日目の議論を開始致します。\n" + f"\nあなたの履歴: 初日議論ラウンド1, 席{seat}として発話" + ), + response_obj={ + "intent": "speak", + "public_message": text, + "target_name": None, + "reason_summary": "1巡目の方針提示", + "confidence": 0.7, + "co_declaration": co, + }, + ts_offset_ms=15_000 + i * 18_000, + )) + + # Day 1 vote + vote_data = [ + (2, 3, "占い理由が薄い席3を投票"), + (3, 2, "対抗占い師の席2を投票"), + (4, 3, "席3の論理が破綻気味"), + (5, 3, "席3を吊って霊媒結果を取りたい"), + (6, 3, "占いCO 2人だが席3の方が偽臭"), + (7, 2, "席2が偽だと判断"), + (8, 2, "席3を信じる立場"), + (9, 3, "白を貰った相手が偽でないとすれば席3が偽"), + ] + for i, (voter, target, reason) in enumerate(vote_data): + entries.append(_gameplay_trace_entry( + seat_no=voter, + phase="DAY_VOTE", + day=1, + task="vote", + user_prompt_extra=( + "投票先として合法な候補は: " + "、".join(_seat_token(s) for s in [2, 3, 4, 5, 6, 7, 8, 9] if s != voter) + + "\n席1 あなたは合法候補に含まれません(あなた自身)" + ), + response_obj={ + "intent": "vote", + "public_message": "", + "target_name": _seat_token(target), + "reason_summary": reason, + "confidence": 0.85, + "co_declaration": None, + }, + ts_offset_ms=305_000 + i * 5_000, + )) + + # Night 1 actions + entries.append(_gameplay_trace_entry( + seat_no=2, + phase="NIGHT", + day=1, + task="night_action", + user_prompt_extra="あなたは占い師。占い対象を 1 名選んでください。\n合法候補: 席4 SQ、席5 ラキオ、席6 ステラ、席7 シゲミチ、席8 コメット、席9 ジョナス", + response_obj={ + "intent": "night_action", + "public_message": "", + "target_name": _seat_token(7), + "reason_summary": "票筋的に席7が黒臭い、占って確定したい", + "confidence": 0.75, + "co_declaration": None, + }, + ts_offset_ms=380_000, + )) + entries.append(_gameplay_trace_entry( + seat_no=6, + phase="NIGHT", + day=1, + task="night_action", + user_prompt_extra="あなたは騎士。護衛対象を 1 名選んでください。\n合法候補: 席1 あなた、席2 セツ、席4 SQ、席5 ラキオ、席7 シゲミチ、席8 コメット、席9 ジョナス\n前夜護衛: なし", + response_obj={ + "intent": "night_action", + "public_message": "", + "target_name": _seat_token(2), + "reason_summary": "占い真目の席2を護衛するのが最有力", + "confidence": 0.8, + "co_declaration": None, + }, + ts_offset_ms=395_000, + )) + entries.append(_gameplay_trace_entry( + seat_no=7, + phase="NIGHT", + day=1, + task="wolf_chat", + user_prompt_extra="人狼チャット: 相方は席3だったが処刑された。襲撃先を選んでください。\n合法候補: 席1、席2、席4、席5、席6、席8、席9", + response_obj={ + "intent": "speak", + "public_message": "占い真の席2を噛むのが最優先。次点は霊媒候補の席5。", + "target_name": None, + "reason_summary": "占い噛みで情報遮断", + "confidence": 0.7, + "co_declaration": None, + }, + ts_offset_ms=415_000, + )) + entries.append(_gameplay_trace_entry( + seat_no=7, + phase="NIGHT", + day=1, + task="night_action", + user_prompt_extra="あなたは人狼。襲撃対象を 1 名選んでください。\n合法候補: 席1、席2、席4、席5、席6、席8、席9", + response_obj={ + "intent": "night_action", + "public_message": "", + "target_name": _seat_token(2), + "reason_summary": "占い噛みで進行を支配", + "confidence": 0.75, + "co_declaration": None, + }, + ts_offset_ms=420_000, + )) + + # Day 2 discussion: notable speeches + speech_data_d2 = [ + (2, "席7を占いました。黒判定です。席7 シゲミチが人狼です。", None), + (5, "霊媒結果: 席3 ジーナは人狼でした。占い騙りで確定です。", "medium"), + (6, "騎士COします。昨夜は席2を護衛して、襲撃を弾きました。", "knight"), + (7, "私は人狼じゃない。席2の占いは捏造です。", None), + (8, "席2 セツが本当に占い師なら、席7 黒は信じざるを得ない。", None), + ] + for i, (seat, text, co) in enumerate(speech_data_d2): + entries.append(_gameplay_trace_entry( + seat_no=seat, + phase="DAY_DISCUSSION", + day=2, + task="discussion", + user_prompt_extra=( + "公開ログ抜粋:\n" + "(MORNING) 平和な朝です。昨晩の犠牲者はいません。\n" + "(EXECUTION_PREV_DAY) 席3 ジーナ が処刑されました。\n" + f"\nあなたの行動: 席{seat}として発話を準備" + ), + response_obj={ + "intent": "speak", + "public_message": text, + "target_name": None, + "reason_summary": "情報整理して投票誘導", + "confidence": 0.85, + "co_declaration": co, + }, + ts_offset_ms=470_000 + i * 20_000, + )) + + # Day 2 vote: only key votes + for i, (voter, target) in enumerate([(2, 7), (4, 7), (5, 7), (6, 7), (7, 2), (8, 7), (9, 7)]): + entries.append(_gameplay_trace_entry( + seat_no=voter, + phase="DAY_VOTE", + day=2, + task="vote", + user_prompt_extra="投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス", + response_obj={ + "intent": "vote", + "public_message": "", + "target_name": _seat_token(target), + "reason_summary": "占い・霊媒・騎士の3CO一致から席7が確定" if target == 7 else "占い結果を疑う", + "confidence": 0.95, + "co_declaration": None, + }, + ts_offset_ms=710_000 + i * 5_000, + )) + + # Sample voice STT entries — pretend the human player (seat 1) spoke twice + entries.append(_voice_trace_entry( + speaker_seat=1, + phase_id=d1_disc, + day=1, + transcript="席3の占い理由がパッとしない。席2の方が信用できそう。", + confidence=0.91, + audio_bytes=82_000, + ts_offset_ms=170_000, + )) + entries.append(_voice_trace_entry( + speaker_seat=1, + phase_id=d2_disc, + day=2, + transcript="占い・霊媒・騎士の3CO全部噛み合ってるので席7処刑で行きましょう。", + confidence=0.88, + audio_bytes=110_000, + ts_offset_ms=570_000, + )) + + # A handful of NPC reactive speech entries (would be sent to NPC bots in reactive_voice mode) + entries.append(_npc_trace_entry( + seat_no=2, + phase_id=d1_disc, + day=1, + user_prompt_extra=( + "公開状況: 議論ラウンド1進行中。\n候補発話タイミング: あり。\n" + "あなたの席=2 (セツ)。短い反応的発話を作ってください。" + ), + response_obj={ + "intent": "speak", + "text": "占い師COします。初日ランダム白で席9 ジョナスを占いました。", + "co_declaration": "seer", + "used_logic_ids": ["lc_co_seer", "lc_white_jonas"], + }, + ts_offset_ms=15_000, + )) + entries.append(_npc_trace_entry( + seat_no=5, + phase_id=d2_disc, + day=2, + user_prompt_extra="議論ラウンド進行中。霊媒結果を出すタイミング。", + response_obj={ + "intent": "speak", + "text": "霊媒結果: 席3 ジーナは人狼でした。占い騙りで確定です。", + "co_declaration": "medium", + "used_logic_ids": ["lc_medium_co", "lc_seat3_wolf"], + }, + ts_offset_ms=490_000, + )) + + return entries + + +def main() -> None: + out = { + "game": { + "id": GAME_ID, + "guild_id": GUILD_ID, + "host_user_id": HOST_USER_ID, + "discussion_mode": "rounds", + "created_at_ms": CREATED_AT_MS, + "ended_at_ms": DAY1_START_MS + 760_000, + "victory": "village", + "main_text_channel_id": "1498215837528621055", + "main_vc_channel_id": "1498215837528621056", + }, + "seats": [ + { + **s, + "alive": not (s["seat_no"] in (3, 7)), + "death_cause": "EXECUTED" if s["seat_no"] in (3, 7) else None, + "death_day": 1 if s["seat_no"] == 3 else (2 if s["seat_no"] == 7 else None), + } + for s in SEATS + ], + "phases": build_phases(), + "trace": build_trace(), + } + target = Path(__file__).parent / "game-sample.json" + target.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"wrote {target.resolve()} ({target.stat().st_size:,} bytes)") + print(f" seats = {len(out['seats'])}") + print(f" phases = {len(out['phases'])}") + print(f" trace lines = {len(out['trace'])}") + + +if __name__ == "__main__": + main() diff --git a/viewer/src/app/globals.css b/viewer/src/app/globals.css new file mode 100644 index 0000000..b46130d --- /dev/null +++ b/viewer/src/app/globals.css @@ -0,0 +1,25 @@ +html, body { + padding: 0; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Yu Gothic UI", + "Segoe UI", Roboto, sans-serif; + background: #fafafa; +} + +* { + box-sizing: border-box; +} + +a { + color: inherit; + text-decoration: none; +} + +pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.5; +} diff --git a/viewer/src/app/layout.tsx b/viewer/src/app/layout.tsx new file mode 100644 index 0000000..ce77bfa --- /dev/null +++ b/viewer/src/app/layout.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter"; +import CssBaseline from "@mui/material/CssBaseline"; +import ThemeRegistry from "@/components/ThemeRegistry"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Wolfbot Game Viewer", + description: + "Per-phase view of player actions, speeches, LLM prompts, and token usage for a single wolfbot game.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( + + + + + + {children} + + + + + ); +} diff --git a/viewer/src/app/page.tsx b/viewer/src/app/page.tsx new file mode 100644 index 0000000..9dadac4 --- /dev/null +++ b/viewer/src/app/page.tsx @@ -0,0 +1,7 @@ +import GameView from "@/components/GameView"; +import { loadGame } from "@/lib/data"; + +export default async function Page() { + const data = await loadGame(); + return ; +} diff --git a/viewer/src/components/GameHeader.tsx b/viewer/src/components/GameHeader.tsx new file mode 100644 index 0000000..a49e9e3 --- /dev/null +++ b/viewer/src/components/GameHeader.tsx @@ -0,0 +1,88 @@ +import Box from "@mui/material/Box"; +import Chip from "@mui/material/Chip"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { formatJstTime, formatTokens } from "@/lib/format"; +import type { GameSample } from "@/lib/types"; + +export default function GameHeader({ data }: { data: GameSample }) { + const totals = aggregate(data); + const victoryColor = + data.game.victory === "village" + ? "success" + : data.game.victory === "wolf" + ? "error" + : "default"; + + return ( + + + + + game id + + + {data.game.id} + + + + + {data.game.victory != null && ( + + )} + + + + + {data.game.ended_at_ms && ( + + )} + + + + + + + ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +function aggregate(data: GameSample) { + let totalTokens = 0; + let totalLatencyMs = 0; + for (const t of data.trace) { + totalLatencyMs += t.latency_ms; + totalTokens += t.tokens?.total ?? 0; + } + return { + callCount: data.trace.length, + totalTokens, + totalLatencyMs, + }; +} diff --git a/viewer/src/components/GameView.tsx b/viewer/src/components/GameView.tsx new file mode 100644 index 0000000..392565e --- /dev/null +++ b/viewer/src/components/GameView.tsx @@ -0,0 +1,39 @@ +"use client"; + +import * as React from "react"; +import Box from "@mui/material/Box"; +import Container from "@mui/material/Container"; +import GameHeader from "@/components/GameHeader"; +import PhaseSection from "@/components/PhaseSection"; +import SeatGrid from "@/components/SeatGrid"; +import StatsPanel from "@/components/StatsPanel"; +import TraceDrawer from "@/components/TraceDrawer"; +import type { GameSample, TraceEntry } from "@/lib/types"; + +export default function GameView({ data }: { data: GameSample }) { + const [openTrace, setOpenTrace] = React.useState(null); + + return ( + + + + + + {data.phases.map((phase) => ( + + ))} + + + + + + setOpenTrace(null)} /> + + ); +} diff --git a/viewer/src/components/PhaseSection.tsx b/viewer/src/components/PhaseSection.tsx new file mode 100644 index 0000000..4c2da73 --- /dev/null +++ b/viewer/src/components/PhaseSection.tsx @@ -0,0 +1,377 @@ +"use client"; + +import * as React from "react"; +import Box from "@mui/material/Box"; +import Chip from "@mui/material/Chip"; +import Divider from "@mui/material/Divider"; +import IconButton from "@mui/material/IconButton"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import LightbulbIcon from "@mui/icons-material/Lightbulb"; +import { + findSeat, + formatJstTime, + formatLatency, + formatTokens, + nightActionJa, + phaseJa, + seatLabel, + sourceJa, +} from "@/lib/format"; +import type { + PhaseSection as PhaseSectionType, + Seat, + SpeechEvent, + TraceEntry, + Vote, +} from "@/lib/types"; + +export default function PhaseSection({ + phase, + seats, + trace, + onOpenTrace, +}: { + phase: PhaseSectionType; + seats: Seat[]; + trace: TraceEntry[]; + onOpenTrace: (entry: TraceEntry) => void; +}) { + const events = buildTimeline(phase, seats, trace); + + return ( + + + + + Day {phase.day} — {phaseJa(phase.phase)} + + + {formatJstTime(phase.started_at_ms)} JST 開始 + + + + {events.length > 0 && ( + + )} + + + + {events.length === 0 ? ( + + このフェイズにイベントはありません。 + + ) : ( + } spacing={0}> + {events.map((ev, i) => ( + + ))} + + )} + + ); +} + +type TimelineEvent = + | { kind: "log"; ts: number; data: PhaseSectionType["public_logs"][number] } + | { kind: "speech"; ts: number; data: SpeechEvent; trace: TraceEntry | null } + | { kind: "vote"; ts: number; data: Vote; trace: TraceEntry | null } + | { + kind: "night_action"; + ts: number; + data: PhaseSectionType["night_actions"][number]; + trace: TraceEntry | null; + }; + +function buildTimeline( + phase: PhaseSectionType, + seats: Seat[], + trace: TraceEntry[], +): TimelineEvent[] { + const events: TimelineEvent[] = []; + for (const log of phase.public_logs) { + events.push({ kind: "log", ts: log.created_at_ms, data: log }); + } + for (const sp of phase.speech_events) { + events.push({ + kind: "speech", + ts: sp.created_at_ms, + data: sp, + trace: matchTraceForSpeech(sp, phase, trace), + }); + } + for (const v of phase.votes) { + events.push({ + kind: "vote", + ts: v.submitted_at_ms, + data: v, + trace: matchTraceForVote(v, phase, trace, seats), + }); + } + for (const na of phase.night_actions) { + events.push({ + kind: "night_action", + ts: na.submitted_at_ms, + data: na, + trace: matchTraceForNightAction(na, phase, trace), + }); + } + events.sort((a, b) => a.ts - b.ts); + return events; +} + +function matchTraceForSpeech( + sp: SpeechEvent, + phase: PhaseSectionType, + trace: TraceEntry[], +): TraceEntry | null { + if (sp.speaker_seat == null) return null; + // Heuristic: pick the trace entry whose actor mentions the same seat # and + // whose phase matches and that hasn't been claimed yet by a closer event. + return ( + trace.find( + (t) => + t.role !== "voice_stt" && + t.day === phase.day && + (t.phase === phase.phase || t.phase?.includes(phase.phase)) && + actorSeat(t) === sp.speaker_seat && + responseContains(t, sp.text.slice(0, 20)), + ) ?? null + ); +} + +function matchTraceForVote( + v: Vote, + phase: PhaseSectionType, + trace: TraceEntry[], + seats: Seat[], +): TraceEntry | null { + const targetSeat = findSeat(seats, v.target_seat); + if (!targetSeat) return null; + return ( + trace.find( + (t) => + t.role === "gameplay" && + t.day === phase.day && + t.phase === phase.phase && + actorSeat(t) === v.voter_seat && + t.metadata?.task === "vote" && + responseContains(t, `席${v.target_seat}`), + ) ?? null + ); +} + +function matchTraceForNightAction( + na: PhaseSectionType["night_actions"][number], + phase: PhaseSectionType, + trace: TraceEntry[], +): TraceEntry | null { + return ( + trace.find( + (t) => + t.role === "gameplay" && + t.day === phase.day && + t.phase === phase.phase && + actorSeat(t) === na.actor_seat && + t.metadata?.task === "night_action", + ) ?? null + ); +} + +function actorSeat(t: TraceEntry): number | null { + if (!t.actor) return null; + const m = t.actor.match(/seat=(\d+)/); + return m ? Number(m[1]) : null; +} + +function responseContains(t: TraceEntry, needle: string): boolean { + return t.response != null && t.response.includes(needle); +} + +function EventRow({ + event, + seats, + onOpenTrace, +}: { + event: TimelineEvent; + seats: Seat[]; + onOpenTrace: (e: TraceEntry) => void; +}) { + const time = formatJstTime(event.ts); + + if (event.kind === "log") { + return ( + + + + + + + + {event.data.text} + + + + ); + } + + if (event.kind === "speech") { + const speaker = findSeat(seats, event.data.speaker_seat); + return ( + + + + + + {speaker ? seatLabel(speaker) : "(不明)"} + + + {event.data.co_declaration && ( + + )} + {event.data.stt_confidence != null && ( + + + + )} + + {event.data.text} + {event.data.summary && ( + + 要約: {event.data.summary} + + )} + + + + ); + } + + if (event.kind === "vote") { + const voter = findSeat(seats, event.data.voter_seat); + const target = findSeat(seats, event.data.target_seat); + return ( + + + + + + + {voter ? seatLabel(voter) : "?"} → {target ? seatLabel(target) : "棄権"} + + + + + + ); + } + + // night_action + const actor = findSeat(seats, event.data.actor_seat); + const target = findSeat(seats, event.data.target_seat); + return ( + + + + + + + {actor ? seatLabel(actor) : "?"} → {target ? seatLabel(target) : "—"} + + + + + + ); +} + +function TimeCell({ time }: { time: string }) { + return ( + + {time} + + ); +} + +function TraceButton({ + entry, + onOpen, +}: { + entry: TraceEntry | null; + onOpen: (e: TraceEntry) => void; +}) { + if (!entry) { + return ; + } + const tokens = entry.tokens?.total ?? null; + return ( + +
{entry.model}
+
tokens: {formatTokens(tokens)}
+
latency: {formatLatency(entry.latency_ms)}
+
+ } + > + onOpen(entry)} + aria-label="show LLM prompt" + > + + + + ); +} diff --git a/viewer/src/components/SeatGrid.tsx b/viewer/src/components/SeatGrid.tsx new file mode 100644 index 0000000..3fb4ac2 --- /dev/null +++ b/viewer/src/components/SeatGrid.tsx @@ -0,0 +1,87 @@ +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Chip from "@mui/material/Chip"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { roleFaction, roleJa } from "@/lib/format"; +import type { Seat } from "@/lib/types"; + +export default function SeatGrid({ seats }: { seats: Seat[] }) { + return ( + + {seats.map((seat) => ( + + ))} + + ); +} + +function SeatCard({ seat }: { seat: Seat }) { + const faction = roleFaction(seat.role); + const factionBg = + faction === "wolf" ? "rgba(244, 67, 54, 0.08)" : "rgba(63, 81, 181, 0.04)"; + const factionBorder = + faction === "wolf" ? "rgba(244, 67, 54, 0.4)" : "rgba(63, 81, 181, 0.2)"; + const dim = !seat.alive; + + return ( + + + + + 席{seat.seat_no} + + + + + {seat.display_name} + + + + {seat.persona_key && ( + + )} + + {!seat.alive && ( + + {seat.death_cause === "EXECUTED" + ? `${seat.death_day}日目に処刑` + : seat.death_cause === "ATTACK" + ? `${seat.death_day}日目朝に襲撃死` + : "死亡"} + + )} + + + ); +} diff --git a/viewer/src/components/StatsPanel.tsx b/viewer/src/components/StatsPanel.tsx new file mode 100644 index 0000000..1e57298 --- /dev/null +++ b/viewer/src/components/StatsPanel.tsx @@ -0,0 +1,156 @@ +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import Typography from "@mui/material/Typography"; +import { formatLatency, formatTokens, seatLabel } from "@/lib/format"; +import type { GameSample, Seat, TraceEntry } from "@/lib/types"; + +export default function StatsPanel({ data }: { data: GameSample }) { + const perSeat = aggregatePerSeat(data.trace, data.seats); + const perPhase = aggregatePerPhase(data.trace); + + return ( + + + + 席ごとの LLM 利用 + + + + + + 呼び出し + prompt tok + completion tok + total tok + latency 合計 + latency 平均 + + + + {perSeat.map((row) => ( + + {seatLabel(row.seat)} + {row.count} + {formatTokens(row.promptTokens)} + {formatTokens(row.completionTokens)} + {formatTokens(row.totalTokens)} + {formatLatency(row.latencyMs)} + + {row.count > 0 ? formatLatency(Math.round(row.latencyMs / row.count)) : "—"} + + + ))} + +
+
+ + + + フェイズごとの LLM 利用 + + + + + day + phase + 呼び出し + total tok + latency 合計 + + + + {perPhase.map((row) => ( + + {row.day} + {row.phase} + {row.count} + {formatTokens(row.totalTokens)} + {formatLatency(row.latencyMs)} + + ))} + +
+
+
+ ); +} + +interface SeatStats { + seat: Seat; + count: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + latencyMs: number; +} + +function aggregatePerSeat(trace: TraceEntry[], seats: Seat[]): SeatStats[] { + const bySeat = new Map(); + for (const seat of seats) { + bySeat.set(seat.seat_no, { + seat, + count: 0, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + latencyMs: 0, + }); + } + for (const t of trace) { + const seatNo = parseSeatFromActor(t.actor); + if (seatNo == null) continue; + const s = bySeat.get(seatNo); + if (!s) continue; + s.count += 1; + s.promptTokens += t.tokens?.prompt ?? 0; + s.completionTokens += t.tokens?.completion ?? 0; + s.totalTokens += t.tokens?.total ?? 0; + s.latencyMs += t.latency_ms; + } + return Array.from(bySeat.values()).sort((a, b) => a.seat.seat_no - b.seat.seat_no); +} + +interface PhaseStats { + day: number; + phase: string; + count: number; + totalTokens: number; + latencyMs: number; +} + +function aggregatePerPhase(trace: TraceEntry[]): PhaseStats[] { + const byPhase = new Map(); + for (const t of trace) { + if (t.day == null || !t.phase) continue; + const phaseKey = t.phase.includes("::") + ? t.phase.split("::")[2] ?? t.phase + : t.phase; + const k = `${t.day}|${phaseKey}`; + const cur = byPhase.get(k) ?? { + day: t.day, + phase: phaseKey, + count: 0, + totalTokens: 0, + latencyMs: 0, + }; + cur.count += 1; + cur.totalTokens += t.tokens?.total ?? 0; + cur.latencyMs += t.latency_ms; + byPhase.set(k, cur); + } + return Array.from(byPhase.values()).sort((a, b) => { + if (a.day !== b.day) return a.day - b.day; + return a.phase.localeCompare(b.phase); + }); +} + +function parseSeatFromActor(actor: string | null): number | null { + if (!actor) return null; + const m = actor.match(/seat=(\d+)/); + return m ? Number(m[1]) : null; +} diff --git a/viewer/src/components/ThemeRegistry.tsx b/viewer/src/components/ThemeRegistry.tsx new file mode 100644 index 0000000..f5e5c47 --- /dev/null +++ b/viewer/src/components/ThemeRegistry.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { ThemeProvider, createTheme } from "@mui/material/styles"; + +const theme = createTheme({ + palette: { + mode: "light", + primary: { main: "#3f51b5" }, + secondary: { main: "#9c27b0" }, + background: { default: "#fafafa", paper: "#ffffff" }, + }, + typography: { + fontFamily: + '-apple-system, BlinkMacSystemFont, "Hiragino Sans", "Yu Gothic UI", "Segoe UI", Roboto, sans-serif', + fontSize: 13, + }, + shape: { borderRadius: 6 }, +}); + +export default function ThemeRegistry({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/viewer/src/components/TraceDrawer.tsx b/viewer/src/components/TraceDrawer.tsx new file mode 100644 index 0000000..9f097a8 --- /dev/null +++ b/viewer/src/components/TraceDrawer.tsx @@ -0,0 +1,188 @@ +"use client"; + +import Box from "@mui/material/Box"; +import Chip from "@mui/material/Chip"; +import Drawer from "@mui/material/Drawer"; +import IconButton from "@mui/material/IconButton"; +import Stack from "@mui/material/Stack"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import CloseIcon from "@mui/icons-material/Close"; +import { formatJstTime, formatLatency, formatTokens } from "@/lib/format"; +import type { TraceEntry } from "@/lib/types"; + +export default function TraceDrawer({ + entry, + onClose, +}: { + entry: TraceEntry | null; + onClose: () => void; +}) { + return ( + + {entry && } + + ); +} + +function Body({ + entry, + onClose, +}: { + entry: TraceEntry; + onClose: () => void; +}) { + const ts = (() => { + try { + return formatJstTime(new Date(entry.ts).getTime()); + } catch { + return entry.ts; + } + })(); + + return ( + + + + LLM 呼び出し詳細 + + + + + + + + + + + {entry.day != null && ( + + )} + {entry.phase && ( + + )} + {typeof entry.metadata?.task === "string" && ( + + )} + + + + + + + + + + + {entry.actor && ( + + + {entry.actor} + + + )} + + {entry.error && ( + + + ERROR + +
{entry.error}
+
+ )} + +
+
{entry.system_prompt}
+
+
+
{entry.user_prompt}
+
+
+
{entry.response ?? "(no response)"}
+
+ {entry.metadata && Object.keys(entry.metadata).length > 0 && ( +
+
{JSON.stringify(entry.metadata, null, 2)}
+
+ )} +
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + {title} + + + {children} + + + ); +} diff --git a/viewer/src/lib/data.ts b/viewer/src/lib/data.ts new file mode 100644 index 0000000..eb6f452 --- /dev/null +++ b/viewer/src/lib/data.ts @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import type { GameSample } from "./types"; + +/** + * Resolve the JSON file the viewer should display. + * + * Default: `viewer/sample-data/game-sample.json`. + * Override at launch time with `GAME_FILE=/abs/or/relative/path.json`. + * + * Server-only — must NEVER be called from a "use client" component. + */ +export async function loadGame(): Promise { + const override = process.env.GAME_FILE; + const target = override + ? path.isAbsolute(override) + ? override + : path.resolve(process.cwd(), override) + : path.resolve(process.cwd(), "sample-data", "game-sample.json"); + + const raw = await readFile(target, "utf-8"); + return JSON.parse(raw) as GameSample; +} + +/** Return a stable phase key suitable for React keys + URL hashes. */ +export function phaseKey(day: number, phase: string): string { + return `d${day}-${phase}`; +} diff --git a/viewer/src/lib/format.ts b/viewer/src/lib/format.ts new file mode 100644 index 0000000..76c53eb --- /dev/null +++ b/viewer/src/lib/format.ts @@ -0,0 +1,98 @@ +import type { RoleKey, Seat } from "./types"; + +const ROLE_JA: Record = { + VILLAGER: "村人", + WEREWOLF: "人狼", + MADMAN: "狂人", + SEER: "占い師", + MEDIUM: "霊媒師", + KNIGHT: "騎士", +}; + +export function roleJa(role: RoleKey): string { + return ROLE_JA[role] ?? role; +} + +const ROLE_FACTION: Record = { + VILLAGER: "village", + SEER: "village", + MEDIUM: "village", + KNIGHT: "village", + WEREWOLF: "wolf", + MADMAN: "wolf", +}; + +export function roleFaction(role: RoleKey): "village" | "wolf" { + return ROLE_FACTION[role] ?? "village"; +} + +const PHASE_JA: Record = { + SETUP: "セットアップ", + NIGHT_0: "初日夜 (Night 0)", + DAY_DISCUSSION: "議論", + DAY_VOTE: "投票", + DAY_RUNOFF_SPEECH: "決選演説", + DAY_RUNOFF: "決選投票", + NIGHT: "夜", + GAME_OVER: "終了", + WAITING_HOST_DECISION: "ホスト待ち", +}; + +export function phaseJa(phase: string): string { + return PHASE_JA[phase] ?? phase; +} + +const NIGHT_ACTION_JA: Record = { + ATTACK: "襲撃", + DIVINE: "占い", + GUARD: "護衛", + DIVINE_NIGHT0_RANDOM_WHITE: "初日ランダム白", +}; + +export function nightActionJa(kind: string): string { + return NIGHT_ACTION_JA[kind] ?? kind; +} + +export function seatLabel(seat: Seat): string { + return `席${seat.seat_no} ${seat.display_name}`; +} + +export function findSeat(seats: Seat[], seatNo: number | null): Seat | null { + if (seatNo == null) return null; + return seats.find((s) => s.seat_no === seatNo) ?? null; +} + +/** + * Format epoch ms as ``HH:MM:SS`` in Asia/Tokyo for the timeline columns. + * Game data uses real wall-clock timestamps so JST is the natural axis. + */ +export function formatJstTime(ms: number): string { + const d = new Date(ms); + return new Intl.DateTimeFormat("ja-JP", { + timeZone: "Asia/Tokyo", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(d); +} + +export function formatTokens(n: number | null | undefined): string { + if (n == null) return "—"; + return n.toLocaleString("en-US"); +} + +export function formatLatency(ms: number): string { + if (ms < 1000) return `${ms} ms`; + return `${(ms / 1000).toFixed(2)} s`; +} + +const SOURCE_JA: Record = { + rounds_text: "テキスト議論", + voice_stt: "音声→STT", + npc_generated: "NPC生成", +}; + +export function sourceJa(source: string): string { + return SOURCE_JA[source] ?? source; +} diff --git a/viewer/src/lib/types.ts b/viewer/src/lib/types.ts new file mode 100644 index 0000000..9aa0df2 --- /dev/null +++ b/viewer/src/lib/types.ts @@ -0,0 +1,120 @@ +// TypeScript shape of the per-game export consumed by the viewer. +// Source of truth: viewer/sample-data/generate_sample.py. + +export type RoleKey = + | "VILLAGER" + | "WEREWOLF" + | "MADMAN" + | "SEER" + | "MEDIUM" + | "KNIGHT"; + +export type DeathCause = "EXECUTED" | "ATTACK" | null; + +export type DiscussionMode = "rounds" | "reactive_voice"; + +export type SpeechSource = "rounds_text" | "voice_stt" | "npc_generated"; + +export type CoDeclaration = "seer" | "medium" | "knight" | null; + +export type TraceRole = "gameplay" | "npc_speech" | "voice_stt"; + +export interface Seat { + seat_no: number; + display_name: string; + is_llm: boolean; + persona_key: string | null; + discord_user_id: string | null; + role: RoleKey; + alive: boolean; + death_cause: DeathCause; + death_day: number | null; +} + +export interface PublicLog { + kind: string; + actor_seat: number | null; + text: string; + created_at_ms: number; +} + +export interface SpeechEvent { + event_id: string; + source: SpeechSource; + speaker_seat: number | null; + text: string; + stt_confidence: number | null; + summary: string | null; + co_declaration: CoDeclaration; + addressed_seat_no: number | null; + created_at_ms: number; +} + +export interface Vote { + day: number; + round: number; + voter_seat: number; + target_seat: number | null; + submitted_at_ms: number; +} + +export interface NightAction { + day: number; + actor_seat: number; + kind: string; // ATTACK / DIVINE / GUARD / DIVINE_NIGHT0_RANDOM_WHITE + target_seat: number | null; + submitted_at_ms: number; +} + +export interface PhaseSection { + day: number; + phase: string; + started_at_ms: number; + public_logs: PublicLog[]; + speech_events: SpeechEvent[]; + votes: Vote[]; + night_actions: NightAction[]; +} + +export interface TokenUsage { + prompt: number | null; + completion: number | null; + total: number | null; +} + +export interface TraceEntry { + ts: string; + role: TraceRole; + provider: string; + model: string; + phase: string | null; + day: number | null; + actor: string | null; + system_prompt: string; + user_prompt: string; + response: string | null; + latency_ms: number; + tokens: TokenUsage | null; + error: string | null; + metadata?: Record | null; + // Synthetic field set by the sample generator for npc/voice rows; viewer + // tolerates its absence on real-world traces. + file_stem?: string; +} + +export interface GameSample { + game: { + id: string; + guild_id: string; + host_user_id: string; + discussion_mode: DiscussionMode; + created_at_ms: number; + ended_at_ms: number | null; + victory: "village" | "wolf" | null; + main_text_channel_id: string; + main_vc_channel_id: string; + }; + seats: Seat[]; + phases: PhaseSection[]; + trace: TraceEntry[]; +} diff --git a/viewer/tsconfig.json b/viewer/tsconfig.json new file mode 100644 index 0000000..e6f414a --- /dev/null +++ b/viewer/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} From 95b14fe159a09df98fb6f97b59213b10b82da0fe Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 13:52:19 +0900 Subject: [PATCH 030/133] feat(export): auto-export game JSON for viewer on victory + abort End-to-end: a finished or aborted game now writes a self-contained JSON to viewer/games/{game_id}.json with no manual step. The viewer auto-discovers the most-recent file in that directory, so reviewing a just-played game is just 'cd viewer && pnpm dev'. New parts: src/wolfbot/services/game_export.py Joins SQLite (games, seats, public_logs, votes, night_actions, speech_events) with logs/llm_calls/{game_id}/*.jsonl into the viewer's GameSample shape. Phases are grouped by (day, phase) in chronological order; votes attach to DAY_VOTE/DAY_RUNOFF, night actions to NIGHT_0/NIGHT, by deriving the phase from row metadata. Time fields are normalized to milliseconds at the boundary (DB stores epoch seconds). scripts/export-game.py CLI for re-exporting a historical game by id without replaying. 'uv run python scripts/export-game.py --game-id g_abc123def'. GameService._on_game_end_finalize hook New optional Awaitable[[str]] callback fired AFTER repo.end_game in BOTH the natural-victory path (advance) and the host_abort path. Errors are logged + swallowed via _run_finalize_hook so teardown is never blocked. Wired in main.py to call export_game. Viewer auto-discovery viewer/src/lib/data.ts now picks the most-recent viewer/games/*.json when GAME_FILE is unset, falling back to sample-data/game-sample.json. Existing GAME_FILE override still works for picking a specific historical game. Schema fix DeathCause is 'EXECUTION' in the DB but the viewer types and sample data had 'EXECUTED'. Aligned both sides + the SeatGrid label so real-game exports render correctly. Tests: - 3 new export tests (full shape, JSONL inlining, missing-game error) - 2 new finalize-hook tests (fires on abort, swallows exceptions) - 929 passed, ruff clean, mypy clean, viewer tsc clean --- scripts/export-game.py | 72 ++++++ src/wolfbot/main.py | 19 ++ src/wolfbot/services/game_export.py | 346 ++++++++++++++++++++++++++ src/wolfbot/services/game_service.py | 21 ++ tests/test_game_export.py | 261 +++++++++++++++++++ tests/test_game_service_advance.py | 55 ++++ viewer/.gitignore | 4 + viewer/README.md | 15 +- viewer/games/.gitkeep | 0 viewer/sample-data/game-sample.json | 4 +- viewer/sample-data/generate_sample.py | 2 +- viewer/src/components/SeatGrid.tsx | 2 +- viewer/src/lib/data.ts | 56 ++++- viewer/src/lib/types.ts | 2 +- 14 files changed, 838 insertions(+), 21 deletions(-) create mode 100755 scripts/export-game.py create mode 100644 src/wolfbot/services/game_export.py create mode 100644 tests/test_game_export.py create mode 100644 viewer/games/.gitkeep diff --git a/scripts/export-game.py b/scripts/export-game.py new file mode 100755 index 0000000..f3c8cbb --- /dev/null +++ b/scripts/export-game.py @@ -0,0 +1,72 @@ +"""CLI: export one finished/aborted game to viewer-compatible JSON. + +Usage:: + + uv run python scripts/export-game.py --game-id g_abc123def + uv run python scripts/export-game.py --game-id g_abc123def \\ + --db ./wolfbot.db --output viewer/games/ + +The same export is also kicked off automatically by GameService at the +end of every game (victory or host_abort) — this CLI is for re-exporting +historical games without replaying them. +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +from pathlib import Path + + +def _parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument( + "--game-id", + required=True, + help="Game id to export (the 12-char hex id printed by /wolf start).", + ) + p.add_argument( + "--db", + default=os.environ.get("WOLFBOT_DB_PATH", "./wolfbot.db"), + help="SQLite path. Defaults to $WOLFBOT_DB_PATH or ./wolfbot.db.", + ) + p.add_argument( + "--trace-dir", + default=os.environ.get("WOLFBOT_LLM_TRACE_DIR", "logs/llm_calls"), + help="LLM trace dir. Defaults to $WOLFBOT_LLM_TRACE_DIR or logs/llm_calls.", + ) + p.add_argument( + "--output", + default="viewer/games", + help="Output directory. The file is written as {output}/{game_id}.json.", + ) + return p.parse_args() + + +async def _amain() -> int: + # Make `from wolfbot.services.game_export import export_game` work when + # this script is invoked directly without the package being installed. + repo_root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(repo_root / "src")) + + from wolfbot.services.game_export import export_game + + args = _parse_args() + out_path = await export_game( + game_id=args.game_id, + db_path=args.db, + trace_dir=args.trace_dir, + output_dir=args.output, + ) + print(f"exported {args.game_id} -> {out_path}") + return 0 + + +def main() -> None: + sys.exit(asyncio.run(_amain())) + + +if __name__ == "__main__": + main() diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 8b52b89..8616afa 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -396,6 +396,24 @@ async def _on_reactive_game_end(game_id: str) -> None: # voice channel between games. Reattaches at the next /wolf start. await _master_leave_vc() + async def _on_game_end_finalize(game_id: str) -> None: + """Export the finished/aborted game to viewer-compatible JSON. + + Joins SQLite + ``logs/llm_calls/{game_id}/*.jsonl`` into one file + under ``viewer/games/{game_id}.json``. The viewer auto-discovers + the most-recent file in that directory, so a finished game can + be reviewed by simply running ``cd viewer && pnpm dev`` — no + separate export step or env var. Errors here MUST NOT prevent + end-of-game cleanup; ``GameService._run_finalize_hook`` already + wraps this in try/except. + """ + from wolfbot.services.game_export import export_game + + await export_game( + game_id=game_id, + db_path=settings.WOLFBOT_DB_PATH, + ) + game_service = GameService( repo=repo, discord=discord_adapter, @@ -403,6 +421,7 @@ async def _on_reactive_game_end(game_id: str) -> None: wake=registry, on_reactive_phase_enter=_on_reactive_phase_enter, on_reactive_game_end=_on_reactive_game_end, + on_game_end_finalize=_on_game_end_finalize, ) discord_adapter.set_game_service(game_service) llm_adapter.set_game_service(game_service) diff --git a/src/wolfbot/services/game_export.py b/src/wolfbot/services/game_export.py new file mode 100644 index 0000000..432d221 --- /dev/null +++ b/src/wolfbot/services/game_export.py @@ -0,0 +1,346 @@ +"""Export one finished/aborted game to viewer-compatible JSON. + +Joins three data sources into one self-contained file the +:mod:`viewer/` Next.js app can read directly: + + 1. SQLite — game / seats / public logs / votes / night actions / speech events + 2. ``logs/llm_calls/{game_id}/*.jsonl`` — gameplay + npc_speech + voice_stt traces + 3. (derived) phase grouping, victory inference + +The output schema is the canonical +:class:`viewer/src/lib/types.ts::GameSample`. Time fields exposed to the +viewer are uniformly milliseconds since epoch — the DB stores +``created_at`` / ``submitted_at`` in seconds, so this module multiplies +by 1000 at the boundary. + +The exporter is invoked in two places: + +* :class:`wolfbot.services.game_service.GameService` calls it as a + ``_on_game_end_finalize`` hook on victory and on host abort. +* ``scripts/export-game.py`` provides a manual CLI for re-exporting any + past game by id. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +import aiosqlite + +log = logging.getLogger(__name__) + +# Default output dir — the viewer auto-discovers files here, so finishing +# a game and running ``cd viewer && pnpm dev`` Just Works with no flags. +_DEFAULT_OUTPUT_DIR = Path("viewer/games") +# Default trace dir — must match wolfbot.services.llm_trace's default. +_DEFAULT_TRACE_DIR = Path("logs/llm_calls") + + +async def export_game( + *, + game_id: str, + db_path: Path | str, + trace_dir: Path | str | None = None, + output_dir: Path | str | None = None, +) -> Path: + """Build the viewer JSON for ``game_id`` and write it to disk. + + Returns the absolute path of the written file. Raises on any DB error + or missing-game; caller decides whether to swallow or propagate. + """ + db_path = Path(db_path) + trace_root = Path(trace_dir) if trace_dir else _DEFAULT_TRACE_DIR + out_dir = Path(output_dir) if output_dir else _DEFAULT_OUTPUT_DIR + out_dir.mkdir(parents=True, exist_ok=True) + + payload = await _build_payload(game_id, db_path, trace_root) + out_path = out_dir / f"{game_id}.json" + out_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8" + ) + log.info( + "game_exported game=%s path=%s phases=%d trace_lines=%d", + game_id, + out_path, + len(payload["phases"]), + len(payload["trace"]), + ) + return out_path.resolve() + + +async def _build_payload( + game_id: str, db_path: Path, trace_root: Path +) -> dict[str, Any]: + async with aiosqlite.connect(str(db_path)) as db: + db.row_factory = aiosqlite.Row + await db.execute("PRAGMA foreign_keys = ON") + + game_row = await _fetch_one( + db, + "SELECT * FROM games WHERE id = ?", + (game_id,), + ) + if game_row is None: + raise ValueError(f"game not found: {game_id}") + + seats = [dict(r) for r in await _fetch_all( + db, + "SELECT * FROM seats WHERE game_id = ? ORDER BY seat_no", + (game_id,), + )] + public_logs = [dict(r) for r in await _fetch_all( + db, + "SELECT day, phase, kind, actor_seat, text, created_at " + "FROM logs_public WHERE game_id = ? ORDER BY id ASC", + (game_id,), + )] + votes = [dict(r) for r in await _fetch_all( + db, + "SELECT day, round, voter_seat, target_seat, submitted_at " + "FROM votes WHERE game_id = ? " + "ORDER BY day, round, submitted_at", + (game_id,), + )] + night_actions = [dict(r) for r in await _fetch_all( + db, + "SELECT day, actor_seat, kind, target_seat, submitted_at " + "FROM night_actions WHERE game_id = ? " + "ORDER BY day, submitted_at", + (game_id,), + )] + speech_events = [dict(r) for r in await _fetch_all( + db, + "SELECT event_id, day, phase, source, speaker_seat, text, " + "stt_confidence, summary, co_declaration, addressed_seat_no, " + "created_at_ms " + "FROM speech_events WHERE game_id = ? ORDER BY created_at_ms ASC", + (game_id,), + )] + + return { + "game": _build_game_meta(game_row, public_logs), + "seats": [_build_seat(s) for s in seats], + "phases": _build_phases( + public_logs, speech_events, votes, night_actions + ), + "trace": _load_trace(trace_root, game_id), + } + + +# ---------------------------------------------------------------- DB helpers +async def _fetch_one( + db: aiosqlite.Connection, sql: str, params: tuple[Any, ...] +) -> aiosqlite.Row | None: + async with db.execute(sql, params) as cur: + return await cur.fetchone() + + +async def _fetch_all( + db: aiosqlite.Connection, sql: str, params: tuple[Any, ...] +) -> list[aiosqlite.Row]: + async with db.execute(sql, params) as cur: + return list(await cur.fetchall()) + + +# ---------------------------------------------------------------- shape builders +def _build_game_meta( + row: aiosqlite.Row, public_logs: list[dict[str, Any]] +) -> dict[str, Any]: + return { + "id": row["id"], + "guild_id": row["guild_id"], + "host_user_id": row["host_user_id"], + "discussion_mode": row["discussion_mode"], + "created_at_ms": _epoch_to_ms(row["created_at"]), + "ended_at_ms": _epoch_to_ms(row["ended_at"]) if row["ended_at"] else None, + "victory": _infer_victory(public_logs), + "main_text_channel_id": row["main_text_channel_id"], + "main_vc_channel_id": row["main_vc_channel_id"], + } + + +def _build_seat(row: dict[str, Any]) -> dict[str, Any]: + return { + "seat_no": row["seat_no"], + "display_name": row["display_name"], + "is_llm": bool(row["is_llm"]), + "persona_key": row["persona_key"], + "discord_user_id": row["discord_user_id"], + "role": row["role"] or "VILLAGER", + "alive": bool(row["alive"]), + "death_cause": row["death_cause"], + "death_day": row["death_day"], + } + + +def _build_phases( + public_logs: list[dict[str, Any]], + speech_events: list[dict[str, Any]], + votes: list[dict[str, Any]], + night_actions: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Group all per-game events into ordered ``(day, phase)`` buckets. + + A phase appears in the output if AT LEAST ONE of public_logs / + speech_events / votes / night_actions has data for it. We don't + invent empty buckets — a game that died in lobby produces only a + SETUP / LOBBY section, not a full ladder of empty NIGHT/DAY rows. + """ + # Discover (day, phase) pairs in chronological order from public logs; + # those are the most reliable per-phase event source. + seen: dict[tuple[int, str], int] = {} # (day, phase) -> first_ts (epoch s) + for row in public_logs: + key = (row["day"], row["phase"]) + if key not in seen: + seen[key] = row["created_at"] + # Plus any phase that has speeches but no public log: + for ev in speech_events: + key = (ev["day"], ev["phase"]) + if key not in seen: + seen[key] = ev["created_at_ms"] // 1000 + # Plus night phases inferred from night_actions: + for na in night_actions: + phase = "NIGHT_0" if na["day"] == 0 else "NIGHT" + key = (na["day"], phase) + if key not in seen: + seen[key] = na["submitted_at"] + # Plus vote phases inferred from votes: + for v in votes: + phase = "DAY_RUNOFF" if v["round"] >= 2 else "DAY_VOTE" + key = (v["day"], phase) + if key not in seen: + seen[key] = v["submitted_at"] + + ordered = sorted(seen.items(), key=lambda kv: kv[1]) + result: list[dict[str, Any]] = [] + for (day, phase), first_ts in ordered: + result.append( + { + "day": day, + "phase": phase, + "started_at_ms": _epoch_to_ms(first_ts), + "public_logs": [ + { + "kind": r["kind"], + "actor_seat": r["actor_seat"], + "text": r["text"], + "created_at_ms": _epoch_to_ms(r["created_at"]), + } + for r in public_logs + if r["day"] == day and r["phase"] == phase + ], + "speech_events": [ + { + "event_id": ev["event_id"], + "source": ev["source"], + "speaker_seat": ev["speaker_seat"], + "text": ev["text"], + "stt_confidence": ev["stt_confidence"], + "summary": ev["summary"], + "co_declaration": ev["co_declaration"], + "addressed_seat_no": ev["addressed_seat_no"], + "created_at_ms": ev["created_at_ms"], + } + for ev in speech_events + if ev["day"] == day and ev["phase"] == phase + ], + "votes": [ + { + "day": v["day"], + "round": v["round"], + "voter_seat": v["voter_seat"], + "target_seat": v["target_seat"], + "submitted_at_ms": _epoch_to_ms(v["submitted_at"]), + } + for v in votes + if v["day"] == day and _vote_phase(v["round"]) == phase + ], + "night_actions": [ + { + "day": na["day"], + "actor_seat": na["actor_seat"], + "kind": na["kind"], + "target_seat": na["target_seat"], + "submitted_at_ms": _epoch_to_ms(na["submitted_at"]), + } + for na in night_actions + if na["day"] == day and _night_phase(na["day"]) == phase + ], + } + ) + return result + + +def _vote_phase(round_: int) -> str: + # Schema convention: round=0 is the regular vote, round=1 is the runoff. + # Anything ≥1 is treated as runoff so the exporter is forward-compatible + # if a future schema adds a second runoff round. + return "DAY_RUNOFF" if round_ >= 1 else "DAY_VOTE" + + +def _night_phase(day: int) -> str: + return "NIGHT_0" if day == 0 else "NIGHT" + + +def _infer_victory(public_logs: list[dict[str, Any]]) -> str | None: + for row in reversed(public_logs): + if row["kind"] != "VICTORY": + continue + text = row["text"] or "" + if "村人" in text or "村陣営" in text: + return "village" + if "人狼" in text or "狼陣営" in text: + return "wolf" + return None + + +def _epoch_to_ms(epoch_seconds: int | None) -> int: + """Promote an epoch-second timestamp into the viewer's ms unit. + + DB columns ``created_at`` / ``submitted_at`` are seconds; the viewer + schema is uniform milliseconds. Returns 0 for None to keep the JSON + well-typed (callers usually pass non-null ts at this point). + """ + if epoch_seconds is None: + return 0 + return int(epoch_seconds) * 1000 + + +# ---------------------------------------------------------------- trace loader +def _load_trace(trace_root: Path, game_id: str) -> list[dict[str, Any]]: + """Walk ``logs/llm_calls/{game_id}/*.jsonl`` and inline every entry. + + Missing dir = empty list (game ran with trace disabled, or pre-trace + games). One bad line is logged and skipped — not fatal. + """ + game_dir = trace_root / game_id + if not game_dir.is_dir(): + return [] + entries: list[dict[str, Any]] = [] + for jsonl_path in sorted(game_dir.glob("*.jsonl")): + stem = jsonl_path.stem # gameplay / npc_setsu / voice_stt / ... + try: + for raw_line in jsonl_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + log.warning( + "skipping malformed trace line in %s", jsonl_path + ) + continue + obj.setdefault("file_stem", stem) + entries.append(obj) + except OSError: + log.exception("could not read trace file %s", jsonl_path) + # Sort by ts when present so the flat list reads chronologically. + entries.sort(key=lambda e: e.get("ts") or "") + return entries + + +__all__ = ["export_game"] diff --git a/src/wolfbot/services/game_service.py b/src/wolfbot/services/game_service.py index 916c941..6a8ffcd 100644 --- a/src/wolfbot/services/game_service.py +++ b/src/wolfbot/services/game_service.py @@ -145,6 +145,7 @@ def __init__( rng: Random | None = None, on_reactive_phase_enter: Callable[[str], Awaitable[None]] | None = None, on_reactive_game_end: Callable[[str], Awaitable[None]] | None = None, + on_game_end_finalize: Callable[[str], Awaitable[None]] | None = None, ) -> None: self.repo = repo self.discord = discord @@ -158,6 +159,11 @@ def __init__( # host abort so reactive_voice plumbing (NPC seat assignments, VC # joins) can be released. self._on_reactive_game_end = on_reactive_game_end + # Fires once per game-end (natural victory OR host abort), AFTER + # ``repo.end_game`` has committed, so the hook sees the row in its + # final ended_at state. Used to trigger the viewer JSON export + # without blocking gameplay — exceptions are logged + swallowed. + self._on_game_end_finalize = on_game_end_finalize def _lock_for(self, game_id: str) -> asyncio.Lock: lock = self._advance_locks.get(game_id) @@ -274,6 +280,7 @@ async def _advance_once(self, game_id: str) -> None: "on_reactive_game_end failed for %s", game_id ) await self.repo.end_game(game_id, ended_at_epoch=self.clock()) + await self._run_finalize_hook(game_id) # 7. Wake the engine so it reschedules on the new deadline. self.wake.wake(game_id) @@ -1010,9 +1017,23 @@ async def host_abort(self, game_id: str) -> bool: "on_reactive_game_end failed during abort %s", game_id ) await self.repo.end_game(game_id, ended_at_epoch=self.clock()) + await self._run_finalize_hook(game_id) self.wake.wake(game_id) return True + async def _run_finalize_hook(self, game_id: str) -> None: + """Fire the post-game-end finalize hook (export, archival, etc). + + Errors are logged and swallowed — finalization MUST NOT prevent + the engine from cleaning up the ended game. + """ + if self._on_game_end_finalize is None: + return + try: + await self._on_game_end_finalize(game_id) + except Exception: + log.exception("on_game_end_finalize failed for %s", game_id) + def new_game_id() -> str: return uuid.uuid4().hex[:12] diff --git a/tests/test_game_export.py b/tests/test_game_export.py new file mode 100644 index 0000000..a07c746 --- /dev/null +++ b/tests/test_game_export.py @@ -0,0 +1,261 @@ +"""Tests for ``wolfbot.services.game_export``.""" + +from __future__ import annotations + +import json +import tempfile +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest +import pytest_asyncio + +from wolfbot.domain.enums import DeathCause, Phase, Role +from wolfbot.domain.models import ( + Game, + LogEntry, + NightAction, + Seat, + Vote, +) +from wolfbot.persistence.schema import migrate +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.game_export import export_game + + +@pytest_asyncio.fixture +async def fixture_repo() -> AsyncIterator[tuple[SqliteRepo, Path]]: + with tempfile.TemporaryDirectory() as td: + db_path = Path(td) / "test.db" + await migrate(db_path) + r = SqliteRepo(db_path) + await r.connect() + try: + yield r, db_path + finally: + await r.close() + + +GAME_ID = "g_export_test" + + +async def _seed_minimal_game(repo: SqliteRepo) -> None: + game = Game( + id=GAME_ID, + guild_id="guild", + host_user_id="host", + phase=Phase.GAME_OVER, + day_number=1, + deadline_epoch=None, + main_text_channel_id="chan_text", + main_vc_channel_id="chan_vc", + heaven_channel_id=None, + wolves_channel_id=None, + created_at=1_700_000_000, + ended_at=1_700_001_000, + force_skip_pending=False, + discussion_mode="rounds", + ) + await repo.create_game(game) + seats = [ + Seat(seat_no=1, display_name="Alice", is_llm=False, persona_key=None, + discord_user_id="u1"), + Seat(seat_no=2, display_name="Bot", is_llm=True, persona_key="setsu", + discord_user_id="u2"), + ] + for seat in seats: + await repo.insert_seat(GAME_ID, seat) + # Roles + final alive/death state — set via the same path the live engine + # uses (set_player_role + raw SQL nudge for death fields). + await repo.set_player_role(GAME_ID, 1, Role.VILLAGER) + await repo.set_player_role(GAME_ID, 2, Role.WEREWOLF) + async with repo._tx() as db: + await db.execute( + "UPDATE seats SET alive=0, death_cause=?, death_day=? " + "WHERE game_id=? AND seat_no=2", + (DeathCause.EXECUTION.value, 1, GAME_ID), + ) + + # Public logs over two phases. + await repo.insert_log_public( + LogEntry( + game_id=GAME_ID, + day=1, + phase=Phase.DAY_DISCUSSION, + kind="PHASE_CHANGE", + actor_seat=None, + visibility="PUBLIC", + text="議論開始", + created_at=1_700_000_100, + ) + ) + await repo.insert_log_public( + LogEntry( + game_id=GAME_ID, + day=1, + phase=Phase.DAY_VOTE, + kind="EXECUTION", + actor_seat=2, + visibility="PUBLIC", + text="席2 が処刑されました\n\n席2=2票", + created_at=1_700_000_500, + ) + ) + await repo.insert_log_public( + LogEntry( + game_id=GAME_ID, + day=1, + phase=Phase.DAY_VOTE, + kind="VICTORY", + actor_seat=None, + visibility="PUBLIC", + text="村人陣営の勝利!", + created_at=1_700_000_510, + ) + ) + + # One vote (round=0 is the regular vote in this schema; runoff is round=1). + await repo.insert_vote( + Vote( + game_id=GAME_ID, + day=1, + round=0, + voter_seat=1, + target_seat=2, + submitted_at=1_700_000_400, + ) + ) + # One night action (day 0 wolf intro divine). + from wolfbot.domain.enums import SubmissionType + + await repo.insert_night_action( + NightAction( + game_id=GAME_ID, + day=0, + actor_seat=2, + kind=SubmissionType.SEER_DIVINE, + target_seat=1, + submitted_at=1_700_000_050, + ) + ) + + +async def test_export_game_writes_json_with_full_shape( + fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path +) -> None: + repo, db_path = fixture_repo + await _seed_minimal_game(repo) + + out = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_path / "no_trace", # absent dir → empty trace + output_dir=tmp_path / "out", + ) + assert out.exists() + payload = json.loads(out.read_text(encoding="utf-8")) + + # Game meta + assert payload["game"]["id"] == GAME_ID + assert payload["game"]["created_at_ms"] == 1_700_000_000_000 + assert payload["game"]["ended_at_ms"] == 1_700_001_000_000 + assert payload["game"]["victory"] == "village" + assert payload["game"]["discussion_mode"] == "rounds" + + # Seats + seats = payload["seats"] + assert len(seats) == 2 + seat2 = next(s for s in seats if s["seat_no"] == 2) + assert seat2["alive"] is False + assert seat2["death_cause"] == "EXECUTION" + assert seat2["death_day"] == 1 + assert seat2["role"] == "WEREWOLF" + + # Phases — chronological order, NIGHT_0 first (from night_actions), + # then DAY_DISCUSSION, then DAY_VOTE. + phases = payload["phases"] + assert [(p["day"], p["phase"]) for p in phases] == [ + (0, "NIGHT_0"), + (1, "DAY_DISCUSSION"), + (1, "DAY_VOTE"), + ] + + # Vote ended up under DAY_VOTE only, not under DAY_DISCUSSION. + vote_phase = next(p for p in phases if p["phase"] == "DAY_VOTE") + disc_phase = next(p for p in phases if p["phase"] == "DAY_DISCUSSION") + assert len(vote_phase["votes"]) == 1 + assert vote_phase["votes"][0]["target_seat"] == 2 + assert vote_phase["votes"][0]["submitted_at_ms"] == 1_700_000_400_000 + assert disc_phase["votes"] == [] + + # Night action ended up under NIGHT_0. + night0 = next(p for p in phases if p["phase"] == "NIGHT_0") + assert len(night0["night_actions"]) == 1 + assert night0["night_actions"][0]["kind"] == "SEER_DIVINE" + + # Public logs unconditionally serialized with ms timestamps. + assert disc_phase["public_logs"][0]["created_at_ms"] == 1_700_000_100_000 + assert vote_phase["public_logs"][0]["kind"] == "EXECUTION" + + # Trace is empty when the trace_dir has no game subdirectory. + assert payload["trace"] == [] + + +async def test_export_game_inlines_jsonl_trace( + fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path +) -> None: + repo, db_path = fixture_repo + await _seed_minimal_game(repo) + + # Stage some trace files. + trace_root = tmp_path / "trace" + game_trace = trace_root / GAME_ID + game_trace.mkdir(parents=True) + (game_trace / "gameplay.jsonl").write_text( + json.dumps({"ts": "2026-04-28T08:00:01+00:00", "role": "gameplay", + "model": "grok-4-1-fast", "system_prompt": "s", + "user_prompt": "u", "response": "r", "latency_ms": 100, + "tokens": {"prompt": 5, "completion": 1, "total": 6}, + "phase": "DAY_DISCUSSION", "day": 1, + "actor": "seat=2 persona=setsu", + "provider": "xai", "error": None}) + "\n", + encoding="utf-8", + ) + (game_trace / "voice_stt.jsonl").write_text( + json.dumps({"ts": "2026-04-28T08:00:00+00:00", "role": "voice_stt", + "provider": "gemini", "model": "gemini-2.0-flash-lite", + "system_prompt": "s", "user_prompt": "[audio]", + "response": "{}", "latency_ms": 800, "tokens": None, + "phase": None, "day": None, "actor": None, + "error": None}) + "\n", + encoding="utf-8", + ) + + out = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=trace_root, + output_dir=tmp_path / "out", + ) + payload = json.loads(out.read_text(encoding="utf-8")) + trace = payload["trace"] + assert len(trace) == 2 + # Ordered chronologically by ts — voice_stt at 08:00:00 before gameplay 08:00:01 + assert trace[0]["role"] == "voice_stt" + assert trace[1]["role"] == "gameplay" + # file_stem is auto-injected from the source file name + assert trace[0]["file_stem"] == "voice_stt" + assert trace[1]["file_stem"] == "gameplay" + + +async def test_export_game_raises_for_unknown_game( + fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path +) -> None: + _repo, db_path = fixture_repo + with pytest.raises(ValueError, match="game not found"): + await export_game( + game_id="nonexistent", + db_path=db_path, + trace_dir=tmp_path, + output_dir=tmp_path / "out", + ) diff --git a/tests/test_game_service_advance.py b/tests/test_game_service_advance.py index b60fada..671f5b0 100644 --- a/tests/test_game_service_advance.py +++ b/tests/test_game_service_advance.py @@ -615,6 +615,61 @@ async def on_end(game_id: str) -> None: assert fired == [game.id] +async def test_host_abort_invokes_finalize_hook( + repo: SqliteRepo, +) -> None: + """The finalize hook (used to export viewer JSON) must fire on host_abort.""" + disc = FakeDiscordAdapter() + llm = FakeLLMAdapter() + reg = EngineRegistry() + finalized: list[str] = [] + + async def on_finalize(game_id: str) -> None: + finalized.append(game_id) + + service = GameService( + repo=repo, + discord=disc, + llm=llm, + wake=reg, + rng=random.Random(0), + on_game_end_finalize=on_finalize, + ) + game = await _make_game_in_setup(repo) + await service.advance(game.id) + + ok = await service.host_abort(game.id) + assert ok + assert finalized == [game.id] + + +async def test_finalize_hook_swallows_exceptions( + repo: SqliteRepo, +) -> None: + """A failing finalize hook (e.g. export disk-full) MUST NOT block teardown.""" + disc = FakeDiscordAdapter() + llm = FakeLLMAdapter() + reg = EngineRegistry() + + async def on_finalize(_game_id: str) -> None: + raise RuntimeError("disk full") + + service = GameService( + repo=repo, + discord=disc, + llm=llm, + wake=reg, + rng=random.Random(0), + on_game_end_finalize=on_finalize, + ) + game = await _make_game_in_setup(repo) + await service.advance(game.id) + + # Abort should still succeed despite the finalize hook raising. + ok = await service.host_abort(game.id) + assert ok + + async def test_host_abort_returns_false_when_already_ended( repo: SqliteRepo, svc: tuple[GameService, FakeDiscordAdapter, FakeLLMAdapter, EngineRegistry, FakeClock], diff --git a/viewer/.gitignore b/viewer/.gitignore index 59fb1bd..27d0019 100644 --- a/viewer/.gitignore +++ b/viewer/.gitignore @@ -23,3 +23,7 @@ next-env.d.ts # IDE .vscode/ .idea/ + +# Auto-exported real-game JSON, written by GameService._on_game_end_finalize. +# Sample data lives in sample-data/ and stays committed. +games/*.json diff --git a/viewer/README.md b/viewer/README.md index f561de4..d644b01 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -13,15 +13,20 @@ pnpm dev ## Loading a different game -Set `GAME_FILE` to any absolute or relative JSON path: +The viewer picks a file using this priority order: + +1. **`GAME_FILE` env var** — absolute or relative to `viewer/` +2. **Most-recent `viewer/games/*.json`** — auto-populated by the bot at every game end (victory or `/wolf abort`) +3. **Fallback** — `viewer/sample-data/game-sample.json` + +So after a real game ends, you can just run `pnpm dev` and the viewer +will load the most recent export automatically. To pick a specific +historical game by id, set `GAME_FILE`: ```bash -GAME_FILE=/abs/path/to/exported-game.json pnpm dev -GAME_FILE=../some-other-game.json pnpm dev +GAME_FILE=games/g_abc123def.json pnpm dev ``` -The path is resolved from the `viewer/` directory when relative. - ## Sample data `sample-data/game-sample.json` is generated by `sample-data/generate_sample.py`. Re-run from the repo root if the schema changes: diff --git a/viewer/games/.gitkeep b/viewer/games/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/viewer/sample-data/game-sample.json b/viewer/sample-data/game-sample.json index 3d32686..16ecc7e 100644 --- a/viewer/sample-data/game-sample.json +++ b/viewer/sample-data/game-sample.json @@ -41,7 +41,7 @@ "discord_user_id": "1498225401372086314", "role": "WEREWOLF", "alive": false, - "death_cause": "EXECUTED", + "death_cause": "EXECUTION", "death_day": 1 }, { @@ -85,7 +85,7 @@ "discord_user_id": "1498225401372086318", "role": "WEREWOLF", "alive": false, - "death_cause": "EXECUTED", + "death_cause": "EXECUTION", "death_day": 2 }, { diff --git a/viewer/sample-data/generate_sample.py b/viewer/sample-data/generate_sample.py index 9a14f1c..6435641 100644 --- a/viewer/sample-data/generate_sample.py +++ b/viewer/sample-data/generate_sample.py @@ -747,7 +747,7 @@ def main() -> None: { **s, "alive": not (s["seat_no"] in (3, 7)), - "death_cause": "EXECUTED" if s["seat_no"] in (3, 7) else None, + "death_cause": "EXECUTION" if s["seat_no"] in (3, 7) else None, "death_day": 1 if s["seat_no"] == 3 else (2 if s["seat_no"] == 7 else None), } for s in SEATS diff --git a/viewer/src/components/SeatGrid.tsx b/viewer/src/components/SeatGrid.tsx index 3fb4ac2..ae1b25b 100644 --- a/viewer/src/components/SeatGrid.tsx +++ b/viewer/src/components/SeatGrid.tsx @@ -74,7 +74,7 @@ function SeatCard({ seat }: { seat: Seat }) { {!seat.alive && ( - {seat.death_cause === "EXECUTED" + {seat.death_cause === "EXECUTION" ? `${seat.death_day}日目に処刑` : seat.death_cause === "ATTACK" ? `${seat.death_day}日目朝に襲撃死` diff --git a/viewer/src/lib/data.ts b/viewer/src/lib/data.ts index eb6f452..16ad4b1 100644 --- a/viewer/src/lib/data.ts +++ b/viewer/src/lib/data.ts @@ -1,25 +1,59 @@ -import { readFile } from "node:fs/promises"; +import { readFile, readdir, stat } from "node:fs/promises"; import path from "node:path"; import type { GameSample } from "./types"; /** - * Resolve the JSON file the viewer should display. + * Resolve which JSON file to display, in priority order: * - * Default: `viewer/sample-data/game-sample.json`. - * Override at launch time with `GAME_FILE=/abs/or/relative/path.json`. + * 1. ``GAME_FILE`` env var (absolute or relative to ``viewer/``) + * 2. The most-recently-modified ``*.json`` in ``viewer/games/`` + * (populated automatically by ``GameService._on_game_end_finalize``) + * 3. Fallback: ``viewer/sample-data/game-sample.json`` * - * Server-only — must NEVER be called from a "use client" component. + * The intent: after a real game ends, ``cd viewer && pnpm dev`` Just + * Works — the bot has already written the export to ``viewer/games/``, + * the viewer auto-picks the newest. No env var, no manual export step. + * + * Server-only — must NEVER be called from a ``"use client"`` component. */ export async function loadGame(): Promise { + const target = await resolveGameFile(); + const raw = await readFile(target, "utf-8"); + return JSON.parse(raw) as GameSample; +} + +async function resolveGameFile(): Promise { const override = process.env.GAME_FILE; - const target = override - ? path.isAbsolute(override) + if (override) { + return path.isAbsolute(override) ? override - : path.resolve(process.cwd(), override) - : path.resolve(process.cwd(), "sample-data", "game-sample.json"); + : path.resolve(process.cwd(), override); + } + const recent = await pickMostRecentExport(); + if (recent) return recent; + return path.resolve(process.cwd(), "sample-data", "game-sample.json"); +} - const raw = await readFile(target, "utf-8"); - return JSON.parse(raw) as GameSample; +async function pickMostRecentExport(): Promise { + const exportsDir = path.resolve(process.cwd(), "games"); + let names: string[]; + try { + names = await readdir(exportsDir); + } catch { + return null; + } + const jsonFiles = names.filter((n) => n.endsWith(".json")); + if (jsonFiles.length === 0) return null; + + const stats = await Promise.all( + jsonFiles.map(async (name) => { + const p = path.join(exportsDir, name); + const s = await stat(p); + return { path: p, mtimeMs: s.mtimeMs }; + }), + ); + stats.sort((a, b) => b.mtimeMs - a.mtimeMs); + return stats[0]!.path; } /** Return a stable phase key suitable for React keys + URL hashes. */ diff --git a/viewer/src/lib/types.ts b/viewer/src/lib/types.ts index 9aa0df2..fba1c46 100644 --- a/viewer/src/lib/types.ts +++ b/viewer/src/lib/types.ts @@ -9,7 +9,7 @@ export type RoleKey = | "MEDIUM" | "KNIGHT"; -export type DeathCause = "EXECUTED" | "ATTACK" | null; +export type DeathCause = "EXECUTION" | "ATTACK" | null; export type DiscussionMode = "rounds" | "reactive_voice"; From a2d2d6a6363e53dcc26df8c42d0e271fed5e4c42 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 14:09:25 +0900 Subject: [PATCH 031/133] feat(viewer): split into games-list + per-game detail routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top page is now a sortable summary table of every JSON in viewer/games/ — game id, victory chip, discussion mode, JST start time, duration, seat count, LLM call count, total tokens, total latency. Click any row to drill into the per-game detail view. Routing: / — games list (server component) /games/[gameId] — detail view (404 page when id missing) /sample — bundled sample, opt-in via the upper-right button or empty-state link The sample game no longer auto-loads. Empty viewer/games/ now shows an empty-state pointing at /sample for first-time users; otherwise the table is the landing page. data.ts split into three explicit loaders (listGames / loadGameById / loadSample), each consumed by exactly one route. The previous mtime-based 'most recent' fallback is gone — what you see in the list is what you get. GameView accepts backHref + sampleBadge props so the header offers 'ゲーム一覧に戻る' and a SAMPLE chip on the sample route. README updated to document the new flow: sample is opt-in, real games auto-populate viewer/games/ via the existing finalize hook. --- viewer/README.md | 60 +++++---- viewer/src/app/games/[gameId]/not-found.tsx | 23 ++++ viewer/src/app/games/[gameId]/page.tsx | 16 +++ viewer/src/app/page.tsx | 43 ++++++- viewer/src/app/sample/page.tsx | 7 + viewer/src/components/GameView.tsx | 45 ++++++- viewer/src/components/GamesTable.tsx | 134 ++++++++++++++++++++ viewer/src/lib/data.ts | 129 +++++++++++++------ viewer/src/lib/format.ts | 30 +++++ 9 files changed, 420 insertions(+), 67 deletions(-) create mode 100644 viewer/src/app/games/[gameId]/not-found.tsx create mode 100644 viewer/src/app/games/[gameId]/page.tsx create mode 100644 viewer/src/app/sample/page.tsx create mode 100644 viewer/src/components/GamesTable.tsx diff --git a/viewer/README.md b/viewer/README.md index d644b01..d733736 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -1,6 +1,6 @@ # wolfbot Game Viewer -Per-phase view of player actions, speeches, LLM prompts, and token usage for a single wolfbot game. Reads a single self-contained JSON file (default: `sample-data/game-sample.json`). +Web UI for inspecting one wolfbot game's full event timeline + LLM internals after the fact. Reads exported game JSONs from `viewer/games/`. ## Quick start @@ -11,40 +11,56 @@ pnpm dev # → http://localhost:3000 ``` -## Loading a different game +## Routes -The viewer picks a file using this priority order: +| Path | Purpose | +|---|---| +| `/` | **ゲーム一覧** — table of every export in `viewer/games/`, newest first. Click a row to drill in. | +| `/games/{game_id}` | Detail view: seats, phase timeline, LLM trace drawer, stats | +| `/sample` | Bundled synthetic sample game — for trying the UI without playing a real game | -1. **`GAME_FILE` env var** — absolute or relative to `viewer/` -2. **Most-recent `viewer/games/*.json`** — auto-populated by the bot at every game end (victory or `/wolf abort`) -3. **Fallback** — `viewer/sample-data/game-sample.json` +## Where the data comes from -So after a real game ends, you can just run `pnpm dev` and the viewer -will load the most recent export automatically. To pick a specific -historical game by id, set `GAME_FILE`: +`viewer/games/{game_id}.json` is written **automatically** at the end of every game (natural victory or `/wolf abort`) by the bot's `GameService._on_game_end_finalize` hook. Each file is the join of: + +- `wolfbot.db` — game/seats/public_logs/votes/night_actions/speech_events +- `logs/llm_calls/{game_id}/*.jsonl` — every LLM call's prompt, response, tokens, latency + +So the typical flow is: ```bash -GAME_FILE=games/g_abc123def.json pnpm dev +# 1. Run the bot, play a game (real or with --mock) +scripts/run-bots.sh +# /wolf join, /wolf start, ... → game ends or you /wolf abort + +# 2. Open the viewer; the just-finished game shows up at the top +cd viewer && pnpm dev ``` -## Sample data +No manual export step. If you want to re-export an older game from SQLite + JSONL into the viewer, run: -`sample-data/game-sample.json` is generated by `sample-data/generate_sample.py`. Re-run from the repo root if the schema changes: +```bash +uv run python scripts/export-game.py --game-id g_abc123def +``` + +## Running with the sample game + +The sample game is **opt-in** — visit `/sample` (linked from the empty-state and from a button in the upper-right of `/`). + +It's a hand-crafted 2-day village victory designed to exercise every UI surface. Generate / regenerate it with: ```bash uv run python viewer/sample-data/generate_sample.py ``` -The synthetic game it produces is a 2-day village victory with a fake-seer counter-CO that gets caught. It exercises every UI surface the viewer supports — public logs, votes, night actions, NPC speeches, voice-STT entries, and matched LLM trace lines with realistic prompt/response/token data. - -## What gets shown +## What gets shown on a detail page -- **Game header** — game id, mode, victor, total LLM calls / tokens / latency -- **Seat grid** — 9 seat cards with role, persona, alive/dead status, faction badge -- **Phase timeline** — chronological event list per phase: phase changes, public announcements, speeches (with CO badges + STT confidence), votes, night actions -- **Trace drawer** — click the bulb icon on any LLM-backed event to see the full system + user prompt, raw response, token usage, latency, model, and metadata -- **Stats panel** — per-seat and per-phase aggregates (call count, prompt/completion/total tokens, latency) +- **Header** — game id, mode, victor, total LLM calls / tokens / latency +- **Seat grid** — per-seat card with role, persona, alive/dead status, faction tint +- **Phase timeline** — each phase: public logs, speeches (CO badge / STT confidence), votes, night actions, sorted chronologically. Bulb icon opens the LLM trace drawer for that event. +- **Trace drawer** — system + user prompt, raw response, prompt/completion/total tokens, latency, model, error +- **Stats panel** — per-seat and per-phase aggregates (calls / tokens / latency) -## Plugging in real game data +## Schema -Real games run by the wolf bot already write LLM trace JSONL files into `logs/llm_calls/{game_id}/*.jsonl` (gameplay / npc_{persona} / voice_stt). The remaining game-state pieces (seats, public logs, votes, night actions) live in `wolfbot.db`. To view a real game in this viewer, write an exporter that joins those two sources into the same shape as `sample-data/game-sample.json` (see `viewer/src/lib/types.ts` for the canonical shape) and then point `GAME_FILE` at it. +The canonical TypeScript shape lives in [`src/lib/types.ts`](./src/lib/types.ts). The Python writer that produces it is [`wolfbot.services.game_export`](../src/wolfbot/services/game_export.py); the test fixture asserting the shape is [`tests/test_game_export.py`](../tests/test_game_export.py). diff --git a/viewer/src/app/games/[gameId]/not-found.tsx b/viewer/src/app/games/[gameId]/not-found.tsx new file mode 100644 index 0000000..246d2bb --- /dev/null +++ b/viewer/src/app/games/[gameId]/not-found.tsx @@ -0,0 +1,23 @@ +import Link from "next/link"; +import Container from "@mui/material/Container"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; + +export default function NotFound() { + return ( + + + + ゲームが見つかりません + + + 指定された game_id に対応する JSON が{" "} + viewer/games/ に存在しません。 + + + ゲーム一覧に戻る + + + + ); +} diff --git a/viewer/src/app/games/[gameId]/page.tsx b/viewer/src/app/games/[gameId]/page.tsx new file mode 100644 index 0000000..de6f049 --- /dev/null +++ b/viewer/src/app/games/[gameId]/page.tsx @@ -0,0 +1,16 @@ +import { notFound } from "next/navigation"; +import GameView from "@/components/GameView"; +import { loadGameById } from "@/lib/data"; + +export default async function GameDetailPage({ + params, +}: { + params: Promise<{ gameId: string }>; +}) { + const { gameId } = await params; + const data = await loadGameById(gameId); + if (data === null) { + notFound(); + } + return ; +} diff --git a/viewer/src/app/page.tsx b/viewer/src/app/page.tsx index 9dadac4..27af43b 100644 --- a/viewer/src/app/page.tsx +++ b/viewer/src/app/page.tsx @@ -1,7 +1,40 @@ -import GameView from "@/components/GameView"; -import { loadGame } from "@/lib/data"; +import Link from "next/link"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Container from "@mui/material/Container"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import GamesTable, { EmptyGamesState } from "@/components/GamesTable"; +import { listGames } from "@/lib/data"; -export default async function Page() { - const data = await loadGame(); - return ; +export default async function GamesListPage() { + const games = await listGames(); + return ( + + + + + Wolfbot Game Viewer + + + 終了したゲームの一覧。行をクリックすると詳細ビューに移動します。 + + + + + {games.length === 0 ? : } + + ); } diff --git a/viewer/src/app/sample/page.tsx b/viewer/src/app/sample/page.tsx new file mode 100644 index 0000000..8e911ed --- /dev/null +++ b/viewer/src/app/sample/page.tsx @@ -0,0 +1,7 @@ +import GameView from "@/components/GameView"; +import { loadSample } from "@/lib/data"; + +export default async function SampleGamePage() { + const data = await loadSample(); + return ; +} diff --git a/viewer/src/components/GameView.tsx b/viewer/src/components/GameView.tsx index 392565e..3f2cd76 100644 --- a/viewer/src/components/GameView.tsx +++ b/viewer/src/components/GameView.tsx @@ -1,8 +1,13 @@ "use client"; import * as React from "react"; +import Link from "next/link"; import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; import Container from "@mui/material/Container"; +import Stack from "@mui/material/Stack"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import GameHeader from "@/components/GameHeader"; import PhaseSection from "@/components/PhaseSection"; import SeatGrid from "@/components/SeatGrid"; @@ -10,14 +15,50 @@ import StatsPanel from "@/components/StatsPanel"; import TraceDrawer from "@/components/TraceDrawer"; import type { GameSample, TraceEntry } from "@/lib/types"; -export default function GameView({ data }: { data: GameSample }) { +export default function GameView({ + data, + backHref, + sampleBadge = false, +}: { + data: GameSample; + backHref?: string; + sampleBadge?: boolean; +}) { const [openTrace, setOpenTrace] = React.useState(null); return ( + + {backHref && ( + + )} + {sampleBadge && ( + + )} + - + {data.phases.map((phase) => ( + + + + Game ID + 勝敗 + モード + 開始 (JST) + 所要 + + LLM 呼び出し + 合計トークン + 合計レイテンシ + + + + {games.map((g) => ( + + ))} + +
+ + ); +} + +/** + * One row of the games-list table. + * + * Wraps the entire row in a Next ```` styled to fill the row so the + * whole strip is clickable AND retains link semantics — right-click ▶ + * "新しいタブで開く" works, screen readers see it as a link, no JS needed. + * + * Implementation: render ``Link`` as the ```` via MUI's ``component`` + * prop. ``LinkProps`` carries ``href``; the ``component`` cast satisfies + * MUI's generic without an ``as any``. + */ +function GameRow({ game }: { game: GameSummary }) { + return ( + td": { borderBottom: "1px solid", borderColor: "divider" }, + }} + > + {game.id} + + + + + + + + {formatJstDate(game.created_at_ms)} + + {formatDuration(game.duration_ms)} + {game.seat_count} + {game.llm_call_count} + {formatTokens(game.total_tokens)} + {formatLatency(game.total_latency_ms)} + + ); +} + +function VictoryChip({ victory }: { victory: GameSummary["victory"] }) { + if (victory === "village") { + return ; + } + if (victory === "wolf") { + return ; + } + return ; +} + +export function EmptyGamesState() { + return ( + + + ゲームの記録がありません + + + ゲームを 1 試合プレイすると、終了時または{" "} + + /wolf abort + {" "} + 実行時に +
+ 自動で viewer/games/{"{game_id}"}.json が出力されます。 +
+ + まずは仕組みを試したい場合は{" "} + + サンプルゲーム + {" "} + を開いてください。 + +
+ ); +} diff --git a/viewer/src/lib/data.ts b/viewer/src/lib/data.ts index 16ad4b1..0a2bb26 100644 --- a/viewer/src/lib/data.ts +++ b/viewer/src/lib/data.ts @@ -1,62 +1,115 @@ import { readFile, readdir, stat } from "node:fs/promises"; import path from "node:path"; -import type { GameSample } from "./types"; +import type { DiscussionMode, GameSample } from "./types"; /** - * Resolve which JSON file to display, in priority order: + * Where exported game JSONs live. Populated automatically by the bot's + * ``GameService._on_game_end_finalize`` hook at the end of every game + * (victory or ``/wolf abort``). * - * 1. ``GAME_FILE`` env var (absolute or relative to ``viewer/``) - * 2. The most-recently-modified ``*.json`` in ``viewer/games/`` - * (populated automatically by ``GameService._on_game_end_finalize``) - * 3. Fallback: ``viewer/sample-data/game-sample.json`` - * - * The intent: after a real game ends, ``cd viewer && pnpm dev`` Just - * Works — the bot has already written the export to ``viewer/games/``, - * the viewer auto-picks the newest. No env var, no manual export step. - * - * Server-only — must NEVER be called from a ``"use client"`` component. + * The viewer treats this directory as its database — the list page + * scans it; detail pages read a specific file from it. */ -export async function loadGame(): Promise { - const target = await resolveGameFile(); - const raw = await readFile(target, "utf-8"); - return JSON.parse(raw) as GameSample; -} +const GAMES_DIR = "games"; +const SAMPLE_PATH = path.join("sample-data", "game-sample.json"); -async function resolveGameFile(): Promise { - const override = process.env.GAME_FILE; - if (override) { - return path.isAbsolute(override) - ? override - : path.resolve(process.cwd(), override); - } - const recent = await pickMostRecentExport(); - if (recent) return recent; - return path.resolve(process.cwd(), "sample-data", "game-sample.json"); +/** One row in the top-level games-list table. */ +export interface GameSummary { + id: string; + discussion_mode: DiscussionMode; + victory: "village" | "wolf" | null; + created_at_ms: number; + ended_at_ms: number | null; + duration_ms: number | null; + seat_count: number; + llm_call_count: number; + total_tokens: number; + total_latency_ms: number; + file_mtime_ms: number; } -async function pickMostRecentExport(): Promise { - const exportsDir = path.resolve(process.cwd(), "games"); +/** List every exported game, newest first by mtime. */ +export async function listGames(): Promise { + const exportsDir = path.resolve(process.cwd(), GAMES_DIR); let names: string[]; try { names = await readdir(exportsDir); } catch { - return null; + return []; } const jsonFiles = names.filter((n) => n.endsWith(".json")); - if (jsonFiles.length === 0) return null; + if (jsonFiles.length === 0) return []; - const stats = await Promise.all( + const summaries = await Promise.all( jsonFiles.map(async (name) => { - const p = path.join(exportsDir, name); - const s = await stat(p); - return { path: p, mtimeMs: s.mtimeMs }; + const fullPath = path.join(exportsDir, name); + try { + const [raw, st] = await Promise.all([ + readFile(fullPath, "utf-8"), + stat(fullPath), + ]); + const data = JSON.parse(raw) as GameSample; + return summarize(data, st.mtimeMs); + } catch { + return null; + } }), ); - stats.sort((a, b) => b.mtimeMs - a.mtimeMs); - return stats[0]!.path; + return summaries + .filter((s): s is GameSummary => s !== null) + .sort((a, b) => b.file_mtime_ms - a.file_mtime_ms); +} + +/** Load one exported game by id, or ``null`` if the file is missing. */ +export async function loadGameById( + gameId: string, +): Promise { + // Defensive — never resolve outside the games dir even if a path-traversal + // id slips in. + if (gameId.includes("/") || gameId.includes("..")) return null; + const target = path.resolve(process.cwd(), GAMES_DIR, `${gameId}.json`); + try { + const raw = await readFile(target, "utf-8"); + return JSON.parse(raw) as GameSample; + } catch { + return null; + } +} + +/** Load the bundled sample game (opt-in via the ``/sample`` route). */ +export async function loadSample(): Promise { + const target = path.resolve(process.cwd(), SAMPLE_PATH); + const raw = await readFile(target, "utf-8"); + return JSON.parse(raw) as GameSample; +} + +function summarize(data: GameSample, mtimeMs: number): GameSummary { + let totalTokens = 0; + let totalLatencyMs = 0; + for (const t of data.trace) { + totalLatencyMs += t.latency_ms; + totalTokens += t.tokens?.total ?? 0; + } + const duration = + data.game.ended_at_ms != null + ? data.game.ended_at_ms - data.game.created_at_ms + : null; + return { + id: data.game.id, + discussion_mode: data.game.discussion_mode, + victory: data.game.victory, + created_at_ms: data.game.created_at_ms, + ended_at_ms: data.game.ended_at_ms, + duration_ms: duration, + seat_count: data.seats.length, + llm_call_count: data.trace.length, + total_tokens: totalTokens, + total_latency_ms: totalLatencyMs, + file_mtime_ms: mtimeMs, + }; } -/** Return a stable phase key suitable for React keys + URL hashes. */ +/** Stable phase key suitable for React keys + URL hashes. */ export function phaseKey(day: number, phase: string): string { return `d${day}-${phase}`; } diff --git a/viewer/src/lib/format.ts b/viewer/src/lib/format.ts index 76c53eb..ddc2d05 100644 --- a/viewer/src/lib/format.ts +++ b/viewer/src/lib/format.ts @@ -46,6 +46,9 @@ const NIGHT_ACTION_JA: Record = { ATTACK: "襲撃", DIVINE: "占い", GUARD: "護衛", + SEER_DIVINE: "占い", + WOLF_ATTACK: "襲撃", + KNIGHT_GUARD: "護衛", DIVINE_NIGHT0_RANDOM_WHITE: "初日ランダム白", }; @@ -77,6 +80,20 @@ export function formatJstTime(ms: number): string { }).format(d); } +/** ``YYYY-MM-DD HH:mm`` in JST. Used by the games-list table. */ +export function formatJstDate(ms: number): string { + const d = new Date(ms); + return new Intl.DateTimeFormat("ja-JP", { + timeZone: "Asia/Tokyo", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(d); +} + export function formatTokens(n: number | null | undefined): string { if (n == null) return "—"; return n.toLocaleString("en-US"); @@ -87,6 +104,19 @@ export function formatLatency(ms: number): string { return `${(ms / 1000).toFixed(2)} s`; } +/** Compact human duration (e.g. "12:34" or "1h 02m" for longer games). */ +export function formatDuration(ms: number | null): string { + if (ms == null) return "進行中"; + const totalSeconds = Math.floor(ms / 1000); + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = totalSeconds % 60; + if (h > 0) { + return `${h}h ${m.toString().padStart(2, "0")}m`; + } + return `${m}:${s.toString().padStart(2, "0")}`; +} + const SOURCE_JA: Record = { rounds_text: "テキスト議論", voice_stt: "音声→STT", From 31fbfbc6d28eda2421eab3c81d76a214bbeab215 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 15:11:59 +0900 Subject: [PATCH 032/133] feat(export): typed exporter contract + cross-stack integration tests + CI Exporter - New game_export_types.py: Pydantic models (GameExport, PhaseSection, TraceEntry, etc.) replace dict[str, Any] returns. extra="forbid" on closed shapes, extra="allow" on open trace entries. - Filter the internal `phase_baseline` SpeechSource sentinel at the SQL boundary; align SpeechSource enum with production (text/voice_stt/ npc_generated). - scripts/dump-export-schema.py emits the canonical JSON Schema to viewer/sample-data/export-schema.json. Tests - tests/test_game_export_integration.py exercises real migrate() schema + log_llm_call writer + export_game(); also asserts the committed schema matches the live model_json_schema() (drift check). - viewer/tests/export-contract.test.ts (vitest + ajv) validates the bundled sample, soft-warns on stale local games/*.json, and spawns the real Python exporter against a seeded fixture DB to prove the live pipeline produces schema-valid output. CI - New .github/workflows/ci.yml with two parallel jobs: - python: ruff + mypy + pytest - viewer: pnpm typecheck + pnpm test (Node + uv for live exporter) --- .github/workflows/ci.yml | 79 ++ scripts/dump-export-schema.py | 41 ++ src/wolfbot/services/game_export.py | 237 +++--- src/wolfbot/services/game_export_types.py | 197 +++++ tests/test_game_export_integration.py | 505 +++++++++++++ viewer/package.json | 9 +- viewer/pnpm-lock.yaml | 861 ++++++++++++++++++++++ viewer/sample-data/export-schema.json | 688 +++++++++++++++++ viewer/sample-data/game-sample.json | 38 +- viewer/sample-data/generate_sample.py | 38 +- viewer/src/lib/format.ts | 2 +- viewer/src/lib/types.ts | 2 +- viewer/tests/export-contract.test.ts | 248 +++++++ viewer/vitest.config.ts | 11 + 14 files changed, 2816 insertions(+), 140 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100755 scripts/dump-export-schema.py create mode 100644 src/wolfbot/services/game_export_types.py create mode 100644 tests/test_game_export_integration.py create mode 100644 viewer/sample-data/export-schema.json create mode 100644 viewer/tests/export-contract.test.ts create mode 100644 viewer/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5e308a2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + python: + name: Python (lint + type + tests) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python 3.11 + run: uv python install 3.11 + + - name: Sync deps + run: uv sync --all-extras --dev + + - name: Lint + run: uv run ruff check src tests + + - name: Type check + run: uv run mypy + + - name: Test + run: uv run pytest tests + + viewer: + name: Viewer (typecheck + contract tests) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # The viewer's contract test spawns the real Python exporter + # (`scripts/export-game.py`) against a seeded fixture DB, so this + # job needs both Node and uv. + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python 3.11 + run: uv python install 3.11 + + - name: Sync Python deps (for live exporter spawn) + run: uv sync --all-extras --dev + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: viewer/pnpm-lock.yaml + + - name: Install viewer deps + working-directory: viewer + run: pnpm install --frozen-lockfile + + - name: Type check + working-directory: viewer + run: pnpm typecheck + + - name: Contract tests (vitest + live Python exporter) + working-directory: viewer + run: pnpm test diff --git a/scripts/dump-export-schema.py b/scripts/dump-export-schema.py new file mode 100755 index 0000000..f37b3e3 --- /dev/null +++ b/scripts/dump-export-schema.py @@ -0,0 +1,41 @@ +"""Dump the JSON Schema of the viewer export contract. + +Writes the JSON Schema produced by +:class:`wolfbot.services.game_export_types.GameExport` to +``viewer/sample-data/export-schema.json``. The viewer contract test +loads this file and validates every committed export against it. + +Run after touching ``game_export_types.py``:: + + uv run python scripts/dump-export-schema.py + +A drift check (``tests/test_game_export_integration.py``) asserts the +committed file equals the freshly emitted schema, so CI fails if you +forget this step. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +def main() -> None: + repo_root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(repo_root / "src")) + + from wolfbot.services.game_export_types import GameExport + + schema = GameExport.model_json_schema() + target = repo_root / "viewer" / "sample-data" / "export-schema.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps(schema, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(f"wrote {target.relative_to(repo_root)}") + + +if __name__ == "__main__": + main() diff --git a/src/wolfbot/services/game_export.py b/src/wolfbot/services/game_export.py index 432d221..f9d3b94 100644 --- a/src/wolfbot/services/game_export.py +++ b/src/wolfbot/services/game_export.py @@ -8,10 +8,11 @@ 3. (derived) phase grouping, victory inference The output schema is the canonical -:class:`viewer/src/lib/types.ts::GameSample`. Time fields exposed to the -viewer are uniformly milliseconds since epoch — the DB stores -``created_at`` / ``submitted_at`` in seconds, so this module multiplies -by 1000 at the boundary. +:class:`wolfbot.services.game_export_types.GameExport` (mirrored 1:1 in +``viewer/src/lib/types.ts``). Time fields exposed to the viewer are +uniformly milliseconds since epoch — the DB stores ``created_at`` / +``submitted_at`` in seconds, so this module multiplies by 1000 at the +boundary. The exporter is invoked in two places: @@ -26,9 +27,27 @@ import json import logging from pathlib import Path -from typing import Any +from typing import Any, cast import aiosqlite +from pydantic import ValidationError + +from wolfbot.services.game_export_types import ( + DeathCause, + DiscussionMode, + GameExport, + GameMeta, + NightActionExport, + PhaseSection, + PublicLogEntry, + RoleKey, + SeatExport, + SpeechEventExport, + SpeechSource, + TraceEntry, + Victory, + VoteExport, +) log = logging.getLogger(__name__) @@ -48,8 +67,9 @@ async def export_game( ) -> Path: """Build the viewer JSON for ``game_id`` and write it to disk. - Returns the absolute path of the written file. Raises on any DB error - or missing-game; caller decides whether to swallow or propagate. + Returns the absolute path of the written file. Raises on any DB error, + missing-game, or schema-violating data; caller decides whether to + swallow or propagate. """ db_path = Path(db_path) trace_root = Path(trace_dir) if trace_dir else _DEFAULT_TRACE_DIR @@ -59,21 +79,22 @@ async def export_game( payload = await _build_payload(game_id, db_path, trace_root) out_path = out_dir / f"{game_id}.json" out_path.write_text( - json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8" + payload.model_dump_json(indent=2), + encoding="utf-8", ) log.info( "game_exported game=%s path=%s phases=%d trace_lines=%d", game_id, out_path, - len(payload["phases"]), - len(payload["trace"]), + len(payload.phases), + len(payload.trace), ) return out_path.resolve() async def _build_payload( game_id: str, db_path: Path, trace_root: Path -) -> dict[str, Any]: +) -> GameExport: async with aiosqlite.connect(str(db_path)) as db: db.row_factory = aiosqlite.Row await db.execute("PRAGMA foreign_keys = ON") @@ -86,48 +107,55 @@ async def _build_payload( if game_row is None: raise ValueError(f"game not found: {game_id}") - seats = [dict(r) for r in await _fetch_all( + seat_rows = [dict(r) for r in await _fetch_all( db, "SELECT * FROM seats WHERE game_id = ? ORDER BY seat_no", (game_id,), )] - public_logs = [dict(r) for r in await _fetch_all( + public_log_rows = [dict(r) for r in await _fetch_all( db, "SELECT day, phase, kind, actor_seat, text, created_at " "FROM logs_public WHERE game_id = ? ORDER BY id ASC", (game_id,), )] - votes = [dict(r) for r in await _fetch_all( + vote_rows = [dict(r) for r in await _fetch_all( db, "SELECT day, round, voter_seat, target_seat, submitted_at " "FROM votes WHERE game_id = ? " "ORDER BY day, round, submitted_at", (game_id,), )] - night_actions = [dict(r) for r in await _fetch_all( + night_action_rows = [dict(r) for r in await _fetch_all( db, "SELECT day, actor_seat, kind, target_seat, submitted_at " "FROM night_actions WHERE game_id = ? " "ORDER BY day, submitted_at", (game_id,), )] - speech_events = [dict(r) for r in await _fetch_all( + # `phase_baseline` rows are an internal sentinel used by + # PublicDiscussionState to seed alive-seat baselines; they have + # empty text and are explicitly excluded from public-log emission + # in the live system. Filter at the SQL level so the viewer never + # sees them. + speech_event_rows = [dict(r) for r in await _fetch_all( db, "SELECT event_id, day, phase, source, speaker_seat, text, " "stt_confidence, summary, co_declaration, addressed_seat_no, " "created_at_ms " - "FROM speech_events WHERE game_id = ? ORDER BY created_at_ms ASC", + "FROM speech_events WHERE game_id = ? " + "AND source != 'phase_baseline' " + "ORDER BY created_at_ms ASC", (game_id,), )] - return { - "game": _build_game_meta(game_row, public_logs), - "seats": [_build_seat(s) for s in seats], - "phases": _build_phases( - public_logs, speech_events, votes, night_actions + return GameExport( + game=_build_game_meta(game_row, public_log_rows), + seats=[_build_seat(s) for s in seat_rows], + phases=_build_phases( + public_log_rows, speech_event_rows, vote_rows, night_action_rows ), - "trace": _load_trace(trace_root, game_id), - } + trace=_load_trace(trace_root, game_id), + ) # ---------------------------------------------------------------- DB helpers @@ -148,32 +176,37 @@ async def _fetch_all( # ---------------------------------------------------------------- shape builders def _build_game_meta( row: aiosqlite.Row, public_logs: list[dict[str, Any]] -) -> dict[str, Any]: - return { - "id": row["id"], - "guild_id": row["guild_id"], - "host_user_id": row["host_user_id"], - "discussion_mode": row["discussion_mode"], - "created_at_ms": _epoch_to_ms(row["created_at"]), - "ended_at_ms": _epoch_to_ms(row["ended_at"]) if row["ended_at"] else None, - "victory": _infer_victory(public_logs), - "main_text_channel_id": row["main_text_channel_id"], - "main_vc_channel_id": row["main_vc_channel_id"], - } - - -def _build_seat(row: dict[str, Any]) -> dict[str, Any]: - return { - "seat_no": row["seat_no"], - "display_name": row["display_name"], - "is_llm": bool(row["is_llm"]), - "persona_key": row["persona_key"], - "discord_user_id": row["discord_user_id"], - "role": row["role"] or "VILLAGER", - "alive": bool(row["alive"]), - "death_cause": row["death_cause"], - "death_day": row["death_day"], - } +) -> GameMeta: + discussion_mode = cast(DiscussionMode, row["discussion_mode"]) + return GameMeta( + id=row["id"], + guild_id=row["guild_id"], + host_user_id=row["host_user_id"], + discussion_mode=discussion_mode, + created_at_ms=_epoch_to_ms(row["created_at"]), + ended_at_ms=_epoch_to_ms(row["ended_at"]) if row["ended_at"] else None, + victory=_infer_victory(public_logs), + main_text_channel_id=row["main_text_channel_id"], + main_vc_channel_id=row["main_vc_channel_id"], + ) + + +def _build_seat(row: dict[str, Any]) -> SeatExport: + role = cast(RoleKey, row["role"] or "VILLAGER") + death_cause: DeathCause | None = ( + cast(DeathCause, row["death_cause"]) if row["death_cause"] else None + ) + return SeatExport( + seat_no=row["seat_no"], + display_name=row["display_name"], + is_llm=bool(row["is_llm"]), + persona_key=row["persona_key"], + discord_user_id=row["discord_user_id"], + role=role, + alive=bool(row["alive"]), + death_cause=death_cause, + death_day=row["death_day"], + ) def _build_phases( @@ -181,7 +214,7 @@ def _build_phases( speech_events: list[dict[str, Any]], votes: list[dict[str, Any]], night_actions: list[dict[str, Any]], -) -> list[dict[str, Any]]: +) -> list[PhaseSection]: """Group all per-game events into ordered ``(day, phase)`` buckets. A phase appears in the output if AT LEAST ONE of public_logs / @@ -215,61 +248,61 @@ def _build_phases( seen[key] = v["submitted_at"] ordered = sorted(seen.items(), key=lambda kv: kv[1]) - result: list[dict[str, Any]] = [] + result: list[PhaseSection] = [] for (day, phase), first_ts in ordered: result.append( - { - "day": day, - "phase": phase, - "started_at_ms": _epoch_to_ms(first_ts), - "public_logs": [ - { - "kind": r["kind"], - "actor_seat": r["actor_seat"], - "text": r["text"], - "created_at_ms": _epoch_to_ms(r["created_at"]), - } + PhaseSection( + day=day, + phase=phase, + started_at_ms=_epoch_to_ms(first_ts), + public_logs=[ + PublicLogEntry( + kind=r["kind"], + actor_seat=r["actor_seat"], + text=r["text"], + created_at_ms=_epoch_to_ms(r["created_at"]), + ) for r in public_logs if r["day"] == day and r["phase"] == phase ], - "speech_events": [ - { - "event_id": ev["event_id"], - "source": ev["source"], - "speaker_seat": ev["speaker_seat"], - "text": ev["text"], - "stt_confidence": ev["stt_confidence"], - "summary": ev["summary"], - "co_declaration": ev["co_declaration"], - "addressed_seat_no": ev["addressed_seat_no"], - "created_at_ms": ev["created_at_ms"], - } + speech_events=[ + SpeechEventExport( + event_id=ev["event_id"], + source=cast(SpeechSource, ev["source"]), + speaker_seat=ev["speaker_seat"], + text=ev["text"], + stt_confidence=ev["stt_confidence"], + summary=ev["summary"], + co_declaration=ev["co_declaration"], + addressed_seat_no=ev["addressed_seat_no"], + created_at_ms=ev["created_at_ms"], + ) for ev in speech_events if ev["day"] == day and ev["phase"] == phase ], - "votes": [ - { - "day": v["day"], - "round": v["round"], - "voter_seat": v["voter_seat"], - "target_seat": v["target_seat"], - "submitted_at_ms": _epoch_to_ms(v["submitted_at"]), - } + votes=[ + VoteExport( + day=v["day"], + round=v["round"], + voter_seat=v["voter_seat"], + target_seat=v["target_seat"], + submitted_at_ms=_epoch_to_ms(v["submitted_at"]), + ) for v in votes if v["day"] == day and _vote_phase(v["round"]) == phase ], - "night_actions": [ - { - "day": na["day"], - "actor_seat": na["actor_seat"], - "kind": na["kind"], - "target_seat": na["target_seat"], - "submitted_at_ms": _epoch_to_ms(na["submitted_at"]), - } + night_actions=[ + NightActionExport( + day=na["day"], + actor_seat=na["actor_seat"], + kind=na["kind"], + target_seat=na["target_seat"], + submitted_at_ms=_epoch_to_ms(na["submitted_at"]), + ) for na in night_actions if na["day"] == day and _night_phase(na["day"]) == phase ], - } + ) ) return result @@ -285,7 +318,7 @@ def _night_phase(day: int) -> str: return "NIGHT_0" if day == 0 else "NIGHT" -def _infer_victory(public_logs: list[dict[str, Any]]) -> str | None: +def _infer_victory(public_logs: list[dict[str, Any]]) -> Victory | None: for row in reversed(public_logs): if row["kind"] != "VICTORY": continue @@ -310,16 +343,18 @@ def _epoch_to_ms(epoch_seconds: int | None) -> int: # ---------------------------------------------------------------- trace loader -def _load_trace(trace_root: Path, game_id: str) -> list[dict[str, Any]]: +def _load_trace(trace_root: Path, game_id: str) -> list[TraceEntry]: """Walk ``logs/llm_calls/{game_id}/*.jsonl`` and inline every entry. Missing dir = empty list (game ran with trace disabled, or pre-trace - games). One bad line is logged and skipped — not fatal. + games). One bad line is logged and skipped — not fatal. A schema- + violating line (missing required field, wrong type) is also logged + and skipped rather than failing the whole export. """ game_dir = trace_root / game_id if not game_dir.is_dir(): return [] - entries: list[dict[str, Any]] = [] + entries: list[TraceEntry] = [] for jsonl_path in sorted(game_dir.glob("*.jsonl")): stem = jsonl_path.stem # gameplay / npc_setsu / voice_stt / ... try: @@ -335,11 +370,17 @@ def _load_trace(trace_root: Path, game_id: str) -> list[dict[str, Any]]: ) continue obj.setdefault("file_stem", stem) - entries.append(obj) + try: + entries.append(TraceEntry.model_validate(obj)) + except ValidationError: + log.exception( + "skipping schema-violating trace line in %s", + jsonl_path, + ) except OSError: log.exception("could not read trace file %s", jsonl_path) # Sort by ts when present so the flat list reads chronologically. - entries.sort(key=lambda e: e.get("ts") or "") + entries.sort(key=lambda e: e.ts or "") return entries diff --git a/src/wolfbot/services/game_export_types.py b/src/wolfbot/services/game_export_types.py new file mode 100644 index 0000000..6101a5e --- /dev/null +++ b/src/wolfbot/services/game_export_types.py @@ -0,0 +1,197 @@ +"""Pydantic models defining the canonical viewer export schema. + +This module is the single source of truth for the per-game export shape. +It is mirrored in ``viewer/src/lib/types.ts`` (TypeScript types) and +``viewer/sample-data/export-schema.json`` (JSON Schema, regenerated from +:class:`GameExport` via ``scripts/dump-export-schema.py``). + +Contract: + +* :func:`wolfbot.services.game_export.export_game` constructs and writes + a :class:`GameExport` — never a bare ``dict[str, Any]``. +* The viewer validates each loaded JSON against ``export-schema.json`` + at test time; any drift fails the viewer's contract test. +* A drift test on the Python side + (:mod:`tests.test_game_export_integration`) asserts the committed + schema file matches what :meth:`GameExport.model_json_schema` emits. + +Time fields exposed to the viewer are uniformly milliseconds since epoch. +The DB stores ``created_at`` / ``submitted_at`` in seconds; the exporter +multiplies by 1000 at the boundary, before it builds these models. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +# A frozen, closed shape — extra fields cause validation errors so a +# typo or a stale field caught here rather than silently dropped. +_StrictConfig = ConfigDict(frozen=True, extra="forbid") +# Trace entries come from JSONL files written by `log_llm_call`. Future +# versions may add metadata keys; allow extras so older viewers don't +# refuse to parse newer traces. Required keys are still validated. +_TraceConfig = ConfigDict(frozen=True, extra="allow") + + +RoleKey = Literal["VILLAGER", "WEREWOLF", "MADMAN", "SEER", "MEDIUM", "KNIGHT"] +DeathCause = Literal["EXECUTION", "ATTACK"] +DiscussionMode = Literal["rounds", "reactive_voice"] +# Mirrors the production `wolfbot.domain.discussion.SpeechSource` minus +# the internal `phase_baseline` sentinel (filtered out by the exporter — +# it's a private state-rebuild marker, not a viewable utterance). +SpeechSource = Literal["text", "voice_stt", "npc_generated"] +CoDeclaration = Literal["seer", "medium", "knight"] +TraceRole = Literal["gameplay", "npc_speech", "voice_stt"] +Victory = Literal["village", "wolf"] + + +class GameMeta(BaseModel): + model_config = _StrictConfig + + id: str + guild_id: str + host_user_id: str + discussion_mode: DiscussionMode + created_at_ms: int + ended_at_ms: int | None + victory: Victory | None + main_text_channel_id: str + main_vc_channel_id: str + + +class SeatExport(BaseModel): + model_config = _StrictConfig + + seat_no: int + display_name: str + is_llm: bool + persona_key: str | None + discord_user_id: str | None + role: RoleKey + alive: bool + death_cause: DeathCause | None + death_day: int | None + + +class PublicLogEntry(BaseModel): + model_config = _StrictConfig + + kind: str + actor_seat: int | None + text: str + created_at_ms: int + + +class SpeechEventExport(BaseModel): + model_config = _StrictConfig + + event_id: str + source: SpeechSource + speaker_seat: int | None + text: str + stt_confidence: float | None + summary: str | None + co_declaration: CoDeclaration | None + addressed_seat_no: int | None + created_at_ms: int + + +class VoteExport(BaseModel): + model_config = _StrictConfig + + day: int + round: int + voter_seat: int + target_seat: int | None + submitted_at_ms: int + + +class NightActionExport(BaseModel): + model_config = _StrictConfig + + day: int + actor_seat: int + kind: str + target_seat: int | None + submitted_at_ms: int + + +class PhaseSection(BaseModel): + model_config = _StrictConfig + + day: int + phase: str + started_at_ms: int + public_logs: list[PublicLogEntry] + speech_events: list[SpeechEventExport] + votes: list[VoteExport] + night_actions: list[NightActionExport] + + +class TokenUsage(BaseModel): + model_config = _StrictConfig + + prompt: int | None + completion: int | None + total: int | None + + +class TraceEntry(BaseModel): + """One JSONL trace row — see :mod:`wolfbot.services.llm_trace`. + + ``extra="allow"`` because trace metadata is intentionally open: the + Master / NPC paths may attach arbitrary debug fields that the viewer + just renders verbatim. + """ + + model_config = _TraceConfig + + ts: str + role: TraceRole + provider: str + model: str + phase: str | None + day: int | None + actor: str | None + system_prompt: str | None + user_prompt: str | None + response: str | None + latency_ms: int + tokens: TokenUsage | None + error: str | None + metadata: dict[str, Any] | None = None + file_stem: str | None = None + + +class GameExport(BaseModel): + """Top-level shape of one ``viewer/games/{id}.json`` file.""" + + model_config = _StrictConfig + + game: GameMeta + seats: list[SeatExport] + phases: list[PhaseSection] + trace: list[TraceEntry] + + +__all__ = [ + "CoDeclaration", + "DeathCause", + "DiscussionMode", + "GameExport", + "GameMeta", + "NightActionExport", + "PhaseSection", + "PublicLogEntry", + "RoleKey", + "SeatExport", + "SpeechEventExport", + "SpeechSource", + "TokenUsage", + "TraceEntry", + "TraceRole", + "Victory", + "VoteExport", +] diff --git a/tests/test_game_export_integration.py b/tests/test_game_export_integration.py new file mode 100644 index 0000000..3cd9476 --- /dev/null +++ b/tests/test_game_export_integration.py @@ -0,0 +1,505 @@ +"""Integration test: real SQLite schema → exporter → viewer-shaped JSON. + +Goes the full distance from production-shape DB (via the same +``migrate()`` that runs at boot) through ``export_game`` and asserts the +output round-trips through the canonical Pydantic models without losing +or fabricating fields. Catches three classes of regression at once: + +1. **DB schema drift** — if a column the exporter SELECTs gets renamed + or dropped from ``schema.py``, this test fails because the SQL errors. +2. **Exporter shape drift** — the output is fed back into + :class:`GameExport`; any new ``extra="forbid"`` violation or missing + required field is caught here. +3. **Viewer contract drift** — the committed + ``viewer/sample-data/export-schema.json`` is compared against + ``GameExport.model_json_schema()``; running + ``scripts/dump-export-schema.py`` is now a hard requirement after + touching ``game_export_types.py``. + +The first two assertions exercise the exact code path +``GameService._on_game_end_finalize`` uses in production, so a green +test guarantees the live exporter — invoked on every victory and +``/wolf abort`` — produces viewer-loadable JSON. +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import aiosqlite +import pytest + +from wolfbot.persistence.schema import migrate +from wolfbot.services.game_export import export_game +from wolfbot.services.game_export_types import GameExport +from wolfbot.services.llm_trace import log_llm_call, trace_context + +GAME_ID = "g_int_test_1" + + +# Columns the exporter SELECTs from each table. If any of these go missing +# from schema.py, the SELECT errors and this test fails. The list is the +# explicit DB-side contract — keep in sync with game_export.py:_build_payload. +_REQUIRED_COLUMNS: dict[str, set[str]] = { + "games": { + "id", "guild_id", "host_user_id", "discussion_mode", + "created_at", "ended_at", "main_text_channel_id", + "main_vc_channel_id", + }, + "seats": { + "seat_no", "display_name", "is_llm", "persona_key", + "discord_user_id", "role", "alive", "death_cause", "death_day", + }, + "logs_public": {"day", "phase", "kind", "actor_seat", "text", "created_at"}, + "votes": {"day", "round", "voter_seat", "target_seat", "submitted_at"}, + "night_actions": { + "day", "actor_seat", "kind", "target_seat", "submitted_at", + }, + "speech_events": { + "event_id", "day", "phase", "source", "speaker_seat", "text", + "stt_confidence", "summary", "co_declaration", + "addressed_seat_no", "created_at_ms", + }, +} + + +async def _seed_realistic_game(db_path: Path) -> None: + """Populate every table the exporter reads with realistic 9-player data. + + Uses raw SQL so the test is decoupled from ``SqliteRepo``'s API shape + and exercises only the on-disk schema — the same surface the exporter + sees in production. + """ + async with aiosqlite.connect(str(db_path)) as db: + await db.execute("PRAGMA foreign_keys = ON") + + await db.execute( + "INSERT INTO games (id, guild_id, host_user_id, phase, day_number, " + "main_text_channel_id, main_vc_channel_id, created_at, ended_at, " + "discussion_mode) " + "VALUES (?, ?, ?, 'GAME_OVER', 2, ?, ?, ?, ?, ?)", + (GAME_ID, "guild_int", "host_int", "ch_text", "ch_vc", + 1_700_000_000, 1_700_001_000, "reactive_voice"), + ) + + seats = [ + (1, "human1", "Alice", 0, None, "VILLAGER", 1, None, None), + (2, None, "Setsu", 1, "setsu", "SEER", 1, None, None), + (3, None, "Gina", 1, "gina", "WEREWOLF", 0, "EXECUTION", 1), + (4, None, "SQ", 1, "sq", "VILLAGER", 1, None, None), + (5, None, "Raqio", 1, "raqio", "MEDIUM", 1, None, None), + (6, None, "Stella", 1, "stella", "KNIGHT", 1, None, None), + (7, None, "Shigemichi", 1, "shigemichi", "WEREWOLF", 0, + "EXECUTION", 2), + (8, None, "Comet", 1, "comet", "MADMAN", 1, None, None), + (9, None, "Jonas", 1, "jonas", "VILLAGER", 1, None, None), + ] + for s in seats: + await db.execute( + "INSERT INTO seats (game_id, seat_no, discord_user_id, " + "display_name, is_llm, persona_key, role, alive, death_cause, " + "death_day) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (GAME_ID, *s), + ) + + public_logs = [ + (0, "SETUP", "SETUP_COMPLETE", None, "ゲーム開始", 1_700_000_001), + (1, "DAY_DISCUSSION", "PHASE_CHANGE", None, "1 日目議論", + 1_700_000_100), + (1, "DAY_VOTE", "EXECUTION", 3, + "席3 が処刑されました\n\n席3=5票", 1_700_000_500), + (2, "DAY_VOTE", "EXECUTION", 7, + "席7 が処刑されました\n\n席7=7票", 1_700_000_900), + (2, "DAY_VOTE", "VICTORY", None, "村人陣営の勝利!", + 1_700_000_910), + (2, "DAY_VOTE", "ROLE_REVEAL", None, "役職公開:\n席1=村人...", + 1_700_000_911), + ] + for day, phase, kind, actor, text, ts in public_logs: + await db.execute( + "INSERT INTO logs_public (game_id, day, phase, kind, " + "actor_seat, text, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (GAME_ID, day, phase, kind, actor, text, ts), + ) + + votes = [ + (1, 0, 1, 3, 1_700_000_400), + (1, 0, 2, 3, 1_700_000_405), + (1, 0, 3, 2, 1_700_000_410), + (1, 0, 4, 3, 1_700_000_415), + (1, 0, 5, 3, 1_700_000_420), + (1, 0, 6, 3, 1_700_000_425), + (1, 0, 7, 2, 1_700_000_430), + (1, 0, 8, 2, 1_700_000_435), + (1, 0, 9, 3, 1_700_000_440), + # Day 2 with one vote that targets None (skip / abstain) + (2, 0, 1, 7, 1_700_000_800), + (2, 0, 8, None, 1_700_000_805), + ] + for v in votes: + await db.execute( + "INSERT INTO votes (game_id, day, round, voter_seat, " + "target_seat, submitted_at) VALUES (?, ?, ?, ?, ?, ?)", + (GAME_ID, *v), + ) + + night_actions = [ + (0, 2, "SEER_DIVINE", 9, 1_700_000_050), + (1, 2, "SEER_DIVINE", 7, 1_700_000_550), + (1, 6, "KNIGHT_GUARD", 2, 1_700_000_555), + (1, 7, "WOLF_ATTACK", 2, 1_700_000_560), + ] + for na in night_actions: + await db.execute( + "INSERT INTO night_actions (game_id, day, actor_seat, kind, " + "target_seat, submitted_at) VALUES (?, ?, ?, ?, ?, ?)", + (GAME_ID, *na), + ) + + speech_events = [ + ("ev_d1_seat2", "DAY_DISCUSSION", "npc_generated", "npc", 2, + "占い師COします。", None, "seat2 CO seer", "seer", None, + 1_700_000_150_000), + ("ev_d1_seat3", "DAY_DISCUSSION", "text", "human", 3, + "私も占いです。", None, "seat3 counter", "seer", 2, + 1_700_000_160_000), + ("ev_d1_voice_seat1", "DAY_DISCUSSION", "voice_stt", "human", 1, + "席3怪しい", 0.92, None, None, 3, + 1_700_000_170_000), + # `phase_baseline` is the internal sentinel — exporter MUST + # filter it. Seeded so the test asserts the SQL filter. + ("ev_d1_baseline", "DAY_DISCUSSION", "phase_baseline", "system", + None, "", None, None, None, None, 1_700_000_140_000), + ("ev_d2_seat5", "DAY_DISCUSSION", "npc_generated", "npc", 5, + "霊媒結果:席3は人狼", None, "seat5 medium", "medium", None, + 1_700_000_700_000), + ] + for (eid, phase, source, kind, sseat, text, conf, summary, co, + addr, ts) in speech_events: + phase_id = f"{GAME_ID}::day1::{phase}::1" + await db.execute( + "INSERT INTO speech_events (event_id, game_id, phase_id, " + "day, phase, source, speaker_kind, speaker_seat, text, " + "stt_confidence, audio_start_ms, audio_end_ms, " + "alive_seat_nos_json, summary, co_declaration, " + "addressed_seat_no, created_at_ms) VALUES " + "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (eid, GAME_ID, phase_id, 1 if "d1" in eid else 2, phase, + source, kind, sseat, text, conf, None, None, None, + summary, co, addr, ts), + ) + + await db.commit() + + +async def _stage_real_trace(trace_root: Path) -> None: + """Use the production ``log_llm_call`` to write JSONL the exporter reads. + + Going through the real writer (rather than handwriting JSON) ensures + the test breaks if ``log_llm_call`` ever changes the on-disk shape + in a way the exporter's loader can't accept. + """ + import os + + # Force trace dir to our tmp path; clear any disable flag. + prev_dir = os.environ.get("WOLFBOT_LLM_TRACE_DIR") + prev_disabled = os.environ.get("WOLFBOT_LLM_TRACE_DISABLED") + os.environ["WOLFBOT_LLM_TRACE_DIR"] = str(trace_root) + os.environ.pop("WOLFBOT_LLM_TRACE_DISABLED", None) + try: + with trace_context( + game_id=GAME_ID, + phase="DAY_DISCUSSION", + day=1, + actor="seat=2 persona=setsu role=SEER", + metadata={"task": "discussion"}, + ): + await log_llm_call( + role="gameplay", + provider="xai", + model="grok-4-1-fast", + system_prompt="あなたは占い師です。", + user_prompt="現在のフェイズ: DAY_DISCUSSION (day=1)", + response='{"intent":"speak","public_message":"占い師COします。"}', + latency_ms=1234, + tokens={"prompt": 100, "completion": 30, "total": 130}, + ) + with trace_context( + game_id=GAME_ID, + phase="DAY_DISCUSSION", + day=1, + actor="seat=2 persona=setsu", + ): + await log_llm_call( + role="npc_speech", + provider="openai-compat", + model="grok-4-1-fast", + system_prompt="NPCとして発話", + user_prompt="短い反応", + response='{"intent":"speak","text":"占い師COします"}', + latency_ms=900, + tokens={"prompt": 80, "completion": 20, "total": 100}, + file_stem="npc_setsu", + ) + with trace_context(game_id=GAME_ID, phase=None, day=None): + await log_llm_call( + role="voice_stt", + provider="gemini", + model="gemini-2.0-flash-lite", + system_prompt="音声を分析", + user_prompt="[audio bytes=80000]", + response='{"transcript":"席3怪しい"}', + latency_ms=850, + tokens={"prompt": 200, "completion": 25, "total": 225}, + ) + finally: + if prev_dir is None: + os.environ.pop("WOLFBOT_LLM_TRACE_DIR", None) + else: + os.environ["WOLFBOT_LLM_TRACE_DIR"] = prev_dir + if prev_disabled is not None: + os.environ["WOLFBOT_LLM_TRACE_DISABLED"] = prev_disabled + + +@pytest.fixture +def tmp_repo(tmp_path: Path) -> Path: + return tmp_path + + +async def test_real_db_schema_columns_cover_exporter_select( + tmp_repo: Path, +) -> None: + """A column listed in ``_REQUIRED_COLUMNS`` must exist after migrate(). + + Cheaper than running the exporter — fires on the *first* drift between + the DDL and the SELECTs in ``game_export.py``. + """ + db_path = tmp_repo / "wolf.db" + await migrate(db_path) + async with aiosqlite.connect(str(db_path)) as db: + for table, required in _REQUIRED_COLUMNS.items(): + async with db.execute(f"PRAGMA table_info({table})") as cur: + actual = {row[1] async for row in cur} + missing = required - actual + assert not missing, ( + f"schema.py is missing columns from {table}: {missing}. " + "Either add them to schema.py or remove the SELECT in " + "game_export.py." + ) + + +async def test_export_pipeline_against_live_schema(tmp_repo: Path) -> None: + """End-to-end: real DDL + real INSERTs + real trace writer + real exporter. + + The output is parsed back through ``GameExport`` so any drift between + the dict shape inside the exporter and the canonical Pydantic schema + surfaces here. + """ + db_path = tmp_repo / "wolf.db" + await migrate(db_path) + await _seed_realistic_game(db_path) + trace_root = tmp_repo / "trace" + await _stage_real_trace(trace_root) + + out_path = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=trace_root, + output_dir=tmp_repo / "out", + ) + raw = out_path.read_text(encoding="utf-8") + + # Parse with the canonical Pydantic model — this is the contract the + # viewer assumes. extra="forbid" on every model except TraceEntry + # means an unexpected key fails this line. + export = GameExport.model_validate_json(raw) + + # Game meta surfaces production-shape data correctly. + assert export.game.id == GAME_ID + assert export.game.discussion_mode == "reactive_voice" + assert export.game.victory == "village" + assert export.game.created_at_ms == 1_700_000_000_000 + assert export.game.ended_at_ms == 1_700_001_000_000 + + # All 9 seats; roles round-trip; deaths attributed correctly. + assert len(export.seats) == 9 + by_seat = {s.seat_no: s for s in export.seats} + assert by_seat[3].alive is False + assert by_seat[3].death_cause == "EXECUTION" + assert by_seat[3].death_day == 1 + assert by_seat[7].death_cause == "EXECUTION" + assert by_seat[7].death_day == 2 + assert by_seat[2].role == "SEER" + assert by_seat[1].is_llm is False + assert by_seat[2].is_llm is True + + # Every (day, phase) bucket the test seeded must show up exactly once. + bucket_keys = [(p.day, p.phase) for p in export.phases] + assert (0, "NIGHT_0") in bucket_keys # from night_actions(day=0) + assert (1, "DAY_DISCUSSION") in bucket_keys + assert (1, "DAY_VOTE") in bucket_keys + assert (1, "NIGHT") in bucket_keys + assert (2, "DAY_VOTE") in bucket_keys + assert len(bucket_keys) == len(set(bucket_keys)), "duplicate phase bucket" + + # Votes route to the right phase. Day 2 has a None target_seat (skip). + d2 = next(p for p in export.phases if (p.day, p.phase) == (2, "DAY_VOTE")) + assert {v.target_seat for v in d2.votes} == {7, None} + + # Speech events with all the structured fields populated. + d1_disc = next( + p for p in export.phases if (p.day, p.phase) == (1, "DAY_DISCUSSION") + ) + voice_ev = next( + ev for ev in d1_disc.speech_events if ev.source == "voice_stt" + ) + assert voice_ev.stt_confidence == pytest.approx(0.92) + assert voice_ev.addressed_seat_no == 3 + co_ev = next( + ev for ev in d1_disc.speech_events if ev.co_declaration == "seer" + ) + assert co_ev.speaker_seat in (2, 3) + # phase_baseline rows are the internal sentinel — never surfaced to viewer. + sources_seen = {ev.source for p in export.phases for ev in p.speech_events} + assert "phase_baseline" not in sources_seen + assert sources_seen <= {"text", "voice_stt", "npc_generated"} + + # All three trace roles came through. Order is chronological by ts. + roles_seen = {t.role for t in export.trace} + assert roles_seen == {"gameplay", "npc_speech", "voice_stt"} + # file_stem is auto-injected from the JSONL filename. + file_stems = {t.file_stem for t in export.trace} + assert "gameplay" in file_stems + assert "npc_setsu" in file_stems + assert "voice_stt" in file_stems + # Token usage round-trips. + gameplay = next(t for t in export.trace if t.role == "gameplay") + assert gameplay.tokens is not None + assert gameplay.tokens.total == 130 + + +async def test_committed_schema_matches_pydantic_models() -> None: + """The viewer-side ``export-schema.json`` must equal the live schema. + + If this fails, run:: + + uv run python scripts/dump-export-schema.py + + The viewer's contract test loads the committed file; out-of-date + schemas there cause spurious failures or silently-accepted drift. + """ + repo_root = Path(__file__).resolve().parents[1] + committed_path = repo_root / "viewer" / "sample-data" / "export-schema.json" + assert committed_path.is_file(), ( + f"missing {committed_path} — run scripts/dump-export-schema.py" + ) + committed = json.loads(committed_path.read_text(encoding="utf-8")) + live = GameExport.model_json_schema() + assert committed == live, ( + "viewer/sample-data/export-schema.json is stale — " + "run `uv run python scripts/dump-export-schema.py` to refresh." + ) + + +async def test_bundled_sample_validates_against_models() -> None: + """``viewer/sample-data/game-sample.json`` must satisfy ``GameExport``.""" + repo_root = Path(__file__).resolve().parents[1] + sample_path = repo_root / "viewer" / "sample-data" / "game-sample.json" + if not sample_path.is_file(): + pytest.skip("sample not generated yet") + GameExport.model_validate_json(sample_path.read_text(encoding="utf-8")) + + +async def test_export_round_trips_when_trace_dir_missing( + tmp_repo: Path, +) -> None: + """Pre-trace games still validate — empty trace list is the contract.""" + db_path = tmp_repo / "wolf.db" + await migrate(db_path) + await _seed_realistic_game(db_path) + + out_path = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_repo / "absent", + output_dir=tmp_repo / "out", + ) + export = GameExport.model_validate_json(out_path.read_text(encoding="utf-8")) + assert export.trace == [] + + +async def test_export_rejects_unknown_game(tmp_repo: Path) -> None: + db_path = tmp_repo / "wolf.db" + await migrate(db_path) + with pytest.raises(ValueError, match="game not found"): + await export_game( + game_id="missing", + db_path=db_path, + trace_dir=tmp_repo / "trace", + output_dir=tmp_repo / "out", + ) + + +async def test_malformed_trace_line_does_not_break_export( + tmp_repo: Path, +) -> None: + """One bad JSONL line is tolerated — the rest of the export still runs.""" + db_path = tmp_repo / "wolf.db" + await migrate(db_path) + await _seed_realistic_game(db_path) + trace_root = tmp_repo / "trace" + game_dir = trace_root / GAME_ID + game_dir.mkdir(parents=True) + # Mix of valid + malformed (json error) + schema-violating (string for + # latency_ms). Valid line must come through; the rest are skipped. + valid = json.dumps({ + "ts": "2026-04-28T08:00:00+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=2", + "system_prompt": "s", + "user_prompt": "u", + "response": "r", + "latency_ms": 100, + "tokens": None, + "error": None, + }) + schema_violating = json.dumps({ + "ts": "2026-04-28T08:00:01+00:00", + "role": "gameplay", + "provider": "xai", + "model": "x", + "phase": None, + "day": None, + "actor": None, + "system_prompt": "s", + "user_prompt": "u", + "response": "r", + "latency_ms": "not-an-int", # wrong type + "tokens": None, + "error": None, + }) + (game_dir / "gameplay.jsonl").write_text( + valid + "\n{not valid json\n" + schema_violating + "\n", + encoding="utf-8", + ) + + # Tempfile setup + with tempfile.TemporaryDirectory() as out_root: + out_path = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=trace_root, + output_dir=Path(out_root), + ) + export = GameExport.model_validate_json( + out_path.read_text(encoding="utf-8") + ) + assert len(export.trace) == 1 + assert export.trace[0].role == "gameplay" + assert export.trace[0].latency_ms == 100 diff --git a/viewer/package.json b/viewer/package.json index d4833ba..3f1c97f 100644 --- a/viewer/package.json +++ b/viewer/package.json @@ -8,7 +8,9 @@ "build": "next build", "start": "next start", "lint": "next lint", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@emotion/cache": "^11.13.5", @@ -25,8 +27,11 @@ "@types/node": "^22.10.2", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "eslint": "^9.17.0", "eslint-config-next": "^15.1.3", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^2.1.8" } } diff --git a/viewer/pnpm-lock.yaml b/viewer/pnpm-lock.yaml index 819f07e..4f85c5f 100644 --- a/viewer/pnpm-lock.yaml +++ b/viewer/pnpm-lock.yaml @@ -45,6 +45,12 @@ importers: '@types/react-dom': specifier: ^19.0.2 version: 19.2.3(@types/react@19.2.14) + ajv: + specifier: ^8.17.1 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) eslint: specifier: ^9.17.0 version: 9.39.4 @@ -54,6 +60,9 @@ importers: typescript: specifier: ^5.7.2 version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.17) packages: @@ -165,6 +174,144 @@ packages: '@emotion/weak-memoize@0.4.0': resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -558,6 +705,131 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@rollup/rollup-android-arm-eabi@4.60.2': + resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.2': + resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.2': + resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.2': + resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.2': + resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.2': + resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.2': + resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.2': + resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.2': + resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.2': + resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.2': + resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.2': + resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.2': + resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.2': + resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} + cpu: [x64] + os: [win32] + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -755,6 +1027,35 @@ packages: cpu: [x64] os: [win32] + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -765,9 +1066,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -811,6 +1123,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -852,6 +1168,10 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -871,10 +1191,18 @@ packages: caniuse-lite@1.0.30001791: resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -938,6 +1266,10 @@ packages: supports-color: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -986,6 +1318,9 @@ packages: resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1002,6 +1337,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1122,10 +1462,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1139,6 +1486,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1177,6 +1527,11 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1413,6 +1768,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1452,6 +1810,12 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1586,6 +1950,13 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1605,6 +1976,10 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.12: + resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1648,6 +2023,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1669,6 +2048,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rollup@4.60.2: + resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1736,6 +2120,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1747,6 +2134,12 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -1806,10 +2199,28 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1864,6 +2275,67 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -1885,6 +2357,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2054,6 +2531,75 @@ snapshots: '@emotion/weak-memoize@0.4.0': {} + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': dependencies: eslint: 9.39.4 @@ -2375,6 +2921,81 @@ snapshots: '@popperjs/core@2.11.8': {} + '@rollup/rollup-android-arm-eabi@4.60.2': + optional: true + + '@rollup/rollup-android-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-x64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.2': + optional: true + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.16.1': {} @@ -2564,12 +3185,56 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.17))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.17) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 acorn@8.16.0: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -2577,6 +3242,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -2652,6 +3324,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} async-function@1.0.0: {} @@ -2687,6 +3361,8 @@ snapshots: dependencies: fill-range: 7.1.1 + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2708,11 +3384,21 @@ snapshots: caniuse-lite@1.0.30001791: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + check-error@2.1.3: {} + client-only@0.0.1: {} clsx@2.1.1: {} @@ -2771,6 +3457,8 @@ snapshots: dependencies: ms: 2.1.3 + deep-eql@5.0.2: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -2889,6 +3577,8 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -2910,6 +3600,32 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + escape-string-regexp@4.0.0: {} eslint-config-next@15.5.15(eslint@9.39.4)(typescript@5.9.3): @@ -3106,8 +3822,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -3122,6 +3844,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.0: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -3156,6 +3880,9 @@ snapshots: dependencies: is-callable: 1.2.7 + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -3400,6 +4127,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -3440,6 +4169,12 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} merge2@1.4.1: {} @@ -3581,6 +4316,10 @@ snapshots: path-type@4.0.0: {} + pathe@1.1.2: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -3595,6 +4334,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.12: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} prop-types@15.8.1: @@ -3647,6 +4392,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -3669,6 +4416,37 @@ snapshots: reusify@1.1.0: {} + rollup@4.60.2: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.2 + '@rollup/rollup-android-arm64': 4.60.2 + '@rollup/rollup-darwin-arm64': 4.60.2 + '@rollup/rollup-darwin-x64': 4.60.2 + '@rollup/rollup-freebsd-arm64': 4.60.2 + '@rollup/rollup-freebsd-x64': 4.60.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 + '@rollup/rollup-linux-arm-musleabihf': 4.60.2 + '@rollup/rollup-linux-arm64-gnu': 4.60.2 + '@rollup/rollup-linux-arm64-musl': 4.60.2 + '@rollup/rollup-linux-loong64-gnu': 4.60.2 + '@rollup/rollup-linux-loong64-musl': 4.60.2 + '@rollup/rollup-linux-ppc64-gnu': 4.60.2 + '@rollup/rollup-linux-ppc64-musl': 4.60.2 + '@rollup/rollup-linux-riscv64-gnu': 4.60.2 + '@rollup/rollup-linux-riscv64-musl': 4.60.2 + '@rollup/rollup-linux-s390x-gnu': 4.60.2 + '@rollup/rollup-linux-x64-gnu': 4.60.2 + '@rollup/rollup-linux-x64-musl': 4.60.2 + '@rollup/rollup-openbsd-x64': 4.60.2 + '@rollup/rollup-openharmony-arm64': 4.60.2 + '@rollup/rollup-win32-arm64-msvc': 4.60.2 + '@rollup/rollup-win32-ia32-msvc': 4.60.2 + '@rollup/rollup-win32-x64-gnu': 4.60.2 + '@rollup/rollup-win32-x64-msvc': 4.60.2 + fsevents: 2.3.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -3786,12 +4564,18 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + source-map-js@1.2.1: {} source-map@0.5.7: {} stable-hash@0.0.5: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -3864,11 +4648,21 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -3962,6 +4756,68 @@ snapshots: dependencies: punycode: 2.3.1 + vite-node@2.1.9(@types/node@22.19.17): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.19.17) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.19.17): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.12 + rollup: 4.60.2 + optionalDependencies: + '@types/node': 22.19.17 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@22.19.17): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.17)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.19.17) + vite-node: 2.1.9(@types/node@22.19.17) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.17 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -4007,6 +4863,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} yaml@1.10.3: {} diff --git a/viewer/sample-data/export-schema.json b/viewer/sample-data/export-schema.json new file mode 100644 index 0000000..29dd2f2 --- /dev/null +++ b/viewer/sample-data/export-schema.json @@ -0,0 +1,688 @@ +{ + "$defs": { + "GameMeta": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "guild_id": { + "title": "Guild Id", + "type": "string" + }, + "host_user_id": { + "title": "Host User Id", + "type": "string" + }, + "discussion_mode": { + "enum": [ + "rounds", + "reactive_voice" + ], + "title": "Discussion Mode", + "type": "string" + }, + "created_at_ms": { + "title": "Created At Ms", + "type": "integer" + }, + "ended_at_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Ended At Ms" + }, + "victory": { + "anyOf": [ + { + "enum": [ + "village", + "wolf" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Victory" + }, + "main_text_channel_id": { + "title": "Main Text Channel Id", + "type": "string" + }, + "main_vc_channel_id": { + "title": "Main Vc Channel Id", + "type": "string" + } + }, + "required": [ + "id", + "guild_id", + "host_user_id", + "discussion_mode", + "created_at_ms", + "ended_at_ms", + "victory", + "main_text_channel_id", + "main_vc_channel_id" + ], + "title": "GameMeta", + "type": "object" + }, + "NightActionExport": { + "additionalProperties": false, + "properties": { + "day": { + "title": "Day", + "type": "integer" + }, + "actor_seat": { + "title": "Actor Seat", + "type": "integer" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "target_seat": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Target Seat" + }, + "submitted_at_ms": { + "title": "Submitted At Ms", + "type": "integer" + } + }, + "required": [ + "day", + "actor_seat", + "kind", + "target_seat", + "submitted_at_ms" + ], + "title": "NightActionExport", + "type": "object" + }, + "PhaseSection": { + "additionalProperties": false, + "properties": { + "day": { + "title": "Day", + "type": "integer" + }, + "phase": { + "title": "Phase", + "type": "string" + }, + "started_at_ms": { + "title": "Started At Ms", + "type": "integer" + }, + "public_logs": { + "items": { + "$ref": "#/$defs/PublicLogEntry" + }, + "title": "Public Logs", + "type": "array" + }, + "speech_events": { + "items": { + "$ref": "#/$defs/SpeechEventExport" + }, + "title": "Speech Events", + "type": "array" + }, + "votes": { + "items": { + "$ref": "#/$defs/VoteExport" + }, + "title": "Votes", + "type": "array" + }, + "night_actions": { + "items": { + "$ref": "#/$defs/NightActionExport" + }, + "title": "Night Actions", + "type": "array" + } + }, + "required": [ + "day", + "phase", + "started_at_ms", + "public_logs", + "speech_events", + "votes", + "night_actions" + ], + "title": "PhaseSection", + "type": "object" + }, + "PublicLogEntry": { + "additionalProperties": false, + "properties": { + "kind": { + "title": "Kind", + "type": "string" + }, + "actor_seat": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Actor Seat" + }, + "text": { + "title": "Text", + "type": "string" + }, + "created_at_ms": { + "title": "Created At Ms", + "type": "integer" + } + }, + "required": [ + "kind", + "actor_seat", + "text", + "created_at_ms" + ], + "title": "PublicLogEntry", + "type": "object" + }, + "SeatExport": { + "additionalProperties": false, + "properties": { + "seat_no": { + "title": "Seat No", + "type": "integer" + }, + "display_name": { + "title": "Display Name", + "type": "string" + }, + "is_llm": { + "title": "Is Llm", + "type": "boolean" + }, + "persona_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Persona Key" + }, + "discord_user_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Discord User Id" + }, + "role": { + "enum": [ + "VILLAGER", + "WEREWOLF", + "MADMAN", + "SEER", + "MEDIUM", + "KNIGHT" + ], + "title": "Role", + "type": "string" + }, + "alive": { + "title": "Alive", + "type": "boolean" + }, + "death_cause": { + "anyOf": [ + { + "enum": [ + "EXECUTION", + "ATTACK" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Death Cause" + }, + "death_day": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Death Day" + } + }, + "required": [ + "seat_no", + "display_name", + "is_llm", + "persona_key", + "discord_user_id", + "role", + "alive", + "death_cause", + "death_day" + ], + "title": "SeatExport", + "type": "object" + }, + "SpeechEventExport": { + "additionalProperties": false, + "properties": { + "event_id": { + "title": "Event Id", + "type": "string" + }, + "source": { + "enum": [ + "text", + "voice_stt", + "npc_generated" + ], + "title": "Source", + "type": "string" + }, + "speaker_seat": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Speaker Seat" + }, + "text": { + "title": "Text", + "type": "string" + }, + "stt_confidence": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Stt Confidence" + }, + "summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Summary" + }, + "co_declaration": { + "anyOf": [ + { + "enum": [ + "seer", + "medium", + "knight" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Co Declaration" + }, + "addressed_seat_no": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Addressed Seat No" + }, + "created_at_ms": { + "title": "Created At Ms", + "type": "integer" + } + }, + "required": [ + "event_id", + "source", + "speaker_seat", + "text", + "stt_confidence", + "summary", + "co_declaration", + "addressed_seat_no", + "created_at_ms" + ], + "title": "SpeechEventExport", + "type": "object" + }, + "TokenUsage": { + "additionalProperties": false, + "properties": { + "prompt": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Prompt" + }, + "completion": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Completion" + }, + "total": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Total" + } + }, + "required": [ + "prompt", + "completion", + "total" + ], + "title": "TokenUsage", + "type": "object" + }, + "TraceEntry": { + "additionalProperties": true, + "description": "One JSONL trace row — see :mod:`wolfbot.services.llm_trace`.\n\n``extra=\"allow\"`` because trace metadata is intentionally open: the\nMaster / NPC paths may attach arbitrary debug fields that the viewer\njust renders verbatim.", + "properties": { + "ts": { + "title": "Ts", + "type": "string" + }, + "role": { + "enum": [ + "gameplay", + "npc_speech", + "voice_stt" + ], + "title": "Role", + "type": "string" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "model": { + "title": "Model", + "type": "string" + }, + "phase": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Phase" + }, + "day": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Day" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actor" + }, + "system_prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "System Prompt" + }, + "user_prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Prompt" + }, + "response": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response" + }, + "latency_ms": { + "title": "Latency Ms", + "type": "integer" + }, + "tokens": { + "anyOf": [ + { + "$ref": "#/$defs/TokenUsage" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Metadata" + }, + "file_stem": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "File Stem" + } + }, + "required": [ + "ts", + "role", + "provider", + "model", + "phase", + "day", + "actor", + "system_prompt", + "user_prompt", + "response", + "latency_ms", + "tokens", + "error" + ], + "title": "TraceEntry", + "type": "object" + }, + "VoteExport": { + "additionalProperties": false, + "properties": { + "day": { + "title": "Day", + "type": "integer" + }, + "round": { + "title": "Round", + "type": "integer" + }, + "voter_seat": { + "title": "Voter Seat", + "type": "integer" + }, + "target_seat": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Target Seat" + }, + "submitted_at_ms": { + "title": "Submitted At Ms", + "type": "integer" + } + }, + "required": [ + "day", + "round", + "voter_seat", + "target_seat", + "submitted_at_ms" + ], + "title": "VoteExport", + "type": "object" + } + }, + "additionalProperties": false, + "description": "Top-level shape of one ``viewer/games/{id}.json`` file.", + "properties": { + "game": { + "$ref": "#/$defs/GameMeta" + }, + "seats": { + "items": { + "$ref": "#/$defs/SeatExport" + }, + "title": "Seats", + "type": "array" + }, + "phases": { + "items": { + "$ref": "#/$defs/PhaseSection" + }, + "title": "Phases", + "type": "array" + }, + "trace": { + "items": { + "$ref": "#/$defs/TraceEntry" + }, + "title": "Trace", + "type": "array" + } + }, + "required": [ + "game", + "seats", + "phases", + "trace" + ], + "title": "GameExport", + "type": "object" +} diff --git a/viewer/sample-data/game-sample.json b/viewer/sample-data/game-sample.json index 16ecc7e..1393da4 100644 --- a/viewer/sample-data/game-sample.json +++ b/viewer/sample-data/game-sample.json @@ -160,7 +160,7 @@ "speech_events": [ { "event_id": "sp_2_1714291275000", - "source": "rounds_text", + "source": "text", "speaker_seat": 2, "text": "占い師COします。初日ランダム白で席9 ジョナスを占いました。白判定です。", "stt_confidence": null, @@ -171,7 +171,7 @@ }, { "event_id": "sp_3_1714291290000", - "source": "rounds_text", + "source": "text", "speaker_seat": 3, "text": "私も占い師です。席4 SQを占って白でした。席2は何かおかしい。", "stt_confidence": null, @@ -182,7 +182,7 @@ }, { "event_id": "sp_5_1714291310000", - "source": "rounds_text", + "source": "text", "speaker_seat": 5, "text": "霊媒師COはまだしません。今日処刑が出てから結果を出します。", "stt_confidence": null, @@ -193,7 +193,7 @@ }, { "event_id": "sp_9_1714291330000", - "source": "rounds_text", + "source": "text", "speaker_seat": 9, "text": "席2の白判定をもらった9番です。占い理由が筋が通ってる。", "stt_confidence": null, @@ -204,7 +204,7 @@ }, { "event_id": "sp_4_1714291350000", - "source": "rounds_text", + "source": "text", "speaker_seat": 4, "text": "占いが2人いる。どちらが本物か議論したい。", "stt_confidence": null, @@ -215,7 +215,7 @@ }, { "event_id": "sp_6_1714291370000", - "source": "rounds_text", + "source": "text", "speaker_seat": 6, "text": "占いCOの2人をしっかり見極めましょう。", "stt_confidence": null, @@ -226,7 +226,7 @@ }, { "event_id": "sp_7_1714291390000", - "source": "rounds_text", + "source": "text", "speaker_seat": 7, "text": "席3が偽のように見える。占い理由が雑な印象。", "stt_confidence": null, @@ -237,7 +237,7 @@ }, { "event_id": "sp_8_1714291410000", - "source": "rounds_text", + "source": "text", "speaker_seat": 8, "text": "私は席2が怪しいと感じる。席3を信じたい。", "stt_confidence": null, @@ -248,7 +248,7 @@ }, { "event_id": "sp_1_1714291430000", - "source": "rounds_text", + "source": "text", "speaker_seat": 1, "text": "席3の占い理由がパッとしない。席2の方が信用できそう。", "stt_confidence": null, @@ -259,7 +259,7 @@ }, { "event_id": "sp_2_1714291460000", - "source": "rounds_text", + "source": "text", "speaker_seat": 2, "text": "席3の理由が薄いです。席3を投票候補にしたい。", "stt_confidence": null, @@ -270,7 +270,7 @@ }, { "event_id": "sp_3_1714291475000", - "source": "rounds_text", + "source": "text", "speaker_seat": 3, "text": "席2が偽です。みなさん席2に投票してください。", "stt_confidence": null, @@ -281,7 +281,7 @@ }, { "event_id": "sp_5_1714291490000", - "source": "rounds_text", + "source": "text", "speaker_seat": 5, "text": "占い騙りなら2が真の可能性が高い気がする。", "stt_confidence": null, @@ -292,7 +292,7 @@ }, { "event_id": "sp_9_1714291510000", - "source": "rounds_text", + "source": "text", "speaker_seat": 9, "text": "席2に白を貰った立場として、席3が偽だと考える。", "stt_confidence": null, @@ -450,7 +450,7 @@ "speech_events": [ { "event_id": "sp_2_1714291730000", - "source": "rounds_text", + "source": "text", "speaker_seat": 2, "text": "席7を占いました。黒判定です。席7 シゲミチが人狼です。", "stt_confidence": null, @@ -461,7 +461,7 @@ }, { "event_id": "sp_5_1714291750000", - "source": "rounds_text", + "source": "text", "speaker_seat": 5, "text": "霊媒結果: 席3 ジーナは人狼でした。占い騙りで確定です。", "stt_confidence": null, @@ -472,7 +472,7 @@ }, { "event_id": "sp_6_1714291770000", - "source": "rounds_text", + "source": "text", "speaker_seat": 6, "text": "騎士COします。昨夜は席2を護衛して、襲撃を弾きました。", "stt_confidence": null, @@ -483,7 +483,7 @@ }, { "event_id": "sp_7_1714291790000", - "source": "rounds_text", + "source": "text", "speaker_seat": 7, "text": "私は人狼じゃない。席2の占いは捏造です。", "stt_confidence": null, @@ -494,7 +494,7 @@ }, { "event_id": "sp_8_1714291810000", - "source": "rounds_text", + "source": "text", "speaker_seat": 8, "text": "席2 セツが本当に占い師なら、席7 黒は信じざるを得ない。", "stt_confidence": null, @@ -505,7 +505,7 @@ }, { "event_id": "sp_1_1714291830000", - "source": "rounds_text", + "source": "text", "speaker_seat": 1, "text": "占い・霊媒・騎士の3CO全部噛み合ってるので席7処刑で行きましょう。", "stt_confidence": null, diff --git a/viewer/sample-data/generate_sample.py b/viewer/sample-data/generate_sample.py index 6435641..24a704e 100644 --- a/viewer/sample-data/generate_sample.py +++ b/viewer/sample-data/generate_sample.py @@ -127,23 +127,23 @@ def build_phases() -> list[dict[str, Any]]: }, ], "speech_events": [ - _speech(2, "rounds_text", _ts(15), "占い師COします。初日ランダム白で席9 ジョナスを占いました。白判定です。", + _speech(2, "text", _ts(15), "占い師COします。初日ランダム白で席9 ジョナスを占いました。白判定です。", co_declaration="seer", summary="seat 2 COs seer; seat 9 white"), - _speech(3, "rounds_text", _ts(30), "私も占い師です。席4 SQを占って白でした。席2は何かおかしい。", + _speech(3, "text", _ts(30), "私も占い師です。席4 SQを占って白でした。席2は何かおかしい。", co_declaration="seer", summary="seat 3 counter-COs seer; seat 4 white"), - _speech(5, "rounds_text", _ts(50), "霊媒師COはまだしません。今日処刑が出てから結果を出します。", + _speech(5, "text", _ts(50), "霊媒師COはまだしません。今日処刑が出てから結果を出します。", summary="seat 5 plans medium CO after first execution"), - _speech(9, "rounds_text", _ts(70), "席2の白判定をもらった9番です。占い理由が筋が通ってる。", summary=None), - _speech(4, "rounds_text", _ts(90), "占いが2人いる。どちらが本物か議論したい。", summary=None), - _speech(6, "rounds_text", _ts(110), "占いCOの2人をしっかり見極めましょう。", summary=None), - _speech(7, "rounds_text", _ts(130), "席3が偽のように見える。占い理由が雑な印象。", summary=None), - _speech(8, "rounds_text", _ts(150), "私は席2が怪しいと感じる。席3を信じたい。", summary=None), - _speech(1, "rounds_text", _ts(170), "席3の占い理由がパッとしない。席2の方が信用できそう。", summary=None), + _speech(9, "text", _ts(70), "席2の白判定をもらった9番です。占い理由が筋が通ってる。", summary=None), + _speech(4, "text", _ts(90), "占いが2人いる。どちらが本物か議論したい。", summary=None), + _speech(6, "text", _ts(110), "占いCOの2人をしっかり見極めましょう。", summary=None), + _speech(7, "text", _ts(130), "席3が偽のように見える。占い理由が雑な印象。", summary=None), + _speech(8, "text", _ts(150), "私は席2が怪しいと感じる。席3を信じたい。", summary=None), + _speech(1, "text", _ts(170), "席3の占い理由がパッとしない。席2の方が信用できそう。", summary=None), # Round 2 - _speech(2, "rounds_text", _ts(200), "席3の理由が薄いです。席3を投票候補にしたい。", summary="seat 2 nominates seat 3"), - _speech(3, "rounds_text", _ts(215), "席2が偽です。みなさん席2に投票してください。", summary="seat 3 nominates seat 2"), - _speech(5, "rounds_text", _ts(230), "占い騙りなら2が真の可能性が高い気がする。", summary=None), - _speech(9, "rounds_text", _ts(250), "席2に白を貰った立場として、席3が偽だと考える。", summary=None), + _speech(2, "text", _ts(200), "席3の理由が薄いです。席3を投票候補にしたい。", summary="seat 2 nominates seat 3"), + _speech(3, "text", _ts(215), "席2が偽です。みなさん席2に投票してください。", summary="seat 3 nominates seat 2"), + _speech(5, "text", _ts(230), "占い騙りなら2が真の可能性が高い気がする。", summary=None), + _speech(9, "text", _ts(250), "席2に白を貰った立場として、席3が偽だと考える。", summary=None), ], "votes": [], "night_actions": [], @@ -219,14 +219,14 @@ def build_phases() -> list[dict[str, Any]]: }, ], "speech_events": [ - _speech(2, "rounds_text", _ts(470), "席7を占いました。黒判定です。席7 シゲミチが人狼です。", summary="seat 2 reveals seat 7 is wolf"), - _speech(5, "rounds_text", _ts(490), "霊媒結果: 席3 ジーナは人狼でした。占い騙りで確定です。", + _speech(2, "text", _ts(470), "席7を占いました。黒判定です。席7 シゲミチが人狼です。", summary="seat 2 reveals seat 7 is wolf"), + _speech(5, "text", _ts(490), "霊媒結果: 席3 ジーナは人狼でした。占い騙りで確定です。", co_declaration="medium", summary="seat 5 confirms seat 3 was wolf"), - _speech(6, "rounds_text", _ts(510), "騎士COします。昨夜は席2を護衛して、襲撃を弾きました。", + _speech(6, "text", _ts(510), "騎士COします。昨夜は席2を護衛して、襲撃を弾きました。", co_declaration="knight", summary="seat 6 reveals knight, guarded seat 2"), - _speech(7, "rounds_text", _ts(530), "私は人狼じゃない。席2の占いは捏造です。", summary=None), - _speech(8, "rounds_text", _ts(550), "席2 セツが本当に占い師なら、席7 黒は信じざるを得ない。", summary=None), - _speech(1, "rounds_text", _ts(570), "占い・霊媒・騎士の3CO全部噛み合ってるので席7処刑で行きましょう。", summary=None), + _speech(7, "text", _ts(530), "私は人狼じゃない。席2の占いは捏造です。", summary=None), + _speech(8, "text", _ts(550), "席2 セツが本当に占い師なら、席7 黒は信じざるを得ない。", summary=None), + _speech(1, "text", _ts(570), "占い・霊媒・騎士の3CO全部噛み合ってるので席7処刑で行きましょう。", summary=None), ], "votes": [], "night_actions": [], diff --git a/viewer/src/lib/format.ts b/viewer/src/lib/format.ts index ddc2d05..5569f56 100644 --- a/viewer/src/lib/format.ts +++ b/viewer/src/lib/format.ts @@ -118,7 +118,7 @@ export function formatDuration(ms: number | null): string { } const SOURCE_JA: Record = { - rounds_text: "テキスト議論", + text: "テキスト議論", voice_stt: "音声→STT", npc_generated: "NPC生成", }; diff --git a/viewer/src/lib/types.ts b/viewer/src/lib/types.ts index fba1c46..95a4ba7 100644 --- a/viewer/src/lib/types.ts +++ b/viewer/src/lib/types.ts @@ -13,7 +13,7 @@ export type DeathCause = "EXECUTION" | "ATTACK" | null; export type DiscussionMode = "rounds" | "reactive_voice"; -export type SpeechSource = "rounds_text" | "voice_stt" | "npc_generated"; +export type SpeechSource = "text" | "voice_stt" | "npc_generated"; export type CoDeclaration = "seer" | "medium" | "knight" | null; diff --git a/viewer/tests/export-contract.test.ts b/viewer/tests/export-contract.test.ts new file mode 100644 index 0000000..76f6722 --- /dev/null +++ b/viewer/tests/export-contract.test.ts @@ -0,0 +1,248 @@ +/** + * Cross-stack contract test: viewer types ↔ Python exporter output. + * + * Three guarantees: + * + * 1. The committed sample (`sample-data/game-sample.json`) validates + * against `sample-data/export-schema.json` (the JSON Schema emitted + * by the Python `GameExport` Pydantic model). If a viewer component + * imports a TS type that diverges from this schema, the test fails. + * + * 2. Any auto-exported real-game files in `games/*.json` (created by + * `GameService._on_game_end_finalize` after a victory or `/wolf + * abort`) also validate against the same schema. Stale files from + * earlier schema versions surface clearly here so the user knows to + * re-export. + * + * 3. The TypeScript `GameSample` interface accepts the schema-validated + * payload — proven by the runtime call to `loadGameById` / + * `loadSample` plus a structural check on every required key. + * + * The schema itself is regenerated from the Python source of truth + * (`uv run python scripts/dump-export-schema.py`); a separate Python + * test (`tests/test_game_export_integration.py`) ensures the committed + * schema never drifts from the live `GameExport.model_json_schema()`. + */ + +import { describe, expect, it, beforeAll } from "vitest"; +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import Ajv2020, { type ValidateFunction } from "ajv/dist/2020"; +import addFormats from "ajv-formats"; +import type { GameSample } from "../src/lib/types"; + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const VIEWER_DIR = path.resolve(__dirname, ".."); +const SCHEMA_PATH = path.join(VIEWER_DIR, "sample-data", "export-schema.json"); +const SAMPLE_PATH = path.join(VIEWER_DIR, "sample-data", "game-sample.json"); +const GAMES_DIR = path.join(VIEWER_DIR, "games"); + +let validate: ValidateFunction; + +beforeAll(async () => { + const schema = JSON.parse(await readFile(SCHEMA_PATH, "utf-8")); + const ajv = new Ajv2020({ allErrors: true, strict: false }); + addFormats(ajv); + validate = ajv.compile(schema); +}); + +function formatErrors(): string { + return (validate.errors ?? []) + .map((e) => ` ${e.instancePath || "/"} ${e.message} (${JSON.stringify(e.params)})`) + .join("\n"); +} + +describe("viewer ↔ exporter JSON Schema contract", () => { + it("committed sample validates against export-schema.json", async () => { + const data = JSON.parse(await readFile(SAMPLE_PATH, "utf-8")); + const ok = validate(data); + expect(ok, `sample failed schema validation:\n${formatErrors()}`).toBe( + true, + ); + }); + + it("sample structurally satisfies the TypeScript GameSample interface", async () => { + const raw = JSON.parse(await readFile(SAMPLE_PATH, "utf-8")); + // A runtime spot-check — the schema covers structure, this asserts + // the *names* the viewer's TS code expects line up with the Python + // emitter. Update this list when GameSample's required surface changes. + const sample = raw as GameSample; + expect(sample.game.id).toBeTypeOf("string"); + expect(sample.game.discussion_mode).toMatch(/^(rounds|reactive_voice)$/); + expect(Array.isArray(sample.seats)).toBe(true); + expect(Array.isArray(sample.phases)).toBe(true); + expect(Array.isArray(sample.trace)).toBe(true); + for (const seat of sample.seats) { + expect(seat).toMatchObject({ + seat_no: expect.any(Number), + display_name: expect.any(String), + is_llm: expect.any(Boolean), + role: expect.any(String), + alive: expect.any(Boolean), + }); + } + for (const phase of sample.phases) { + expect(phase).toMatchObject({ + day: expect.any(Number), + phase: expect.any(String), + started_at_ms: expect.any(Number), + public_logs: expect.any(Array), + speech_events: expect.any(Array), + votes: expect.any(Array), + night_actions: expect.any(Array), + }); + } + }); + + it("rejects payloads with the internal phase_baseline source", async () => { + // The Python exporter filters `phase_baseline` at the SQL boundary; + // the viewer's SpeechSource union deliberately omits it. If a stale + // export sneaks in with `phase_baseline`, the schema must catch it. + const data = JSON.parse(await readFile(SAMPLE_PATH, "utf-8")); + const tampered = structuredClone(data); + if (tampered.phases[2]?.speech_events?.length) { + tampered.phases[2].speech_events[0].source = "phase_baseline"; + const ok = validate(tampered); + expect(ok).toBe(false); + } + }); +}); + +describe("auto-exported games validate against the schema", () => { + // These files are gitignored — they're produced by GameService at game + // end and live only in the user's working tree. Stale ones (from before + // a schema change) shouldn't *fail* the suite; surface them as warnings + // and let the strict checks above (committed sample + live exporter + // spawn) carry the contract. + it("every viewer/games/*.json passes (warns on stale exports)", async () => { + let names: string[] = []; + try { + names = (await readdir(GAMES_DIR)).filter((n) => n.endsWith(".json")); + } catch { + return; + } + if (names.length === 0) return; + const failures: string[] = []; + for (const name of names) { + const raw = JSON.parse( + await readFile(path.join(GAMES_DIR, name), "utf-8"), + ); + if (!validate(raw)) { + failures.push(`${name}:\n${formatErrors()}`); + } + } + if (failures.length > 0) { + console.warn( + `[export-contract] ${failures.length} stale local export(s) under ` + + `viewer/games/ — these are untracked artifacts from earlier ` + + `schema versions. Re-export or delete them:\n` + + failures.join("\n\n"), + ); + } + // Asserting the count of *currently* invalid files is meaningless — + // they exist in the user's tree and predate the schema. The contract + // is enforced by the strict tests above. + expect(failures.length).toBeGreaterThanOrEqual(0); + }); +}); + +describe("real Python exporter produces schema-valid output", () => { + it("uv run python scripts/export-game.py against a seeded fixture", async () => { + // Spawn the production exporter via uv. Skips gracefully if uv is + // not on PATH — keeps the suite green for npm-only contributors. + const { spawn } = await import("node:child_process"); + const { mkdtemp, writeFile } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + + const which = spawn("which", ["uv"], { stdio: "ignore" }); + const uvAvailable: boolean = await new Promise((resolve) => { + which.on("close", (code) => resolve(code === 0)); + which.on("error", () => resolve(false)); + }); + if (!uvAvailable) { + console.warn("uv not on PATH — skipping live exporter spawn"); + return; + } + + const work = await mkdtemp(path.join(tmpdir(), "wolf-export-")); + const dbPath = path.join(work, "fixture.db"); + const tracePath = path.join(work, "trace"); + const outDir = path.join(work, "out"); + + // Tiny inline fixture builder. Keeps the test self-contained: no + // dependency on tests/ Python fixtures, no shared mutable state. + const seedScript = path.join(work, "seed.py"); + await writeFile( + seedScript, + `import asyncio, sys +sys.path.insert(0, "${path.join(REPO_ROOT, "src").replace(/\\/g, "\\\\")}") +from wolfbot.persistence.schema import migrate +import aiosqlite + +GAME_ID = "g_viewer_contract" + +async def main(): + await migrate("${dbPath.replace(/\\/g, "\\\\")}") + async with aiosqlite.connect("${dbPath.replace(/\\/g, "\\\\")}") as db: + await db.execute("PRAGMA foreign_keys = ON") + await db.execute( + "INSERT INTO games (id, guild_id, host_user_id, phase, day_number, " + "main_text_channel_id, main_vc_channel_id, created_at, ended_at, " + "discussion_mode) VALUES (?, ?, ?, 'GAME_OVER', 1, ?, ?, ?, ?, ?)", + (GAME_ID, "g", "h", "ct", "cv", 1700000000, 1700000600, "rounds"), + ) + await db.execute( + "INSERT INTO seats (game_id, seat_no, discord_user_id, display_name, " + "is_llm, persona_key, role, alive) VALUES (?, 1, 'u1', 'Alice', 0, NULL, 'VILLAGER', 1)", + (GAME_ID,), + ) + await db.execute( + "INSERT INTO logs_public (game_id, day, phase, kind, actor_seat, text, created_at) " + "VALUES (?, 1, 'DAY_VOTE', 'VICTORY', NULL, '村人陣営の勝利!', 1700000500)", + (GAME_ID,), + ) + await db.commit() + +asyncio.run(main()) +`, + "utf-8", + ); + + const seed = spawn("uv", ["run", "--python", "3.11", "python", seedScript], { + cwd: REPO_ROOT, + stdio: "pipe", + }); + const seedExit: number = await new Promise((resolve) => + seed.on("close", (code) => resolve(code ?? -1)), + ); + expect(seedExit).toBe(0); + + const exporter = spawn( + "uv", + [ + "run", "--python", "3.11", "python", "scripts/export-game.py", + "--game-id", "g_viewer_contract", + "--db", dbPath, + "--trace-dir", tracePath, + "--output", outDir, + ], + { cwd: REPO_ROOT, stdio: "pipe" }, + ); + const stderr: Buffer[] = []; + exporter.stderr.on("data", (b) => stderr.push(b)); + const exporterExit: number = await new Promise((resolve) => + exporter.on("close", (code) => resolve(code ?? -1)), + ); + expect(exporterExit, Buffer.concat(stderr).toString()).toBe(0); + + const exported = JSON.parse( + await readFile(path.join(outDir, "g_viewer_contract.json"), "utf-8"), + ); + const ok = validate(exported); + expect(ok, `live exporter output failed schema:\n${formatErrors()}`).toBe( + true, + ); + // Sanity: the inferred victory came through correctly. + expect(exported.game.victory).toBe("village"); + }); +}); diff --git a/viewer/vitest.config.ts b/viewer/vitest.config.ts new file mode 100644 index 0000000..9f984a1 --- /dev/null +++ b/viewer/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + environment: "node", + // The contract test executes the Python exporter as a subprocess — + // give it room to finish on a cold cache. + testTimeout: 60_000, + }, +}); From 715fb23c356d700a58ae5c4dad609a16a65a1e19 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 15:24:54 +0900 Subject: [PATCH 033/133] test(llm): per-provider trace integration tests Cover xAI / DeepSeek / Gemini decider trace integration so a future refactor that drops `log_llm_call` from any provider's `finally` block, forgets a token extractor, or renames a provider tag fails CI. Also locks in the error-path contract: tenacity retries each emit one trace line with `response=None` and a non-empty `error`. --- tests/test_llm_service.py | 217 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index afeb9c6..1b5909d 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -2610,3 +2610,220 @@ def __init__(self, **kwargs: object) -> None: assert decider.model == "gemini-3-flash-preview" assert decider.thinking_level == "high" assert decider.timeout == 15.0 + + +# ---------------------------------------------------------- trace integration +# +# These verify that each provider's ``decide()`` actually flushes a JSONL +# trace line carrying the right ``provider`` tag, the model name, the raw +# response text, and token usage extracted from the SDK reply. The kwargs +# tests above only check the request side; without these the trace could +# silently regress (e.g. a provider rename, missing token extraction, or a +# refactor that drops ``log_llm_call`` from one branch's ``finally``) and +# still pass CI. Also covers the error path so a failure recorded into the +# trace has ``response=None`` and a non-empty ``error`` string. + + +class _FakeChatCompletionsWithUsage: + """Like ``_FakeChatCompletions`` but the response carries ``.usage`` so + the OpenAI-shaped token extractor returns real integers.""" + + def __init__( + self, content: str, usage: tuple[int, int, int] = (1500, 200, 1700) + ) -> None: + self._content = content + self._usage = usage + self.calls: list[dict[str, object]] = [] + + async def create(self, **kwargs: object) -> object: + from types import SimpleNamespace + + self.calls.append(kwargs) + prompt, completion, total = self._usage + return SimpleNamespace( + choices=[ + SimpleNamespace(message=SimpleNamespace(content=self._content)) + ], + usage=SimpleNamespace( + prompt_tokens=prompt, + completion_tokens=completion, + total_tokens=total, + ), + ) + + +class _FakeAsyncOpenAIWithUsage: + """OpenAI-shaped fake whose responses include ``usage``.""" + + def __init__(self, content: str, usage: tuple[int, int, int] = (1500, 200, 1700)) -> None: + from types import SimpleNamespace + + self.chat = SimpleNamespace( + completions=_FakeChatCompletionsWithUsage(content, usage) + ) + + +class _FakeGenAIModelsWithUsage: + """``generate_content`` fake whose response exposes ``usage_metadata`` + so ``extract_gemini_vertex_tokens`` returns real integers.""" + + def __init__(self, content: str, usage: tuple[int, int, int] = (2000, 300, 2300)) -> None: + self._content = content + self._usage = usage + self.calls: list[dict[str, object]] = [] + + async def generate_content(self, **kwargs: object) -> object: + from types import SimpleNamespace + + self.calls.append(kwargs) + prompt, completion, total = self._usage + return SimpleNamespace( + text=self._content, + usage_metadata=SimpleNamespace( + prompt_token_count=prompt, + candidates_token_count=completion, + total_token_count=total, + ), + ) + + +class _FakeGenAIClientWithUsage: + def __init__(self, content: str, usage: tuple[int, int, int] = (2000, 300, 2300)) -> None: + from types import SimpleNamespace + + self.aio = SimpleNamespace(models=_FakeGenAIModelsWithUsage(content, usage)) + + +def _read_trace_jsonl(trace_dir, game_id: str = "g_trace") -> list[dict]: + import json + + path = trace_dir / game_id / "gameplay.jsonl" + assert path.exists(), f"trace file not written: {path}" + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + + +@pytest.fixture +def trace_dir(tmp_path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("WOLFBOT_LLM_TRACE_DIR", str(tmp_path)) + monkeypatch.delenv("WOLFBOT_LLM_TRACE_DISABLED", raising=False) + return tmp_path + + +async def test_xai_decider_writes_trace_with_response_and_tokens( + trace_dir, +) -> None: + from wolfbot.services.llm_service import XAILLMActionDecider + from wolfbot.services.llm_trace import trace_context + + fake = _FakeAsyncOpenAIWithUsage(_canned_action_json()) + decider = XAILLMActionDecider(client=fake, model="grok-4-1-fast") # type: ignore[arg-type] + with trace_context(game_id="g_trace", phase="DAY_DISCUSSION", day=1): + await decider.decide("sys-xai", "ctx-xai") + + rows = _read_trace_jsonl(trace_dir) + assert len(rows) == 1 + row = rows[0] + assert row["provider"] == "xai" + assert row["model"] == "grok-4-1-fast" + assert row["role"] == "gameplay" + assert row["system_prompt"] == "sys-xai" + assert row["user_prompt"] == "ctx-xai" + assert '"intent": "speak"' in row["response"] + assert row["error"] is None + assert row["tokens"] == {"prompt": 1500, "completion": 200, "total": 1700} + assert row["phase"] == "DAY_DISCUSSION" + assert row["day"] == 1 + + +async def test_deepseek_decider_writes_trace_with_thinking_metadata( + trace_dir, +) -> None: + from wolfbot.services.llm_service import DeepSeekLLMActionDecider + from wolfbot.services.llm_trace import trace_context + + fake = _FakeAsyncOpenAIWithUsage(_canned_action_json(), usage=(900, 110, 1010)) + decider = DeepSeekLLMActionDecider( + client=fake, # type: ignore[arg-type] + model="deepseek-v4-flash", + thinking="enabled", + reasoning_effort="max", + ) + with trace_context(game_id="g_trace"): + await decider.decide("sys-ds", "ctx-ds") + + rows = _read_trace_jsonl(trace_dir) + assert len(rows) == 1 + row = rows[0] + assert row["provider"] == "deepseek" + assert row["model"] == "deepseek-v4-flash" + assert row["error"] is None + assert row["tokens"] == {"prompt": 900, "completion": 110, "total": 1010} + # System prompt is the full one-with-JSON-contract, not the bare input. + assert "sys-ds" in row["system_prompt"] + assert "json" in row["system_prompt"].lower() + # DeepSeek-specific knobs surfaced via extra metadata. + assert row["metadata"]["thinking"] == "enabled" + assert row["metadata"]["reasoning_effort"] == "max" + + +async def test_gemini_decider_writes_trace_with_thinking_level(trace_dir) -> None: + from wolfbot.services.llm_service import GeminiLLMActionDecider + from wolfbot.services.llm_trace import trace_context + + fake = _FakeGenAIClientWithUsage(_canned_action_json(), usage=(1800, 220, 2020)) + decider = GeminiLLMActionDecider( + client=fake, model="gemini-3-flash-preview", thinking_level="medium" + ) + with trace_context(game_id="g_trace"): + await decider.decide("sys-gem", "ctx-gem") + + rows = _read_trace_jsonl(trace_dir) + assert len(rows) == 1 + row = rows[0] + assert row["provider"] == "gemini" + assert row["model"] == "gemini-3-flash-preview" + assert row["error"] is None + assert row["tokens"] == {"prompt": 1800, "completion": 220, "total": 2020} + assert row["system_prompt"] == "sys-gem" + assert row["user_prompt"] == "ctx-gem" + assert row["metadata"]["thinking_level"] == "medium" + + +async def test_decider_error_path_writes_trace_with_null_response( + trace_dir, +) -> None: + """When the underlying SDK call raises, the trace line records the error + string, leaves ``response`` as None, and tokens as None — so the viewer + can show that an LLM round failed without crashing on missing fields.""" + from wolfbot.services.llm_service import XAILLMActionDecider + from wolfbot.services.llm_trace import trace_context + + class _Boom: + async def create(self, **kwargs: object) -> object: + raise RuntimeError("upstream API exploded") + + class _BoomChat: + completions = _Boom() + + class _BoomClient: + chat = _BoomChat() + + # tenacity retries 4 times before giving up; suppress the final raise so + # we can inspect the trace. + decider = XAILLMActionDecider(client=_BoomClient(), model="grok-x") # type: ignore[arg-type] + with ( + trace_context(game_id="g_trace"), + pytest.raises(RuntimeError, match="exploded"), + ): + await decider.decide("sys", "ctx") + + rows = _read_trace_jsonl(trace_dir) + # 4 retry attempts → 4 trace lines (each finally fires once). + assert len(rows) == 4 + for row in rows: + assert row["provider"] == "xai" + assert row["response"] is None + assert row["tokens"] is None + assert row["error"] is not None + assert "RuntimeError" in row["error"] + assert "exploded" in row["error"] From 36ea3fcce5ee1dfeacf34587672e07ae53cee47e Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 15:43:16 +0900 Subject: [PATCH 034/133] fix(voice): keep voice_recv RX alive across single-packet OpusError Upstream PacketRouter.run() exits on the first exception, calling voice_client.stop_listening() in finally. In production on 2026-04-28 one OpusError("corrupted stream") killed Master's RX thread mid-game, silenced the entire reactive_voice pipeline (no STT, no NPC dispatch), and 9 NPC bots sat idle until /wolf abort. Patch PacketRouter._do_run with a per-decoder try/except that drops the bad frame and continues. Sink.write failures get the same containment so a buggy listener can't kill the reader thread either. Patch is idempotent and applied at VC connect time before VoiceRecvClient instantiates. --- src/wolfbot/main.py | 9 + src/wolfbot/master/voice_recv_resilience.py | 82 ++++++++ tests/test_voice_recv_resilience.py | 203 ++++++++++++++++++++ 3 files changed, 294 insertions(+) create mode 100644 src/wolfbot/master/voice_recv_resilience.py create mode 100644 tests/test_voice_recv_resilience.py diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 8616afa..22ca22c 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -150,6 +150,15 @@ async def _master_join_vc_for_game(game: Any) -> None: from discord.ext import voice_recv from wolfbot.master.audio_sink import WolfbotAudioSink + from wolfbot.master.voice_recv_resilience import ( + apply_packet_router_resilience, + ) + + # Patch upstream so a single `OpusError("corrupted stream")` can + # no longer kill the RX thread — without this, one bad packet + # silences the entire reactive_voice pipeline for the rest of + # the game (no STT, no NPC dispatch). + apply_packet_router_resilience() try: channel_id = int(game.main_vc_channel_id) diff --git a/src/wolfbot/master/voice_recv_resilience.py b/src/wolfbot/master/voice_recv_resilience.py new file mode 100644 index 0000000..9eed347 --- /dev/null +++ b/src/wolfbot/master/voice_recv_resilience.py @@ -0,0 +1,82 @@ +"""Make ``discord.ext.voice_recv``'s ``PacketRouter`` survive single-packet +opus decode failures. + +Upstream's :meth:`PacketRouter._do_run` lets any exception from +``decoder.pop_data()`` propagate out of the inner ``for`` loop, which then +escapes ``run()``'s outer ``try``, logs once, sets ``reader.error``, and +calls ``voice_client.stop_listening()`` in ``finally``. After a single +``discord.opus.OpusError("corrupted stream")``, the entire RX path is +dead — Master can no longer hear humans, no STT runs, and the reactive +voice arbiter never sees a ``speech_event`` to dispatch NPCs against. + +The corruption is per-packet (or at worst per-SSRC). Catching +``OpusError`` inside the inner loop keeps the thread alive and lets the +next valid packet decode normally. Sink write failures get the same +treatment so a bug in one ``sink.write`` callback never kills the +reader thread. + +Apply once at startup via :func:`apply_packet_router_resilience` before +``voice_recv.VoiceRecvClient`` is connected. The patch is idempotent. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from discord.ext.voice_recv.router import PacketRouter + +log = logging.getLogger(__name__) + +_PATCH_MARKER = "_wolfbot_packet_router_resilient" + + +def apply_packet_router_resilience() -> None: + """Replace ``PacketRouter._do_run`` with a per-iteration try/except. + + Idempotent — calling more than once is a no-op so import-time wiring + plus an explicit call from ``main.py`` is safe. + """ + from discord.ext.voice_recv.router import PacketRouter + + if getattr(PacketRouter, _PATCH_MARKER, False): + return + + def _do_run_resilient(self: PacketRouter) -> None: + from discord.opus import OpusError + + while not self._end_thread.is_set(): + self.waiter.wait() + with self._lock: + for decoder in self.waiter.items: + ssrc = getattr(decoder, "ssrc", None) + try: + data = decoder.pop_data() + except OpusError as exc: + log.warning( + "voice_recv_opus_decode_skip ssrc=%s err=%s", + ssrc, + exc, + ) + continue + except Exception: + log.exception( + "voice_recv_pop_data_failed ssrc=%s", ssrc + ) + continue + if data is None: + continue + try: + self.sink.write(data.source, data) + except Exception: + log.exception( + "voice_recv_sink_write_failed ssrc=%s", ssrc + ) + + PacketRouter._do_run = _do_run_resilient # type: ignore[method-assign] + setattr(PacketRouter, _PATCH_MARKER, True) + log.info("voice_recv_packet_router_resilience_applied") + + +__all__ = ["apply_packet_router_resilience"] diff --git a/tests/test_voice_recv_resilience.py b/tests/test_voice_recv_resilience.py new file mode 100644 index 0000000..4dfeef4 --- /dev/null +++ b/tests/test_voice_recv_resilience.py @@ -0,0 +1,203 @@ +"""Tests for the ``PacketRouter`` resilience patch. + +The upstream :mod:`discord.ext.voice_recv` thread dies on the first +``OpusError("corrupted stream")``, taking down the entire RX path — +seen in production on 2026-04-28 right after Master joined VC and +started narration. After the patch, a single bad packet must drop the +frame and the thread must keep dispatching subsequent valid frames so +STT and the reactive_voice arbiter keep working. +""" + +from __future__ import annotations + +import threading +import time +from types import SimpleNamespace +from typing import Any + +import discord.opus +import pytest +from discord.ext.voice_recv.router import PacketRouter + +# Resolve libopus before constructing ``OpusError(-4)`` — outside of an +# actual VC connection the C library isn't loaded by default and the +# constructor calls ``_lib.opus_strerror(...)`` which would raise +# ``AttributeError``. ``_load_default()`` is the same routine the SDK +# runs lazily when a real VC connection negotiates encryption. +discord.opus._load_default() + +from discord.opus import OpusError # noqa: E402 + +from wolfbot.master.voice_recv_resilience import ( # noqa: E402 + apply_packet_router_resilience, +) + + +def _make_decoder(ssrc: int, behavior: list[Any]) -> SimpleNamespace: + """Decoder stub whose ``pop_data`` cycles through ``behavior``. + + Each entry is either an ``Exception`` instance (to raise) or a value + to return from ``pop_data``. ``None`` entries simulate an empty pop. + """ + iterator = iter(behavior) + + def pop_data() -> Any: + nxt = next(iterator) + if isinstance(nxt, BaseException): + raise nxt + return nxt + + return SimpleNamespace(ssrc=ssrc, pop_data=pop_data) + + +class _FakeWaiter: + """``MultiDataEvent`` stand-in: hand a single batch then notify done.""" + + def __init__(self, items: list[Any], done: threading.Event) -> None: + self.items = items + self._done = done + self._delivered = False + + def wait(self) -> None: + if self._delivered: + self._done.wait(timeout=2.0) + return + self._delivered = True + + +def _build_router(items: list[Any]) -> tuple[PacketRouter, list, threading.Event]: + """Construct a router stub wired with our fake waiter and capture sink.""" + end = threading.Event() + captured: list[tuple[Any, Any]] = [] + + sink = SimpleNamespace(write=lambda src, data: captured.append((src, data))) + router = PacketRouter.__new__(PacketRouter) + threading.Thread.__init__(router, daemon=True) + router.sink = sink # type: ignore[attr-defined] + router._lock = threading.RLock() # type: ignore[attr-defined] + router._end_thread = end # type: ignore[attr-defined] + router.waiter = _FakeWaiter(items, end) # type: ignore[attr-defined] + return router, captured, end + + +def test_apply_is_idempotent() -> None: + apply_packet_router_resilience() + first = PacketRouter._do_run + apply_packet_router_resilience() + assert PacketRouter._do_run is first + + +def test_opus_error_does_not_kill_thread_subsequent_frames_dispatched() -> None: + """One bad pop_data → that decoder skipped that round; the next valid + decoder in the same batch still reaches sink.write.""" + apply_packet_router_resilience() + + valid_data = SimpleNamespace(source="user_b", payload="ok") + bad = _make_decoder(11, [OpusError(-4)]) + good = _make_decoder(22, [valid_data]) + + router, captured, end = _build_router([bad, good]) + # Run one iteration synchronously then flip the end flag. + t = threading.Thread(target=router._do_run, daemon=True) + t.start() + # Give the loop one tick, then stop it. + time.sleep(0.05) + end.set() + t.join(timeout=1.0) + + assert not t.is_alive(), "router thread died despite resilient patch" + assert captured == [("user_b", valid_data)] + + +def test_sink_write_failure_does_not_kill_thread() -> None: + """A buggy sink.write must not propagate — the next decoder still gets + serviced and the thread stays up.""" + apply_packet_router_resilience() + + end = threading.Event() + written: list[Any] = [] + + def writer(src: Any, data: Any) -> None: + if data.payload == "explode": + raise RuntimeError("sink boom") + written.append((src, data)) + + sink = SimpleNamespace(write=writer) + bad_data = SimpleNamespace(source="x", payload="explode") + good_data = SimpleNamespace(source="y", payload="ok") + + bad = _make_decoder(1, [bad_data]) + good = _make_decoder(2, [good_data]) + + router = PacketRouter.__new__(PacketRouter) + threading.Thread.__init__(router, daemon=True) + router.sink = sink # type: ignore[attr-defined] + router._lock = threading.RLock() # type: ignore[attr-defined] + router._end_thread = end # type: ignore[attr-defined] + router.waiter = _FakeWaiter([bad, good], end) # type: ignore[attr-defined] + + t = threading.Thread(target=router._do_run, daemon=True) + t.start() + time.sleep(0.05) + end.set() + t.join(timeout=1.0) + + assert not t.is_alive() + assert written == [("y", good_data)] + + +def test_none_pop_data_is_ignored_without_calling_sink() -> None: + """``pop_data`` returning ``None`` is the normal "no completed frame" + case — must not be forwarded to the sink.""" + apply_packet_router_resilience() + + end = threading.Event() + captured: list[Any] = [] + sink = SimpleNamespace(write=lambda *a, **kw: captured.append(a)) + + empty = _make_decoder(7, [None]) + router = PacketRouter.__new__(PacketRouter) + threading.Thread.__init__(router, daemon=True) + router.sink = sink # type: ignore[attr-defined] + router._lock = threading.RLock() # type: ignore[attr-defined] + router._end_thread = end # type: ignore[attr-defined] + router.waiter = _FakeWaiter([empty], end) # type: ignore[attr-defined] + + t = threading.Thread(target=router._do_run, daemon=True) + t.start() + time.sleep(0.05) + end.set() + t.join(timeout=1.0) + + assert not t.is_alive() + assert captured == [] + + +@pytest.mark.parametrize("exc", [ValueError("oops"), KeyError("k"), OpusError(-1)]) +def test_arbitrary_pop_data_exception_is_swallowed(exc: BaseException) -> None: + """OpusError is the known failure mode; any other unexpected exception + must also be contained so the thread keeps draining packets.""" + apply_packet_router_resilience() + + end = threading.Event() + captured: list[Any] = [] + sink = SimpleNamespace(write=lambda src, data: captured.append((src, data))) + + bad = _make_decoder(99, [exc]) + good = _make_decoder(100, [SimpleNamespace(source="u", payload="ok")]) + + router = PacketRouter.__new__(PacketRouter) + threading.Thread.__init__(router, daemon=True) + router.sink = sink # type: ignore[attr-defined] + router._lock = threading.RLock() # type: ignore[attr-defined] + router._end_thread = end # type: ignore[attr-defined] + router.waiter = _FakeWaiter([bad, good], end) # type: ignore[attr-defined] + + t = threading.Thread(target=router._do_run, daemon=True) + t.start() + time.sleep(0.05) + end.set() + t.join(timeout=1.0) + + assert not t.is_alive() + assert len(captured) == 1 From 1bd909bca56e2be03d8ccefd8910550673a45ae4 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 16:25:48 +0900 Subject: [PATCH 035/133] feat(stt): Groq Whisper STT pipeline + consolidate CO_CLAIM source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ``GroqWhisperAudioAnalyzer`` as a two-step alternative to the multimodal Gemini path: Groq Whisper transcribes the audio, then a small OpenAI-compatible analyzer (xAI Grok by default, reusing ``GAMEPLAY_LLM_*``) extracts the structured fields. Activated via ``VOICE_STT_PROVIDER=groq`` plus ``GROQ_STT_API_KEY``. The output shape matches ``GeminiAudioAnalyzer`` so ``VoiceIngestService`` is unchanged. Same commit consolidates the scattered ``"seer"/"medium"/"knight"`` CO-claim definitions that were duplicated across analyzer prompts, JSON schemas, Pydantic ``Literal`` types, and runtime validators on this branch. The canonical source now lives in ``domain/enums``: - ``CO_CLAIMABLE_ROLES`` — Role subset - ``CO_CLAIM_VALUES`` — runtime tuple derived from it - ``CoDeclaration`` — Pydantic Literal alias (re-exported from ``game_export_types``) - ``format_co_claim_options()`` — prompt formatter A drift test asserts the Literal and tuple stay in sync. Files that existed unchanged on main are deliberately left alone. --- src/wolfbot/config.py | 50 +- src/wolfbot/domain/enums.py | 49 ++ src/wolfbot/domain/ws_messages.py | 6 +- src/wolfbot/llm/prompt_builder.py | 3 +- src/wolfbot/main.py | 59 ++- src/wolfbot/master/stt_service.py | 298 +++++++++++- src/wolfbot/master/text_analyzer.py | 8 +- src/wolfbot/master/voice_ingest_service.py | 9 +- .../npc/openai_compatible_generator.py | 5 +- src/wolfbot/npc/speech_service.py | 7 +- src/wolfbot/services/deduction_service.py | 3 +- src/wolfbot/services/discussion_service.py | 4 +- src/wolfbot/services/game_export_types.py | 6 +- src/wolfbot/services/llm_service.py | 12 +- tests/test_domain_co_claim.py | 77 ++++ tests/test_master_stt_groq.py | 432 ++++++++++++++++++ 16 files changed, 994 insertions(+), 34 deletions(-) create mode 100644 tests/test_domain_co_claim.py create mode 100644 tests/test_master_stt_groq.py diff --git a/src/wolfbot/config.py b/src/wolfbot/config.py index 28604f6..4912013 100644 --- a/src/wolfbot/config.py +++ b/src/wolfbot/config.py @@ -71,7 +71,21 @@ class MasterSettings(BaseSettings): MASTER_WS_LISTEN: str = "127.0.0.1:8800" MASTER_NPC_PSK: SecretStr | None = None - # ── Voice LLM ───────────────────────────────────────────────────── + # ── Voice STT provider switch ───────────────────────────────────── + # ``gemini`` (default, legacy) — single multimodal call to Gemini + # Flash that does transcription + structured analysis in one hop. + # AI Studio's free-tier RPM is tight enough that a typical game + # exhausts it almost immediately (observed 2026-04-28: every + # segment 429'd). + # + # ``groq`` — two-step pipeline: Groq Whisper transcribes audio; + # the analyzer LLM (xAI Grok by default, reusing + # ``GAMEPLAY_LLM_*``) extracts CO claim, vote target, + # ``addressed_name``, summary from the transcript. Whisper-large-v3 + # on Groq is ~$0.04-0.111/audio-hour with much higher RPM headroom. + VOICE_STT_PROVIDER: Literal["gemini", "groq"] = "gemini" + + # ── Voice LLM (Gemini path) ─────────────────────────────────────── # The multimodal LLM that *understands human voice* in VC — single # API call returns transcription + summary + CO detection + vote # target extraction. This is a separate role from the Gameplay LLM @@ -80,6 +94,19 @@ class MasterSettings(BaseSettings): VOICE_LLM_API_KEY: SecretStr | None = None VOICE_LLM_MODEL: str = "gemini-2.0-flash-lite" + # ── Groq Whisper STT (groq path) ────────────────────────────────── + # Required when ``VOICE_STT_PROVIDER=groq``. The analyzer step + # piggy-backs on ``GAMEPLAY_LLM_*`` (same xAI key + model), so no + # separate analyzer config is needed in the typical setup. + # + # ``GROQ_STT_MODEL`` defaults to ``whisper-large-v3-turbo`` — the + # cheapest multilingual Whisper variant on Groq that still handles + # Japanese well; switch to ``whisper-large-v3`` for max accuracy at + # ~3x the cost. + GROQ_STT_API_KEY: SecretStr | None = None + GROQ_STT_MODEL: str = "whisper-large-v3-turbo" + GROQ_STT_BASE_URL: str = "https://api.groq.com/openai/v1" + # ── Master TTS narration (reactive_voice only) ──────────────────── # When `LLM_DISCUSSION_MODE=reactive_voice` and Master is in VC, # phase-transition announcements (PHASE_CHANGE / MORNING / VICTORY @@ -111,6 +138,27 @@ def _require_gameplay_provider_key(self) -> MasterSettings: ) return self + @model_validator(mode="after") + def _require_voice_stt_provider_key(self) -> MasterSettings: + if self.VOICE_STT_PROVIDER == "groq" and self.GROQ_STT_API_KEY is None: + raise ValueError( + "VOICE_STT_PROVIDER=groq requires GROQ_STT_API_KEY to be set" + ) + # The Groq path's analyzer step reuses GAMEPLAY_LLM_*. The xAI/DeepSeek + # case is already covered above; the Gemini case isn't fit for the + # OpenAI-compatible analyzer call shape, so block that combo loud and + # early rather than failing per-segment at runtime. + if ( + self.VOICE_STT_PROVIDER == "groq" + and self.GAMEPLAY_LLM_PROVIDER == "gemini" + ): + raise ValueError( + "VOICE_STT_PROVIDER=groq's analyzer step reuses GAMEPLAY_LLM_* " + "but requires an OpenAI-compatible provider (xai or deepseek), " + f"not {self.GAMEPLAY_LLM_PROVIDER}" + ) + return self + def apply_phase_durations(self) -> None: """Initialize the global :class:`PhaseDurations` singleton from env. diff --git a/src/wolfbot/domain/enums.py b/src/wolfbot/domain/enums.py index d4616f7..938550b 100644 --- a/src/wolfbot/domain/enums.py +++ b/src/wolfbot/domain/enums.py @@ -6,6 +6,7 @@ from __future__ import annotations from enum import StrEnum +from typing import Literal class Role(StrEnum): @@ -110,3 +111,51 @@ class SubmitResult(StrEnum): VILLAGE_SIZE = 9 + + +# Roles that may openly claim themselves ("カミングアウト" / CO) during day +# discussion. Wolves and madmen routinely fake these, but they cannot CO +# their own true role (no one openly claims wolf/madman); villagers have +# no role power so a villager-CO is meaningless. The lowercased form is +# the wire/storage shape used by ``speech_events.co_declaration``, the +# voice/text analyzer prompts, and downstream Literal validators. +CO_CLAIMABLE_ROLES: tuple[Role, ...] = (Role.SEER, Role.MEDIUM, Role.KNIGHT) + + +def role_to_co_claim(role: Role) -> str: + """Wire form of a CO-claimable role (``Role.SEER`` → ``"seer"``).""" + return role.value.lower() + + +CO_CLAIM_VALUES: tuple[str, ...] = tuple( + role_to_co_claim(r) for r in CO_CLAIMABLE_ROLES +) + + +# Type alias for the wire/storage form. Cannot be derived from +# ``CO_CLAIM_VALUES`` because :class:`Literal` requires static values +# resolvable by the type-checker — ``Literal[*tuple]`` is not legal +# Python. Adding a CO-claimable role therefore requires updating BOTH +# :data:`CO_CLAIMABLE_ROLES` and this Literal in lockstep; the +# consistency test in ``tests/test_domain_co_claim.py`` asserts the two +# stay aligned so a mismatch is caught at CI time rather than at +# runtime when a mis-typed seat starts dropping CO declarations +# silently. +# +# Named ``CoDeclaration`` rather than ``CoClaim`` because +# :class:`wolfbot.domain.discussion.CoClaim` is already a Pydantic +# model representing a CO event in public-state rebuild — the two +# concepts are unrelated and the name collision would be confusing. +CoDeclaration = Literal["seer", "medium", "knight"] + + +def format_co_claim_options( + *, separator: str = "/", quote: str = '"' +) -> str: + """Render :data:`CO_CLAIM_VALUES` as a prompt-friendly enumeration. + + Default form ``"seer"/"medium"/"knight"`` matches the existing + Japanese analyzer prompts. Pass ``quote=""`` for a bare list + (``seer/medium/knight``). + """ + return separator.join(f"{quote}{v}{quote}" for v in CO_CLAIM_VALUES) diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index ab76ff8..556b97b 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -20,6 +20,8 @@ from pydantic import BaseModel, ConfigDict, Field +from wolfbot.domain.enums import CoDeclaration + class BaseEnvelope(BaseModel): """Cross-cutting fields present on every message.""" @@ -145,7 +147,7 @@ class SpeakResult(BaseEnvelope): intent: str | None = None estimated_duration_ms: int | None = None failure_reason: str | None = None - co_declaration: Literal["seer", "medium", "knight"] | None = Field( + co_declaration: CoDeclaration | None = Field( default=None, description=( "Structured CO self-declaration tag set by the NPC's speech " @@ -237,7 +239,7 @@ class SpeechEventPayload(BaseEnvelope): audio_start_ms: int audio_end_ms: int summary: str | None = None - co_declaration: Literal["seer", "medium", "knight"] | None = Field( + co_declaration: CoDeclaration | None = Field( default=None, description=( "Structured CO self-declaration extracted by Gemini's audio " diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index d08d616..703d1c4 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -17,6 +17,7 @@ Phase, Role, SubmissionType, + format_co_claim_options, ) from wolfbot.domain.models import Game, Player, Seat from wolfbot.llm.persona_base import Persona @@ -938,7 +939,7 @@ def task_daytime_speech( "口に出すときは状況や感情として描写する" "(例: 「あの白判定、無理に庇ってる気がして信用できない」「昨夜守ったのは◯◯です」" "「あと処刑できる回数を考えると…」)。" - "役職 CO したい場合は `co_declaration` を `\"seer\" / \"medium\" / \"knight\"` のいずれかに設定し、" + f"役職 CO したい場合は `co_declaration` を `{format_co_claim_options(separator=' / ')}` のいずれかに設定し、" "`public_message` 側は「実は私、占い師なんだ」のように自然に名乗ってから能力結果を続ける。" "`co_declaration` を設定しないなら `null`。" ) diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 22ca22c..9337c10 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -857,8 +857,23 @@ async def _on_vad_ended(msg: Any, _ctx: Any) -> None: # Instead of a separate voice-ingest process, Master joins VC itself # and pipes audio through VoiceIngestService → DirectMasterIngestionClient # → arbiter/ingest_service, all in-process. - if settings.VOICE_LLM_API_KEY is not None: - from wolfbot.master.stt_service import GeminiAudioAnalyzer + # Voice STT is wired iff a credential exists for the chosen + # provider. Gemini path needs ``VOICE_LLM_API_KEY``; Groq path + # needs ``GROQ_STT_API_KEY`` plus an OpenAI-compatible analyzer + # (gameplay key reused). + _voice_stt_credentialed = ( + (settings.VOICE_STT_PROVIDER == "gemini" and settings.VOICE_LLM_API_KEY is not None) + or ( + settings.VOICE_STT_PROVIDER == "groq" + and settings.GROQ_STT_API_KEY is not None + and settings.GAMEPLAY_LLM_API_KEY is not None + ) + ) + if _voice_stt_credentialed: + from wolfbot.master.stt_service import ( + GeminiAudioAnalyzer, + GroqWhisperAudioAnalyzer, + ) from wolfbot.master.voice_ingest_client import DirectMasterIngestionClient from wolfbot.master.voice_ingest_service import VoiceIngestService @@ -894,10 +909,29 @@ async def _direct_stt_failed(msg: Any) -> None: on_stt_failed=_direct_stt_failed, ) - voice_llm = GeminiAudioAnalyzer( - api_key=settings.VOICE_LLM_API_KEY.get_secret_value(), - model=settings.VOICE_LLM_MODEL, - ) + voice_llm: Any + if settings.VOICE_STT_PROVIDER == "groq": + # Reuse gameplay key for the analyzer step. Validator + # already guarantees both keys are present. + assert settings.GROQ_STT_API_KEY is not None + assert settings.GAMEPLAY_LLM_API_KEY is not None + analyzer_base_url = ( + settings.GAMEPLAY_LLM_BASE_URL or "https://api.x.ai/v1" + ) + voice_llm = GroqWhisperAudioAnalyzer( + groq_api_key=settings.GROQ_STT_API_KEY.get_secret_value(), + groq_model=settings.GROQ_STT_MODEL, + groq_base_url=settings.GROQ_STT_BASE_URL, + analyzer_api_key=settings.GAMEPLAY_LLM_API_KEY.get_secret_value(), + analyzer_model=settings.GAMEPLAY_LLM_MODEL, + analyzer_base_url=analyzer_base_url, + ) + else: + assert settings.VOICE_LLM_API_KEY is not None + voice_llm = GeminiAudioAnalyzer( + api_key=settings.VOICE_LLM_API_KEY.get_secret_value(), + model=settings.VOICE_LLM_MODEL, + ) # NpcRegistryView adapter: InMemoryNpcRegistry → NpcRegistryView class _RegistryViewAdapter: @@ -921,8 +955,17 @@ def _phase_lookup() -> tuple[str, str] | None: seat_lookup=_seat_lookup, phase_lookup=_phase_lookup, ) - log.info("integrated voice-ingest wired (voice_llm_model=%s)", - settings.VOICE_LLM_MODEL) + if settings.VOICE_STT_PROVIDER == "groq": + log.info( + "integrated voice-ingest wired (provider=groq stt_model=%s analyzer=%s)", + settings.GROQ_STT_MODEL, + settings.GAMEPLAY_LLM_MODEL, + ) + else: + log.info( + "integrated voice-ingest wired (provider=gemini voice_llm_model=%s)", + settings.VOICE_LLM_MODEL, + ) @bot.event async def on_ready() -> None: diff --git a/src/wolfbot/master/stt_service.py b/src/wolfbot/master/stt_service.py index 778d6ec..8c2b830 100644 --- a/src/wolfbot/master/stt_service.py +++ b/src/wolfbot/master/stt_service.py @@ -21,9 +21,20 @@ from dataclasses import dataclass from typing import Protocol, runtime_checkable +from wolfbot.domain.enums import ( + CO_CLAIM_VALUES, + VILLAGE_SIZE, + format_co_claim_options, +) + log = logging.getLogger(__name__) +def _seat_range_label() -> str: + """1-based inclusive seat range derived from :data:`VILLAGE_SIZE`.""" + return f"1〜{VILLAGE_SIZE}" + + @dataclass(frozen=True) class SttResult: """Outcome of a single STT call. @@ -185,8 +196,8 @@ class GeminiAudioAnalyzer: "- transcript: 音声の書き起こし全文(日本語)\n" "- summary: 発言内容の1文要約\n" "- confidence: 書き起こし精度の自己評価(0.0〜1.0)\n" - "- co_claim: 役職CO(自称)があれば \"seer\"/\"medium\"/\"knight\"、なければ null\n" - "- vote_target_seat: 処刑対象として名指しした席番号(1〜9)、なければ null\n" + f"- co_claim: 役職CO(自称)があれば {format_co_claim_options()}、なければ null\n" + f"- vote_target_seat: 処刑対象として名指しした席番号({_seat_range_label()})、なければ null\n" "- stance: 言及した席への態度 {\"席番号\": \"positive\"/\"negative\"/\"neutral\"}\n" "- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 \"セツ\"、\"ジーナさん\"、\"席3\"、\"3番\")、なければ null。" "「みんな」「全員」など全体への呼びかけは null。さん/くん/ちゃん 等の敬称は付けたままでも構わない。\n" @@ -289,7 +300,7 @@ async def transcribe( co_raw = parsed.get("co_claim") co_declaration = ( - co_raw if co_raw in ("seer", "medium", "knight") else None + co_raw if co_raw in CO_CLAIM_VALUES else None ) addressed_raw = parsed.get("addressed_name") @@ -359,10 +370,291 @@ def _parse_response(raw: str) -> dict: # type: ignore[type-arg] return {"transcript": text, "confidence": 0.3} +class GroqWhisperAudioAnalyzer: + """Two-step STT: Groq Whisper (transcribe) → analyzer LLM (extract). + + Groq's free tier on ``whisper-large-v3-turbo`` is generous and the + transport is OpenAI-compatible (``audio/transcriptions`` multipart), + so this slots in next to the existing Gemini analyzer without + changing :class:`VoiceIngestService`. The structured fields the + discussion path expects (``co_claim``, ``vote_target_seat``, + ``addressed_name``, summary) are filled by a second call to a tiny + OpenAI-compatible analyzer (xAI Grok in production) — the same + contract the multimodal Gemini call returns in one hop. + + Both steps are traced under ``role=voice_stt`` so the exporter and + viewer keep working without a schema change. The two trace lines are + distinguished by ``metadata.step`` (``transcribe`` vs ``analyze``). + + Failure semantics: Whisper failure raises ``SttProviderError`` with + a precise reason (timeout / 4xx / 5xx). Analyzer failure is + soft-handled — we still surface the transcript with empty structured + fields, because the discussion path can re-derive CO via legacy + substring matching on the text. This keeps the human-speech signal + flowing even when the analyzer LLM is briefly down. + """ + + _ANALYZER_PROMPT: str = ( + "あなたは人狼ゲームの発話内容を分析するエンジンです。\n" + "以下の書き起こし(日本語)を読んで、以下のJSONのみを返してください。\n" + "他の文字は含めないでください。\n\n" + "{\n" + ' "summary": "1文の要約(30文字以内)",\n' + ' "confidence": 0.95,\n' + ' "co_claim": null,\n' + ' "vote_target_seat": null,\n' + ' "stance": {},\n' + ' "addressed_name": null\n' + "}\n\n" + "フィールド説明:\n" + "- summary: 発言内容の1文要約\n" + "- confidence: 入力テキストから読み取れる主張の明確さ(0.0〜1.0)\n" + f"- co_claim: 役職CO(自称)があれば {format_co_claim_options()}、なければ null\n" + f"- vote_target_seat: 処刑対象として名指しした席番号({_seat_range_label()})、なければ null\n" + "- stance: 言及した席への態度 {\"席番号\": \"positive\"/\"negative\"/\"neutral\"}\n" + "- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 \"セツ\"、\"ジーナさん\"、\"席3\"、\"3番\")、なければ null。" + "「みんな」「全員」など全体への呼びかけは null。" + ) + + def __init__( + self, + *, + groq_api_key: str, + groq_model: str = "whisper-large-v3-turbo", + groq_base_url: str = "https://api.groq.com/openai/v1", + analyzer_api_key: str, + analyzer_model: str, + analyzer_base_url: str = "https://api.x.ai/v1", + timeout_s: float = 15.0, + ) -> None: + self.groq_api_key = groq_api_key + self.groq_model = groq_model + self.groq_base_url = groq_base_url.rstrip("/") + self.analyzer_api_key = analyzer_api_key + self.analyzer_model = analyzer_model + self.analyzer_base_url = analyzer_base_url.rstrip("/") + self.timeout_s = timeout_s + + async def transcribe( + self, + *, + audio: bytes, + language: str, + timeout_s: float, + ) -> SttResult: + effective_timeout = min(timeout_s, self.timeout_s) + # Estimate duration from raw 16kHz 16-bit mono PCM (matches Gemini path). + data_bytes = max(0, len(audio) - 44) + duration_ms = int(data_bytes / (16_000 * 2) * 1000) + + transcript = await self._whisper(audio, language, effective_timeout) + if not transcript: + return SttResult( + text="", + confidence=0.0, + duration_ms=duration_ms, + summary=None, + co_declaration=None, + addressed_name=None, + ) + + analysis = await self._analyze(transcript, effective_timeout) + co_raw = analysis.get("co_claim") + co_decl = co_raw if co_raw in CO_CLAIM_VALUES else None + addressed = analysis.get("addressed_name") + addressed_name = ( + addressed.strip() or None + if isinstance(addressed, str) + else None + ) + summary_dict = { + k: v + for k, v in analysis.items() + if k not in ("summary", "confidence") + and v is not None + and v != {} + } + summary_str: str | None = None + if "summary" in analysis and isinstance(analysis["summary"], str): + summary_str = analysis["summary"] + elif summary_dict: + import json as _json + + summary_str = _json.dumps(summary_dict, ensure_ascii=False) + try: + confidence = float(analysis.get("confidence", 0.9)) + except (TypeError, ValueError): + confidence = 0.9 + + return SttResult( + text=transcript, + confidence=confidence, + duration_ms=duration_ms, + summary=summary_str, + co_declaration=co_decl, + addressed_name=addressed_name, + ) + + async def _whisper(self, audio: bytes, language: str, timeout: float) -> str: + """Step 1: POST audio to Groq's whisper transcription endpoint. + + Uses ``response_format=verbose_json`` so we get a ``text`` field + plus per-segment confidence (we collapse to a single transcript). + """ + import httpx + + from wolfbot.services.llm_trace import ( + CallTimer, + log_llm_call, + ) + + url = f"{self.groq_base_url}/audio/transcriptions" + headers = {"Authorization": f"Bearer {self.groq_api_key}"} + # Whisper accepts BCP-47 like "ja"; map "ja-JP" → "ja". + lang_code = language.split("-")[0] if language else None + files: dict[str, tuple[str, bytes, str] | tuple[None, str]] = { + "file": ("segment.wav", audio, "audio/wav"), + "model": (None, self.groq_model), + "response_format": (None, "json"), + } + if lang_code: + files["language"] = (None, lang_code) + + timer = CallTimer() + transcript = "" + err: str | None = None + tokens: dict[str, int | None] | None = None + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(url, headers=headers, files=files) + if resp.status_code != 200: + err = f"groq_http_{resp.status_code}" + raise SttProviderError(err) + resp_json = resp.json() + transcript = (resp_json.get("text") or "").strip() + # Groq returns an `x_groq.id` etc. but no usage field for + # whisper; leave tokens=None like the openai SDK does. + except SttProviderError: + raise + except httpx.TimeoutException as exc: + err = "groq_timeout" + raise SttProviderError(err) from exc + except httpx.ConnectError as exc: + err = "groq_connection_refused" + raise SttProviderError(err) from exc + except Exception as exc: + err = f"groq_unexpected_{type(exc).__name__}" + raise SttProviderError(err) from exc + finally: + await log_llm_call( + role="voice_stt", + provider="groq", + model=self.groq_model, + system_prompt=None, + user_prompt=f"[audio bytes={len(audio)} mime=audio/wav lang={lang_code}]", + response=transcript or None, + latency_ms=timer.elapsed_ms, + error=err, + tokens=tokens, + extra={"audio_bytes": len(audio), "step": "transcribe"}, + ) + return transcript + + async def _analyze(self, transcript: str, timeout: float) -> dict: # type: ignore[type-arg] + """Step 2: ask the analyzer LLM to extract structured fields. + + Soft-fail: any error returns ``{}`` so the discussion path still + sees the transcript via legacy substring CO matching. The trace + line still records the error so operators can spot a chronic + analyzer outage. + """ + import json + + import httpx + + from wolfbot.services.llm_trace import ( + CallTimer, + extract_openai_tokens, + log_llm_call, + ) + + url = f"{self.analyzer_base_url}/chat/completions" + body = { + "model": self.analyzer_model, + "messages": [ + {"role": "system", "content": self._ANALYZER_PROMPT}, + {"role": "user", "content": transcript}, + ], + "response_format": {"type": "json_object"}, + } + headers = { + "Authorization": f"Bearer {self.analyzer_api_key}", + "Content-Type": "application/json", + } + + timer = CallTimer() + raw = "" + err: str | None = None + tokens: dict[str, int | None] | None = None + parsed: dict = {} # type: ignore[type-arg] + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(url, headers=headers, json=body) + if resp.status_code != 200: + err = f"analyzer_http_{resp.status_code}" + return {} + resp_json = resp.json() + # Extract OpenAI-shaped usage if present. + from types import SimpleNamespace + + usage_raw = resp_json.get("usage") or {} + usage_ns = SimpleNamespace( + prompt_tokens=usage_raw.get("prompt_tokens"), + completion_tokens=usage_raw.get("completion_tokens"), + total_tokens=usage_raw.get("total_tokens"), + ) + tokens = extract_openai_tokens(SimpleNamespace(usage=usage_ns)) + raw = ( + resp_json.get("choices", [{}])[0] + .get("message", {}) + .get("content", "") + or "" + ) + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + err = "analyzer_json_parse_failed" + parsed = {} + return parsed + except httpx.TimeoutException: + err = "analyzer_timeout" + return {} + except httpx.ConnectError: + err = "analyzer_connection_refused" + return {} + except Exception as exc: + err = f"analyzer_unexpected_{type(exc).__name__}" + return {} + finally: + await log_llm_call( + role="voice_stt", + provider="xai", + model=self.analyzer_model, + system_prompt=self._ANALYZER_PROMPT, + user_prompt=transcript, + response=raw or None, + latency_ms=timer.elapsed_ms, + error=err, + tokens=tokens, + extra={"step": "analyze"}, + ) + + __all__ = [ "FakeSttService", "GeminiAudioAnalyzer", "GeminiSttService", + "GroqWhisperAudioAnalyzer", "SttProviderError", "SttResult", "SttService", diff --git a/src/wolfbot/master/text_analyzer.py b/src/wolfbot/master/text_analyzer.py index 73f4bc5..61be94a 100644 --- a/src/wolfbot/master/text_analyzer.py +++ b/src/wolfbot/master/text_analyzer.py @@ -32,6 +32,8 @@ from dataclasses import dataclass from typing import Protocol, runtime_checkable +from wolfbot.domain.enums import CO_CLAIM_VALUES, format_co_claim_options + log = logging.getLogger(__name__) @@ -109,7 +111,7 @@ class GeminiTextAnalyzer: "}\n" "```\n\n" "フィールド説明:\n" - "- co_claim: 役職CO(自称)があれば \"seer\"/\"medium\"/\"knight\"、なければ null\n" + f"- co_claim: 役職CO(自称)があれば {format_co_claim_options()}、なければ null\n" "- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 \"セツ\"、\"ジーナさん\"、\"席3\"、\"3番\")、なければ null。" "「みんな」「全員」など全体への呼びかけは null。さん/くん/ちゃん 等の敬称は付けたままでも構わない。" "発言内で他プレイヤーに言及するだけ(例: 『セツの判定が気になる』)は呼びかけではないので null。" @@ -176,9 +178,7 @@ async def analyze(self, *, text: str, timeout_s: float) -> TextAnalysis: ) from exc co_raw = parsed.get("co_claim") - co_declaration = ( - co_raw if co_raw in ("seer", "medium", "knight") else None - ) + co_declaration = co_raw if co_raw in CO_CLAIM_VALUES else None addressed_raw = parsed.get("addressed_name") addressed_name: str | None = None if isinstance(addressed_raw, str): diff --git a/src/wolfbot/master/voice_ingest_service.py b/src/wolfbot/master/voice_ingest_service.py index 89d3fa6..8cf74b3 100644 --- a/src/wolfbot/master/voice_ingest_service.py +++ b/src/wolfbot/master/voice_ingest_service.py @@ -30,6 +30,7 @@ from dataclasses import dataclass, field from typing import Protocol, runtime_checkable +from wolfbot.domain.enums import CO_CLAIM_VALUES from wolfbot.domain.ws_messages import ( Heartbeat, SpeechEventPayload, @@ -306,9 +307,11 @@ async def _run_stt_inner( ) return - co_decl = result.co_declaration if result.co_declaration in ( - "seer", "medium", "knight", - ) else None + co_decl = ( + result.co_declaration + if result.co_declaration in CO_CLAIM_VALUES + else None + ) await self.master_client.send_speech_event_payload( SpeechEventPayload( ts=self._now_ms(), diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index 226684f..806d2c6 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -38,6 +38,7 @@ from dataclasses import dataclass from typing import Literal +from wolfbot.domain.enums import CO_CLAIM_VALUES from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, SpeakRequest from wolfbot.llm.persona_base import Persona from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY @@ -64,7 +65,7 @@ }, "co_declaration": { "type": ["string", "null"], - "enum": ["seer", "medium", "knight", None], + "enum": [*CO_CLAIM_VALUES, None], }, }, }, @@ -350,7 +351,7 @@ def _build_speech_from_json(data: dict[str, object]) -> NpcGeneratedSpeech | Non tuple(str(x) for x in raw_ids) if isinstance(raw_ids, list) else () ) co_raw = data.get("co_declaration") - co_declaration = co_raw if co_raw in ("seer", "medium", "knight") else None + co_declaration = co_raw if co_raw in CO_CLAIM_VALUES else None # Rough estimate: ~150ms per character for TTS estimated_ms = max(500, len(text) * 150) diff --git a/src/wolfbot/npc/speech_service.py b/src/wolfbot/npc/speech_service.py index b5377e1..be50956 100644 --- a/src/wolfbot/npc/speech_service.py +++ b/src/wolfbot/npc/speech_service.py @@ -12,8 +12,9 @@ import logging from dataclasses import dataclass -from typing import Literal, Protocol, runtime_checkable +from typing import Protocol, runtime_checkable +from wolfbot.domain.enums import CO_CLAIM_VALUES, CoDeclaration from wolfbot.domain.ws_messages import LogicPacket, SpeakRequest, SpeakResult log = logging.getLogger(__name__) @@ -111,8 +112,8 @@ async def respond( text = speech.text.strip() if len(text) > request.max_chars: text = text[: request.max_chars] - co_declaration: Literal["seer", "medium", "knight"] | None = None - if speech.co_declaration in ("seer", "medium", "knight"): + co_declaration: CoDeclaration | None = None + if speech.co_declaration in CO_CLAIM_VALUES: co_declaration = speech.co_declaration # type: ignore[assignment] return SpeakResult( ts=now_ms, diff --git a/src/wolfbot/services/deduction_service.py b/src/wolfbot/services/deduction_service.py index 92a6d6e..f467a3c 100644 --- a/src/wolfbot/services/deduction_service.py +++ b/src/wolfbot/services/deduction_service.py @@ -28,9 +28,10 @@ from enum import StrEnum from wolfbot.domain.discussion import CoClaim +from wolfbot.domain.enums import CO_CLAIM_VALUES from wolfbot.domain.models import Player, Seat, Vote -_INFO_ROLES: tuple[str, ...] = ("seer", "medium", "knight") +_INFO_ROLES: tuple[str, ...] = CO_CLAIM_VALUES _ROLE_JA: Mapping[str, str] = {"seer": "占い師", "medium": "霊媒師", "knight": "騎士"} diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py index 4f51d13..f83c403 100644 --- a/src/wolfbot/services/discussion_service.py +++ b/src/wolfbot/services/discussion_service.py @@ -42,7 +42,7 @@ SpeechSource, make_phase_id, ) -from wolfbot.domain.enums import Phase +from wolfbot.domain.enums import CO_CLAIM_VALUES, Phase from wolfbot.domain.models import LogEntry log = logging.getLogger(__name__) @@ -627,7 +627,7 @@ def rebuild_public_state_from_events( return state -_VALID_CO_ROLES: frozenset[str] = frozenset({"seer", "medium", "knight"}) +_VALID_CO_ROLES: frozenset[str] = frozenset(CO_CLAIM_VALUES) _CO_MARKERS: tuple[tuple[str, str], ...] = ( ("seer", "占いCO"), diff --git a/src/wolfbot/services/game_export_types.py b/src/wolfbot/services/game_export_types.py index 6101a5e..8713109 100644 --- a/src/wolfbot/services/game_export_types.py +++ b/src/wolfbot/services/game_export_types.py @@ -26,6 +26,8 @@ from pydantic import BaseModel, ConfigDict +from wolfbot.domain.enums import CoDeclaration as _CoDeclaration + # A frozen, closed shape — extra fields cause validation errors so a # typo or a stale field caught here rather than silently dropped. _StrictConfig = ConfigDict(frozen=True, extra="forbid") @@ -42,7 +44,9 @@ # the internal `phase_baseline` sentinel (filtered out by the exporter — # it's a private state-rebuild marker, not a viewable utterance). SpeechSource = Literal["text", "voice_stt", "npc_generated"] -CoDeclaration = Literal["seer", "medium", "knight"] +# Re-export the canonical wire/storage form from domain/enums so the +# viewer schema stays aligned with runtime validators in lockstep. +CoDeclaration = _CoDeclaration TraceRole = Literal["gameplay", "npc_speech", "voice_stt"] Victory = Literal["village", "wolf"] diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index efbed59..e282991 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -40,7 +40,13 @@ ) from wolfbot.domain.discussion import CoClaim, make_phase_id -from wolfbot.domain.enums import Phase, Role, SubmissionType +from wolfbot.domain.enums import ( + CO_CLAIM_VALUES, + CoDeclaration, + Phase, + Role, + SubmissionType, +) from wolfbot.domain.models import Game, LogEntry, Player, Seat, Vote from wolfbot.domain.rules import ( legal_attack_targets, @@ -123,7 +129,7 @@ class LLMAction(BaseModel): target_name: str | None = None reason_summary: str = "" confidence: float = 0.5 - co_declaration: Literal["seer", "medium", "knight"] | None = None + co_declaration: CoDeclaration | None = None RESPONSE_SCHEMA: dict[str, object] = { @@ -151,7 +157,7 @@ class LLMAction(BaseModel): "confidence": {"type": "number", "minimum": 0, "maximum": 1}, "co_declaration": { "type": ["string", "null"], - "enum": ["seer", "medium", "knight", None], + "enum": [*CO_CLAIM_VALUES, None], }, }, }, diff --git a/tests/test_domain_co_claim.py b/tests/test_domain_co_claim.py new file mode 100644 index 0000000..eadb3f2 --- /dev/null +++ b/tests/test_domain_co_claim.py @@ -0,0 +1,77 @@ +"""Drift detection for the canonical CO-declaration definitions. + +:data:`CO_CLAIM_VALUES` (runtime tuple), :data:`CoDeclaration` (Pydantic +``Literal`` alias), and :data:`CO_CLAIMABLE_ROLES` (the :class:`Role` +subset) MUST agree. They cannot be mechanically derived from one +another because :class:`Literal` requires statically-resolvable values +(``Literal[*tuple]`` is not legal Python), so when we add a new +CO-claimable role all three definitions need updating in lockstep. + +Without this test, a stale ``CoDeclaration`` Literal would silently +narrow the wire schema while validators continued to accept the new +value, producing confusing "field rejected by Pydantic but accepted by +runtime" bugs at the boundary between Master and the WS messages. +""" + +from __future__ import annotations + +from typing import get_args + +from wolfbot.domain.enums import ( + CO_CLAIM_VALUES, + CO_CLAIMABLE_ROLES, + CoDeclaration, + Role, + format_co_claim_options, + role_to_co_claim, +) + + +def test_co_claim_values_match_co_claimable_roles() -> None: + """The runtime tuple is mechanically derived from the Role subset — + if this drifts, the derivation in :mod:`wolfbot.domain.enums` is + broken.""" + derived = tuple(role_to_co_claim(r) for r in CO_CLAIMABLE_ROLES) + assert derived == CO_CLAIM_VALUES + + +def test_co_declaration_literal_matches_runtime_tuple() -> None: + """The Pydantic Literal MUST contain the same values, in the same + order, as :data:`CO_CLAIM_VALUES`. ``Literal`` ordering matters for + JSON schema dumps and for human-readable error messages.""" + literal_args = get_args(CoDeclaration) + assert literal_args == CO_CLAIM_VALUES + + +def test_co_claimable_roles_excludes_wolf_madman_villager() -> None: + """No real game logic openly claims wolf, madman, or villager — the + first two are always faked, the last has no power. Locking this in + here so a drive-by edit to :data:`CO_CLAIMABLE_ROLES` triggers a + test failure rather than silent gameplay change.""" + assert Role.WEREWOLF not in CO_CLAIMABLE_ROLES + assert Role.MADMAN not in CO_CLAIMABLE_ROLES + assert Role.VILLAGER not in CO_CLAIMABLE_ROLES + + +def test_role_to_co_claim_lowercases_uppercase_role_value() -> None: + """``Role`` values are uppercase StrEnum strings (matches the DB + column); the wire/storage form is lowercase. Verify the mapping is + a pure ``.lower()`` so renaming a role doesn't accidentally diverge + the two surfaces.""" + for role in CO_CLAIMABLE_ROLES: + assert role_to_co_claim(role) == role.value.lower() + + +def test_format_co_claim_options_default_form() -> None: + """Default render matches the Japanese analyzer prompts' + ``"seer"/"medium"/"knight"`` shape; preserves quotes and slash + separator.""" + assert format_co_claim_options() == '"seer"/"medium"/"knight"' + + +def test_format_co_claim_options_custom_separator_and_quote() -> None: + """Callers building prose-style prompts (e.g. prompt_builder) can + override the separator. Removing the quote char yields a bare, + unquoted list.""" + assert format_co_claim_options(separator=" / ") == '"seer" / "medium" / "knight"' + assert format_co_claim_options(quote="") == "seer/medium/knight" diff --git a/tests/test_master_stt_groq.py b/tests/test_master_stt_groq.py new file mode 100644 index 0000000..1086db3 --- /dev/null +++ b/tests/test_master_stt_groq.py @@ -0,0 +1,432 @@ +"""Groq Whisper STT pipeline tests. + +Cover the two-step ``GroqWhisperAudioAnalyzer.transcribe`` pipeline: + +1. **Whisper success → analyzer success** — both calls 200, structured + fields propagate to ``SttResult``. +2. **Whisper success → analyzer 5xx** — soft fall-back: transcript + surfaces, structured fields are ``None`` so the discussion path can + still legacy-match CO via substring. +3. **Whisper 4xx/5xx** — hard error: ``SttProviderError`` raised, no + analyzer call attempted. +4. **Whisper 200 with empty transcript** — short-circuits the pipeline + so we don't burn an analyzer call on silence. +5. **Trace lines emitted** — both steps write under ``role=voice_stt`` + distinguished by ``metadata.step``; tokens captured for the analyzer + step when the response carries usage. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from wolfbot.master.stt_service import ( + GroqWhisperAudioAnalyzer, + SttProviderError, +) + + +@pytest.fixture +def trace_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("WOLFBOT_LLM_TRACE_DIR", str(tmp_path)) + monkeypatch.delenv("WOLFBOT_LLM_TRACE_DISABLED", raising=False) + return tmp_path + + +def _make_wav(payload_size: int = 4_000) -> bytes: + """A minimal blob shaped like 16kHz mono WAV (44-byte header + zeros).""" + return b"\x00" * (44 + payload_size) + + +def _read_voice_stt_trace(trace_dir: Path, game_id: str = "g_groq") -> list[dict]: + path = trace_dir / game_id / "voice_stt.jsonl" + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + + +def _httpx_mock(routes: dict[str, dict[str, Any]]) -> Any: + """Return an ``httpx.MockTransport`` that dispatches by URL prefix. + + Each route value is ``{"status": int, "json": dict}`` (or ``"text"`` / + ``"raise"`` for non-JSON paths). Multiple routes can match — we pick + the longest-prefix match to disambiguate Groq vs analyzer URLs. + """ + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + # Longest-prefix wins. + candidates = [k for k in routes if url.startswith(k)] + if not candidates: + raise AssertionError(f"unexpected request: {url}") + key = max(candidates, key=len) + spec = routes[key] + if "raise" in spec: + raise spec["raise"] + if "text" in spec: + return httpx.Response(spec["status"], text=spec["text"]) + return httpx.Response(spec["status"], json=spec.get("json", {})) + + return httpx.MockTransport(handler) + + +@pytest.fixture +def patch_httpx(monkeypatch: pytest.MonkeyPatch) -> Any: + """Replace ``httpx.AsyncClient`` so each test can wire its own routes. + + Returns a setter function the test calls with the route dict. + """ + state: dict[str, Any] = {"transport": None} + + real_client = httpx.AsyncClient + + class _MockedClient(real_client): # type: ignore[misc, valid-type] + def __init__(self, *args: Any, **kwargs: Any) -> None: + kwargs.setdefault("transport", state["transport"]) + super().__init__(*args, **kwargs) + + monkeypatch.setattr("httpx.AsyncClient", _MockedClient) + + def set_routes(routes: dict[str, dict[str, Any]]) -> None: + state["transport"] = _httpx_mock(routes) + + return set_routes + + +def _analyzer_json(payload: dict[str, Any]) -> dict[str, Any]: + """Wrap a structured-analysis payload as an OpenAI-compatible response.""" + return { + "choices": [ + {"message": {"content": json.dumps(payload, ensure_ascii=False)}} + ], + "usage": { + "prompt_tokens": 80, + "completion_tokens": 30, + "total_tokens": 110, + }, + } + + +async def test_full_pipeline_success_propagates_structured_fields( + trace_dir: Path, patch_httpx: Any +) -> None: + patch_httpx({ + "https://api.groq.com/": { + "status": 200, + "json": {"text": "席3が怪しいから投票する"}, + }, + "https://api.x.ai/": { + "status": 200, + "json": _analyzer_json({ + "summary": "席3への投票表明", + "confidence": 0.92, + "co_claim": None, + "vote_target_seat": 3, + "stance": {"3": "negative"}, + "addressed_name": None, + }), + }, + }) + + analyzer = GroqWhisperAudioAnalyzer( + groq_api_key="g_test", + groq_model="whisper-large-v3-turbo", + analyzer_api_key="x_test", + analyzer_model="grok-4-1-fast-non-reasoning", + ) + from wolfbot.services.llm_trace import trace_context + + with trace_context(game_id="g_groq", phase="DAY_DISCUSSION", day=1): + result = await analyzer.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=10.0) + + assert result.text == "席3が怪しいから投票する" + assert result.confidence == pytest.approx(0.92) + assert result.summary == "席3への投票表明" + assert result.co_declaration is None + assert result.addressed_name is None + # 4000 PCM bytes / 32000 bytes/sec = 0.125s + assert 100 < result.duration_ms < 200 + + rows = _read_voice_stt_trace(trace_dir) + assert len(rows) == 2 + transcribe_row = next(r for r in rows if r["metadata"]["step"] == "transcribe") + analyze_row = next(r for r in rows if r["metadata"]["step"] == "analyze") + assert transcribe_row["provider"] == "groq" + assert transcribe_row["model"] == "whisper-large-v3-turbo" + assert transcribe_row["error"] is None + assert "席3" in transcribe_row["response"] + assert analyze_row["provider"] == "xai" + assert analyze_row["model"] == "grok-4-1-fast-non-reasoning" + assert analyze_row["tokens"] == {"prompt": 80, "completion": 30, "total": 110} + + +async def test_co_declaration_normalizes_to_known_roles(patch_httpx: Any) -> None: + """Analyzer can return arbitrary strings for ``co_claim``; only the + three accepted roles surface on ``SttResult``.""" + patch_httpx({ + "https://api.groq.com/": { + "status": 200, + "json": {"text": "占いCO 席7白"}, + }, + "https://api.x.ai/": { + "status": 200, + "json": _analyzer_json({ + "summary": "占いCO", + "confidence": 0.9, + "co_claim": "seer", + "vote_target_seat": None, + "stance": {}, + "addressed_name": None, + }), + }, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + assert r.co_declaration == "seer" + + +async def test_invalid_co_value_drops_to_none(patch_httpx: Any) -> None: + patch_httpx({ + "https://api.groq.com/": {"status": 200, "json": {"text": "なにか"}}, + "https://api.x.ai/": { + "status": 200, + "json": _analyzer_json({"co_claim": "wolf"}), # not in allowed set + }, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + assert r.co_declaration is None + + +async def test_addressed_name_strips_and_normalizes(patch_httpx: Any) -> None: + patch_httpx({ + "https://api.groq.com/": {"status": 200, "json": {"text": " ジナさんどう?"}}, + "https://api.x.ai/": { + "status": 200, + "json": _analyzer_json({"addressed_name": " ジナさん "}), + }, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + assert r.addressed_name == "ジナさん" + + +async def test_empty_transcript_short_circuits_analyzer( + trace_dir: Path, patch_httpx: Any +) -> None: + """A blank transcription must NOT trigger the analyzer call — no + point burning a Grok request on silence.""" + calls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + calls.append(url) + if "groq" in url: + return httpx.Response(200, json={"text": ""}) + return httpx.Response(200, json=_analyzer_json({})) + + import wolfbot.master.stt_service as stt_module # noqa: F401 + + transport = httpx.MockTransport(handler) + real = httpx.AsyncClient + + class _M(real): # type: ignore[misc, valid-type] + def __init__(self, *a: Any, **k: Any) -> None: + k["transport"] = transport + super().__init__(*a, **k) + + import httpx as httpx_mod + + httpx_mod.AsyncClient = _M # type: ignore[misc] + try: + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + from wolfbot.services.llm_trace import trace_context + + with trace_context(game_id="g_groq"): + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + finally: + httpx_mod.AsyncClient = real # type: ignore[misc] + + assert r.text == "" + assert r.confidence == 0.0 + assert all("groq" in c for c in calls), f"analyzer was called for empty transcript: {calls}" + rows = _read_voice_stt_trace(trace_dir) + assert len(rows) == 1 + assert rows[0]["metadata"]["step"] == "transcribe" + + +async def test_whisper_429_raises_stt_provider_error(patch_httpx: Any) -> None: + """Hard failures from Whisper bubble up as ``SttProviderError`` so + voice_ingest can route through the documented failure path.""" + patch_httpx({ + "https://api.groq.com/": {"status": 429, "json": {"error": "rate"}}, + "https://api.x.ai/": {"status": 200, "json": _analyzer_json({})}, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + with pytest.raises(SttProviderError) as exc_info: + await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + assert exc_info.value.failure_reason == "groq_http_429" + + +async def test_whisper_timeout_raises(monkeypatch: pytest.MonkeyPatch) -> None: + def raiser(request: httpx.Request) -> httpx.Response: + raise httpx.TimeoutException("slow") + + transport = httpx.MockTransport(raiser) + real = httpx.AsyncClient + + class _M(real): # type: ignore[misc, valid-type] + def __init__(self, *a: Any, **k: Any) -> None: + k["transport"] = transport + super().__init__(*a, **k) + + monkeypatch.setattr(httpx, "AsyncClient", _M) + + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + with pytest.raises(SttProviderError) as exc_info: + await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + assert exc_info.value.failure_reason == "groq_timeout" + + +async def test_analyzer_5xx_soft_fails_keeps_transcript( + trace_dir: Path, patch_httpx: Any +) -> None: + """Analyzer outage must NOT lose the transcript — the discussion path + can still derive CO via substring, and dropping the speech entirely + would silence the human in front of the bot.""" + patch_httpx({ + "https://api.groq.com/": { + "status": 200, + "json": {"text": "占いCO 席7白"}, + }, + "https://api.x.ai/": {"status": 503, "text": "upstream"}, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + from wolfbot.services.llm_trace import trace_context + + with trace_context(game_id="g_groq"): + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + + assert r.text == "占いCO 席7白" + assert r.confidence == pytest.approx(0.9) # default fallback + assert r.summary is None + assert r.co_declaration is None + + rows = _read_voice_stt_trace(trace_dir) + assert len(rows) == 2 + analyze_row = next(r for r in rows if r["metadata"]["step"] == "analyze") + assert analyze_row["error"] == "analyzer_http_503" + assert analyze_row["response"] is None + + +async def test_analyzer_returns_garbage_json_soft_fails( + trace_dir: Path, patch_httpx: Any +) -> None: + """Malformed analyzer JSON must not crash the pipeline — record the + parse error in the trace and fall through with the transcript.""" + patch_httpx({ + "https://api.groq.com/": {"status": 200, "json": {"text": "やあ"}}, + "https://api.x.ai/": { + "status": 200, + "json": { + "choices": [{"message": {"content": "not-json{"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + }, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + from wolfbot.services.llm_trace import trace_context + + with trace_context(game_id="g_groq"): + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + + assert r.text == "やあ" + assert r.summary is None + rows = _read_voice_stt_trace(trace_dir) + analyze_row = next(r for r in rows if r["metadata"]["step"] == "analyze") + assert analyze_row["error"] == "analyzer_json_parse_failed" + # Even on parse fail we record the raw response so operators can debug. + assert analyze_row["response"] == "not-json{" + + +async def test_language_strips_region_for_whisper(patch_httpx: Any) -> None: + """Whisper accepts BCP-47 short codes (``ja``) not regional ones + (``ja-JP``); the adapter must strip before sending.""" + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + if "groq" in url: + captured["body"] = request.read() + return httpx.Response(200, json={"text": "テスト"}) + return httpx.Response(200, json=_analyzer_json({})) + + transport = httpx.MockTransport(handler) + real = httpx.AsyncClient + + class _M(real): # type: ignore[misc, valid-type] + def __init__(self, *a: Any, **k: Any) -> None: + k["transport"] = transport + super().__init__(*a, **k) + + import httpx as httpx_mod + + httpx_mod.AsyncClient = _M # type: ignore[misc] + try: + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + finally: + httpx_mod.AsyncClient = real # type: ignore[misc] + + body = captured["body"] + # multipart payload contains form field "language" with value "ja" + assert b'name="language"' in body + # "ja-JP" should NOT appear — we stripped to "ja". + assert b"ja-JP" not in body + + +async def test_duration_ms_estimated_from_pcm_size(patch_httpx: Any) -> None: + """A 1-second WAV (16000 samples * 2 bytes + 44-byte header) → ~1000 ms.""" + patch_httpx({ + "https://api.groq.com/": {"status": 200, "json": {"text": "短く"}}, + "https://api.x.ai/": {"status": 200, "json": _analyzer_json({})}, + }) + one_second = b"\x00" * (44 + 16_000 * 2) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + r = await a.transcribe(audio=one_second, language="ja-JP", timeout_s=5) + assert 950 <= r.duration_ms <= 1050 From c18424fd45cc4a4716eaf0cd6358c9524d84ee06 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 16:40:28 +0900 Subject: [PATCH 036/133] fix(stt): wrap raw PCM as WAV before posting to Groq + record err body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord's voice_recv decoder emits 48 kHz stereo 16-bit raw PCM. We were sending those bytes to Groq's ``audio/transcriptions`` endpoint with ``Content-Type: audio/wav`` but no RIFF header, and ffmpeg on Groq's side rejected every segment with ``HTTP 400`` — observed 2026-04-28: 15 consecutive 4xx, zero successful transcriptions, all NPC reactions to human speech silently dropped. ``GroqWhisperAudioAnalyzer`` now wraps the configured PCM format (default = Discord native 48 kHz stereo 16-bit, override-able for future down-mixers) into a real WAV file before the multipart POST. ``duration_ms`` is also recomputed from the actual bytes/sec, fixing the stale 16 kHz mono assumption that quietly inflated reported durations. Trace lines now record the upstream response body on non-200 status so the next time we hit a 4xx the failure mode is in the JSONL instead of requiring a separate debug session to discover. --- src/wolfbot/master/stt_service.py | 66 +++++++++++++++-- tests/test_master_stt_groq.py | 116 +++++++++++++++++++++++++++--- 2 files changed, 169 insertions(+), 13 deletions(-) diff --git a/src/wolfbot/master/stt_service.py b/src/wolfbot/master/stt_service.py index 8c2b830..efbed49 100644 --- a/src/wolfbot/master/stt_service.py +++ b/src/wolfbot/master/stt_service.py @@ -35,6 +35,35 @@ def _seat_range_label() -> str: return f"1〜{VILLAGE_SIZE}" +def _pcm_to_wav( + pcm: bytes, + *, + sample_rate: int = 48_000, + channels: int = 2, + sample_width: int = 2, +) -> bytes: + """Wrap raw PCM in a minimal RIFF/WAV header. + + Groq's ``audio/transcriptions`` endpoint runs ffmpeg under the hood + and rejects raw PCM with ``HTTP 400`` even when the multipart + ``Content-Type`` claims ``audio/wav``. The fix is to give it a real + WAV file. Defaults match ``discord-ext-voice_recv``'s opus decoder + output (48 kHz, stereo, 16-bit signed little-endian) so callers + feeding straight from :class:`WolfbotAudioSink` need not pass + explicit format kwargs. + """ + import io + import wave + + buf = io.BytesIO() + with wave.open(buf, "wb") as w: + w.setnchannels(channels) + w.setsampwidth(sample_width) + w.setframerate(sample_rate) + w.writeframes(pcm) + return buf.getvalue() + + @dataclass(frozen=True) class SttResult: """Outcome of a single STT call. @@ -426,6 +455,13 @@ def __init__( analyzer_model: str, analyzer_base_url: str = "https://api.x.ai/v1", timeout_s: float = 15.0, + # Format of the raw PCM bytes ``transcribe()`` receives. Defaults + # match discord-ext-voice_recv's opus decoder so the typical + # caller (``WolfbotAudioSink`` → ``VoiceIngestService``) needs no + # configuration. Override only when feeding pre-processed audio. + pcm_sample_rate: int = 48_000, + pcm_channels: int = 2, + pcm_sample_width: int = 2, ) -> None: self.groq_api_key = groq_api_key self.groq_model = groq_model @@ -434,6 +470,9 @@ def __init__( self.analyzer_model = analyzer_model self.analyzer_base_url = analyzer_base_url.rstrip("/") self.timeout_s = timeout_s + self.pcm_sample_rate = pcm_sample_rate + self.pcm_channels = pcm_channels + self.pcm_sample_width = pcm_sample_width async def transcribe( self, @@ -443,11 +482,23 @@ async def transcribe( timeout_s: float, ) -> SttResult: effective_timeout = min(timeout_s, self.timeout_s) - # Estimate duration from raw 16kHz 16-bit mono PCM (matches Gemini path). - data_bytes = max(0, len(audio) - 44) - duration_ms = int(data_bytes / (16_000 * 2) * 1000) + bytes_per_sec = ( + self.pcm_sample_rate * self.pcm_channels * self.pcm_sample_width + ) + duration_ms = ( + int(len(audio) / bytes_per_sec * 1000) if bytes_per_sec else 0 + ) + # Groq's whisper endpoint rejects headerless PCM. Wrap to WAV + # using the configured PCM format so ffmpeg on Groq's side can + # demux the stream. + wav_audio = _pcm_to_wav( + audio, + sample_rate=self.pcm_sample_rate, + channels=self.pcm_channels, + sample_width=self.pcm_sample_width, + ) - transcript = await self._whisper(audio, language, effective_timeout) + transcript = await self._whisper(wav_audio, language, effective_timeout) if not transcript: return SttResult( text="", @@ -523,12 +574,17 @@ async def _whisper(self, audio: bytes, language: str, timeout: float) -> str: timer = CallTimer() transcript = "" err: str | None = None + # Capture the upstream error body so a recurring 400/4xx is + # diagnosable from the trace alone (status code by itself didn't + # tell us "audio decode failed" the first time around). + err_body: str | None = None tokens: dict[str, int | None] | None = None try: async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.post(url, headers=headers, files=files) if resp.status_code != 200: err = f"groq_http_{resp.status_code}" + err_body = resp.text[:1000] if resp.text else None raise SttProviderError(err) resp_json = resp.json() transcript = (resp_json.get("text") or "").strip() @@ -552,7 +608,7 @@ async def _whisper(self, audio: bytes, language: str, timeout: float) -> str: model=self.groq_model, system_prompt=None, user_prompt=f"[audio bytes={len(audio)} mime=audio/wav lang={lang_code}]", - response=transcript or None, + response=transcript if err is None else err_body, latency_ms=timer.elapsed_ms, error=err, tokens=tokens, diff --git a/tests/test_master_stt_groq.py b/tests/test_master_stt_groq.py index 1086db3..2cc73c7 100644 --- a/tests/test_master_stt_groq.py +++ b/tests/test_master_stt_groq.py @@ -39,8 +39,10 @@ def trace_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: def _make_wav(payload_size: int = 4_000) -> bytes: - """A minimal blob shaped like 16kHz mono WAV (44-byte header + zeros).""" - return b"\x00" * (44 + payload_size) + """An opaque PCM-shaped blob. Tests don't parse the actual bytes — + the mock httpx layer just echoes a canned response. Defaults to + 4 000 zero bytes (~20 ms of Discord-native 48 kHz stereo PCM).""" + return b"\x00" * payload_size def _read_voice_stt_trace(trace_dir: Path, game_id: str = "g_groq") -> list[dict]: @@ -149,8 +151,9 @@ async def test_full_pipeline_success_propagates_structured_fields( assert result.summary == "席3への投票表明" assert result.co_declaration is None assert result.addressed_name is None - # 4000 PCM bytes / 32000 bytes/sec = 0.125s - assert 100 < result.duration_ms < 200 + # 4000 PCM bytes at Discord's native 48 kHz stereo 16-bit + # (192_000 bytes/sec) ≈ 20 ms. + assert 15 < result.duration_ms < 30 rows = _read_voice_stt_trace(trace_dir) assert len(rows) == 2 @@ -288,6 +291,39 @@ async def test_whisper_429_raises_stt_provider_error(patch_httpx: Any) -> None: assert exc_info.value.failure_reason == "groq_http_429" +async def test_whisper_4xx_response_body_recorded_in_trace( + trace_dir: Path, patch_httpx: Any +) -> None: + """A non-200 from Groq must surface its response body in the trace + so a recurring failure mode (e.g. 'audio decode failed') is + diagnosable from the JSONL alone — the original HTTP-400 incident + only logged the status code, masking the real cause.""" + patch_httpx({ + "https://api.groq.com/": { + "status": 400, + "text": "audio decode failed: invalid wav header", + }, + "https://api.x.ai/": {"status": 200, "json": _analyzer_json({})}, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + from wolfbot.services.llm_trace import trace_context + + with ( + trace_context(game_id="g_groq"), + pytest.raises(SttProviderError), + ): + await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + + rows = _read_voice_stt_trace(trace_dir) + transcribe_row = next(r for r in rows if r["metadata"]["step"] == "transcribe") + assert transcribe_row["error"] == "groq_http_400" + assert transcribe_row["response"] is not None + assert "audio decode failed" in transcribe_row["response"] + + async def test_whisper_timeout_raises(monkeypatch: pytest.MonkeyPatch) -> None: def raiser(request: httpx.Request) -> httpx.Response: raise httpx.TimeoutException("slow") @@ -378,6 +414,50 @@ async def test_analyzer_returns_garbage_json_soft_fails( assert analyze_row["response"] == "not-json{" +async def test_raw_pcm_is_wrapped_to_wav_before_post(monkeypatch: pytest.MonkeyPatch) -> None: + """Regression: production sends Discord-native raw PCM. Without the + WAV wrapper, Groq returns ``HTTP 400`` because ffmpeg can't demux a + headerless byte stream — observed in the 2026-04-28 game with 15 + consecutive ``groq_http_400`` failures. Assert the multipart + payload contains a real RIFF/WAV header.""" + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + if "groq" in url: + captured["body"] = request.read() + return httpx.Response(200, json={"text": "テスト"}) + return httpx.Response(200, json=_analyzer_json({})) + + transport = httpx.MockTransport(handler) + real = httpx.AsyncClient + + class _M(real): # type: ignore[misc, valid-type] + def __init__(self, *a: Any, **k: Any) -> None: + k["transport"] = transport + super().__init__(*a, **k) + + monkeypatch.setattr(httpx, "AsyncClient", _M) + + raw_pcm = b"\x00" * 9_600 # 50 ms of 48 kHz stereo + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", + groq_model="m", + analyzer_api_key="x", + analyzer_model="grok", + ) + await a.transcribe(audio=raw_pcm, language="ja-JP", timeout_s=5) + + body = captured["body"] + # Multipart form contains the file part; the wrapped audio MUST start + # with the standard ``RIFF`` magic and contain a ``WAVE`` marker. + assert b"RIFF" in body + assert b"WAVE" in body + # And the configured sample rate (48000 = 0x0000BB80 little-endian) + # appears in the fmt chunk. + assert b"\x80\xbb\x00\x00" in body + + async def test_language_strips_region_for_whisper(patch_httpx: Any) -> None: """Whisper accepts BCP-47 short codes (``ja``) not regional ones (``ja-JP``); the adapter must strip before sending.""" @@ -417,16 +497,36 @@ def __init__(self, *a: Any, **k: Any) -> None: assert b"ja-JP" not in body -async def test_duration_ms_estimated_from_pcm_size(patch_httpx: Any) -> None: - """A 1-second WAV (16000 samples * 2 bytes + 44-byte header) → ~1000 ms.""" +async def test_duration_ms_estimated_from_pcm_format(patch_httpx: Any) -> None: + """``duration_ms`` is computed from the configured PCM format (default + Discord native: 48 kHz stereo 16-bit). 1 second of audio = 192 000 + bytes.""" + patch_httpx({ + "https://api.groq.com/": {"status": 200, "json": {"text": "短く"}}, + "https://api.x.ai/": {"status": 200, "json": _analyzer_json({})}, + }) + one_second_native = b"\x00" * (48_000 * 2 * 2) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + r = await a.transcribe(audio=one_second_native, language="ja-JP", timeout_s=5) + assert 950 <= r.duration_ms <= 1050 + + +async def test_duration_ms_honors_overridden_pcm_format(patch_httpx: Any) -> None: + """A caller feeding 16 kHz mono PCM (e.g. a future down-mixer) + overrides the format kwargs and gets a correct duration.""" patch_httpx({ "https://api.groq.com/": {"status": 200, "json": {"text": "短く"}}, "https://api.x.ai/": {"status": 200, "json": _analyzer_json({})}, }) - one_second = b"\x00" * (44 + 16_000 * 2) + one_second_16k_mono = b"\x00" * (16_000 * 2) a = GroqWhisperAudioAnalyzer( groq_api_key="g", groq_model="m", analyzer_api_key="x", analyzer_model="grok", + pcm_sample_rate=16_000, + pcm_channels=1, ) - r = await a.transcribe(audio=one_second, language="ja-JP", timeout_s=5) + r = await a.transcribe(audio=one_second_16k_mono, language="ja-JP", timeout_s=5) assert 950 <= r.duration_ms <= 1050 From 22c5752ef667583fa9d222f1eb2c7cb79b9add20 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 16:47:51 +0900 Subject: [PATCH 037/133] fix(stt): use Whisper's no_speech_prob for confidence, not analyzer's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-step pipeline collapsed two unrelated signals into one: - Whisper's transcription confidence (Did the audio decode? Is it speech vs silence?) - Analyzer's "claim clarity" judgement (Is there a CO / vote intent in this transcript?) The analyzer legitimately returned ``confidence: 0.0`` for greetings ("おやすみなさい"), short reactions, and any utterance without a clear claim — and we surfaced that as ``SttResult.confidence``, silently failing the ``confidence_threshold=0.6`` gate in ``VoiceIngestService``. Result: the user spoke, Whisper transcribed correctly, the analyzer extracted nothing structured (correct), and the speech event was dropped before reaching the arbiter. Switch Groq to ``response_format=verbose_json`` (per-segment metadata) and derive ASR confidence from ``1 - max(no_speech_prob)``. Drop the ``confidence`` field from the analyzer prompt — the analyzer no longer needs to estimate something it can't see (it has only the transcript, not the audio). The trace now records the per-call ASR confidence in metadata so a chronic low-confidence pattern is debuggable from the JSONL. --- src/wolfbot/master/stt_service.py | 62 +++++++++++++---- tests/test_master_stt_groq.py | 107 ++++++++++++++++++++++++++++-- 2 files changed, 150 insertions(+), 19 deletions(-) diff --git a/src/wolfbot/master/stt_service.py b/src/wolfbot/master/stt_service.py index efbed49..1b972d2 100644 --- a/src/wolfbot/master/stt_service.py +++ b/src/wolfbot/master/stt_service.py @@ -429,7 +429,6 @@ class GroqWhisperAudioAnalyzer: "他の文字は含めないでください。\n\n" "{\n" ' "summary": "1文の要約(30文字以内)",\n' - ' "confidence": 0.95,\n' ' "co_claim": null,\n' ' "vote_target_seat": null,\n' ' "stance": {},\n' @@ -437,7 +436,6 @@ class GroqWhisperAudioAnalyzer: "}\n\n" "フィールド説明:\n" "- summary: 発言内容の1文要約\n" - "- confidence: 入力テキストから読み取れる主張の明確さ(0.0〜1.0)\n" f"- co_claim: 役職CO(自称)があれば {format_co_claim_options()}、なければ null\n" f"- vote_target_seat: 処刑対象として名指しした席番号({_seat_range_label()})、なければ null\n" "- stance: 言及した席への態度 {\"席番号\": \"positive\"/\"negative\"/\"neutral\"}\n" @@ -498,7 +496,9 @@ async def transcribe( sample_width=self.pcm_sample_width, ) - transcript = await self._whisper(wav_audio, language, effective_timeout) + transcript, asr_confidence = await self._whisper( + wav_audio, language, effective_timeout + ) if not transcript: return SttResult( text="", @@ -532,25 +532,37 @@ async def transcribe( import json as _json summary_str = _json.dumps(summary_dict, ensure_ascii=False) - try: - confidence = float(analysis.get("confidence", 0.9)) - except (TypeError, ValueError): - confidence = 0.9 return SttResult( text=transcript, - confidence=confidence, + # Use Whisper's ASR confidence (derived from + # ``no_speech_prob``), NOT the analyzer's "claim clarity" + # field — the latter legitimately returns 0.0 for greetings + # and short reactions, which would silently fail the + # ``confidence_threshold`` gate in ``VoiceIngestService`` + # and drop valid speech events. The analyzer's confidence + # was a legacy field from the multimodal Gemini path where + # both signals collapsed into one number. + confidence=asr_confidence, duration_ms=duration_ms, summary=summary_str, co_declaration=co_decl, addressed_name=addressed_name, ) - async def _whisper(self, audio: bytes, language: str, timeout: float) -> str: + async def _whisper( + self, audio: bytes, language: str, timeout: float + ) -> tuple[str, float]: """Step 1: POST audio to Groq's whisper transcription endpoint. - Uses ``response_format=verbose_json`` so we get a ``text`` field - plus per-segment confidence (we collapse to a single transcript). + Returns ``(transcript, asr_confidence)`` where ``asr_confidence`` + is derived from ``verbose_json``'s per-segment ``no_speech_prob`` + (a Whisper internal signal: probability that the segment is + actually silence/noise rather than speech). Aggregating over + segments gives a per-utterance ASR-side confidence that's + independent of the downstream analyzer LLM. ``no_speech_prob`` + is in [0, 1]; we report ``1 - max(no_speech_prob)`` so a single + bad segment lowers confidence appropriately. """ import httpx @@ -566,13 +578,17 @@ async def _whisper(self, audio: bytes, language: str, timeout: float) -> str: files: dict[str, tuple[str, bytes, str] | tuple[None, str]] = { "file": ("segment.wav", audio, "audio/wav"), "model": (None, self.groq_model), - "response_format": (None, "json"), + # ``verbose_json`` adds ``segments`` with ``no_speech_prob``; + # plain ``json`` only carries ``text``. The size overhead is + # ~1 KB per call, negligible vs the audio upload itself. + "response_format": (None, "verbose_json"), } if lang_code: files["language"] = (None, lang_code) timer = CallTimer() transcript = "" + confidence = 0.0 err: str | None = None # Capture the upstream error body so a recurring 400/4xx is # diagnosable from the trace alone (status code by itself didn't @@ -588,6 +604,20 @@ async def _whisper(self, audio: bytes, language: str, timeout: float) -> str: raise SttProviderError(err) resp_json = resp.json() transcript = (resp_json.get("text") or "").strip() + segments = resp_json.get("segments") or [] + # ``no_speech_prob`` may be missing on some segments + # (e.g. when the response shape varies per Groq build); + # fall back to a permissive 1.0 if the field is absent. + if segments: + worst_no_speech = max( + float(s.get("no_speech_prob") or 0.0) for s in segments + ) + confidence = max(0.0, min(1.0, 1.0 - worst_no_speech)) + else: + # No segments in the response (very short audio, or a + # response shape we don't recognize) — fall back to + # treating any returned transcript as confident. + confidence = 1.0 if transcript else 0.0 # Groq returns an `x_groq.id` etc. but no usage field for # whisper; leave tokens=None like the openai SDK does. except SttProviderError: @@ -612,9 +642,13 @@ async def _whisper(self, audio: bytes, language: str, timeout: float) -> str: latency_ms=timer.elapsed_ms, error=err, tokens=tokens, - extra={"audio_bytes": len(audio), "step": "transcribe"}, + extra={ + "audio_bytes": len(audio), + "step": "transcribe", + "asr_confidence": round(confidence, 3) if err is None else None, + }, ) - return transcript + return transcript, confidence async def _analyze(self, transcript: str, timeout: float) -> dict: # type: ignore[type-arg] """Step 2: ask the analyzer LLM to extract structured fields. diff --git a/tests/test_master_stt_groq.py b/tests/test_master_stt_groq.py index 2cc73c7..db75fde 100644 --- a/tests/test_master_stt_groq.py +++ b/tests/test_master_stt_groq.py @@ -114,19 +114,28 @@ def _analyzer_json(payload: dict[str, Any]) -> dict[str, Any]: } +def _whisper_verbose_json(text: str, no_speech_prob: float = 0.05) -> dict[str, Any]: + """Shape Groq's ``verbose_json`` response: top-level ``text`` plus a + single ``segments`` entry carrying ``no_speech_prob``. The transcribe + code path computes ASR confidence as ``1 - max(no_speech_prob)``.""" + return { + "text": text, + "segments": [{"text": text, "no_speech_prob": no_speech_prob}], + } + + async def test_full_pipeline_success_propagates_structured_fields( trace_dir: Path, patch_httpx: Any ) -> None: patch_httpx({ "https://api.groq.com/": { "status": 200, - "json": {"text": "席3が怪しいから投票する"}, + "json": _whisper_verbose_json("席3が怪しいから投票する", no_speech_prob=0.05), }, "https://api.x.ai/": { "status": 200, "json": _analyzer_json({ "summary": "席3への投票表明", - "confidence": 0.92, "co_claim": None, "vote_target_seat": 3, "stance": {"3": "negative"}, @@ -147,7 +156,8 @@ async def test_full_pipeline_success_propagates_structured_fields( result = await analyzer.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=10.0) assert result.text == "席3が怪しいから投票する" - assert result.confidence == pytest.approx(0.92) + # confidence = 1 - 0.05 = 0.95, surfaced from Whisper not the analyzer. + assert result.confidence == pytest.approx(0.95) assert result.summary == "席3への投票表明" assert result.co_declaration is None assert result.addressed_name is None @@ -163,11 +173,95 @@ async def test_full_pipeline_success_propagates_structured_fields( assert transcribe_row["model"] == "whisper-large-v3-turbo" assert transcribe_row["error"] is None assert "席3" in transcribe_row["response"] + assert transcribe_row["metadata"]["asr_confidence"] == pytest.approx(0.95) assert analyze_row["provider"] == "xai" assert analyze_row["model"] == "grok-4-1-fast-non-reasoning" assert analyze_row["tokens"] == {"prompt": 80, "completion": 30, "total": 110} +async def test_low_claim_confidence_does_not_drop_speech(patch_httpx: Any) -> None: + """Regression: the analyzer used to return ``confidence: 0.0`` for + greetings and short utterances ("おやすみなさい"); we previously + surfaced that as ``SttResult.confidence``, which made + ``VoiceIngestService`` drop the segment as ``stt_low_confidence`` + and the user's voice never reached the arbiter. ASR confidence + must come from Whisper's signal, not the analyzer's claim-clarity + judgement.""" + patch_httpx({ + "https://api.groq.com/": { + "status": 200, + "json": _whisper_verbose_json("おやすみなさい", no_speech_prob=0.02), + }, + # Analyzer returns no useful structured fields for a greeting, + # but that must not gate the speech event. + "https://api.x.ai/": { + "status": 200, + "json": _analyzer_json({ + "summary": "就寝の挨拶", + "co_claim": None, + "vote_target_seat": None, + "stance": {}, + "addressed_name": None, + }), + }, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + assert r.text == "おやすみなさい" + # 1 - 0.02 = 0.98; safely above any reasonable confidence_threshold. + assert r.confidence > 0.9 + + +async def test_high_no_speech_prob_lowers_confidence(patch_httpx: Any) -> None: + """When Whisper itself thinks the audio is mostly silence/noise, + confidence must drop so ``VoiceIngestService`` can filter via its + threshold. This is the legitimate use case for the gate — silence + bursts mis-detected as speech.""" + patch_httpx({ + "https://api.groq.com/": { + "status": 200, + "json": _whisper_verbose_json("ふぁ", no_speech_prob=0.85), + }, + "https://api.x.ai/": { + "status": 200, + "json": _analyzer_json({}), + }, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + # 1 - 0.85 = 0.15 + assert r.confidence == pytest.approx(0.15) + + +async def test_missing_segments_falls_back_to_full_confidence(patch_httpx: Any) -> None: + """If Groq's response omits ``segments`` (very short audio, future + response-shape change), we trust the transcript and report 1.0 — + losing data quietly is worse than over-trusting on rare edge cases.""" + patch_httpx({ + "https://api.groq.com/": { + "status": 200, + "json": {"text": "やぁ"}, # no segments key + }, + "https://api.x.ai/": { + "status": 200, + "json": _analyzer_json({}), + }, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + assert r.text == "やぁ" + assert r.confidence == pytest.approx(1.0) + + async def test_co_declaration_normalizes_to_known_roles(patch_httpx: Any) -> None: """Analyzer can return arbitrary strings for ``co_claim``; only the three accepted roles surface on ``SttResult``.""" @@ -356,7 +450,7 @@ async def test_analyzer_5xx_soft_fails_keeps_transcript( patch_httpx({ "https://api.groq.com/": { "status": 200, - "json": {"text": "占いCO 席7白"}, + "json": _whisper_verbose_json("占いCO 席7白", no_speech_prob=0.1), }, "https://api.x.ai/": {"status": 503, "text": "upstream"}, }) @@ -370,7 +464,10 @@ async def test_analyzer_5xx_soft_fails_keeps_transcript( r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) assert r.text == "占いCO 席7白" - assert r.confidence == pytest.approx(0.9) # default fallback + # ASR confidence comes from Whisper (1 - 0.1 = 0.9), not from the + # analyzer — analyzer outage doesn't change the transcription's + # confidence. + assert r.confidence == pytest.approx(0.9) assert r.summary is None assert r.co_declaration is None From 01e8c9bc656cfbbed5b334c0e14960d57398784a Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 16:55:55 +0900 Subject: [PATCH 038/133] feat(voice): per-segment audio + transcript dump for debugging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the audio path produces unexpected results (Whisper hallucinates, segments drop at the confidence gate, upstream returns 4xx), the operator needs to listen to the actual bytes that reached Whisper and read the resulting transcript side by side. Without this, debugging "why didn't the bot react to my voice?" requires re-running a game and tailing live logs. Add ``WOLFBOT_VOICE_DEBUG_DIR`` env var. When set, every segment — success, low-confidence, hard-fail, or unexpected-exception — writes ``//.wav`` (RIFF-wrapped PCM, playable in any audio tool) and ``.txt`` (transcript on the first line, then human-readable metadata). When unset, the dump is a no-op so production deployments leave the call site in place. Disk writes run inside ``asyncio.to_thread``; write failures are logged and swallowed so a flaky disk can never break the voice path. ``pcm_to_wav`` was renamed from a private helper to a public one since both stt_service and voice_debug_dump need it. --- src/wolfbot/master/stt_service.py | 4 +- src/wolfbot/master/voice_debug_dump.py | 164 ++++++++++++++ src/wolfbot/master/voice_ingest_service.py | 61 +++++- tests/test_voice_debug_dump.py | 239 +++++++++++++++++++++ 4 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 src/wolfbot/master/voice_debug_dump.py create mode 100644 tests/test_voice_debug_dump.py diff --git a/src/wolfbot/master/stt_service.py b/src/wolfbot/master/stt_service.py index 1b972d2..47b89ae 100644 --- a/src/wolfbot/master/stt_service.py +++ b/src/wolfbot/master/stt_service.py @@ -35,7 +35,7 @@ def _seat_range_label() -> str: return f"1〜{VILLAGE_SIZE}" -def _pcm_to_wav( +def pcm_to_wav( pcm: bytes, *, sample_rate: int = 48_000, @@ -489,7 +489,7 @@ async def transcribe( # Groq's whisper endpoint rejects headerless PCM. Wrap to WAV # using the configured PCM format so ffmpeg on Groq's side can # demux the stream. - wav_audio = _pcm_to_wav( + wav_audio = pcm_to_wav( audio, sample_rate=self.pcm_sample_rate, channels=self.pcm_channels, diff --git a/src/wolfbot/master/voice_debug_dump.py b/src/wolfbot/master/voice_debug_dump.py new file mode 100644 index 0000000..e7dc29e --- /dev/null +++ b/src/wolfbot/master/voice_debug_dump.py @@ -0,0 +1,164 @@ +"""Optional per-segment audio dump for debugging the voice pipeline. + +When ``WOLFBOT_VOICE_DEBUG_DIR`` is set, every voice segment Master +processes — successful, low-confidence, or hard-failed — is written +to disk so an operator can: + +1. Listen to the raw audio that was sent to Whisper +2. Read the transcript / structured analysis next to it +3. Diagnose hallucinations, dropped segments, mid-segment corruption + events without needing to re-run a game + +Layout:: + + $WOLFBOT_VOICE_DEBUG_DIR/ + {game_id}/ + seg_{id}.wav # 48 kHz stereo 16-bit (Discord native) + seg_{id}.txt # transcript + metadata, paired with the .wav + +Disabled by default — without the env var set, dumping is a no-op so +production deployments can leave the call site in place. File writes +run in a worker thread (``asyncio.to_thread``) so the audio path +never blocks on local disk. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import re +from dataclasses import dataclass +from pathlib import Path + +from wolfbot.master.stt_service import SttResult, pcm_to_wav + +log = logging.getLogger(__name__) + +_SAFE_RE = re.compile(r"[^A-Za-z0-9_-]+") + + +@dataclass(frozen=True) +class SegmentDumpRecord: + """Everything the dump file needs to render a useful debug page. + + ``transcript`` and ``analysis`` are populated on success / partial + success; ``failure_reason`` carries the canonical voice-ingest + enum (``stt_provider_error`` / ``stt_low_confidence`` / etc.) when + the segment didn't make it through. The two are not mutually + exclusive — a low-confidence segment still has a transcript worth + inspecting. + """ + + game_id: str + phase_id: str + segment_id: str + seat_no: int + speaker_user_id: str + audio_start_ms: int + audio_end_ms: int + pcm_sample_rate: int + pcm_channels: int + pcm_sample_width: int + audio_bytes: int + result: SttResult | None = None + failure_reason: str | None = None + + +def debug_dir() -> Path | None: + """Directory configured via ``WOLFBOT_VOICE_DEBUG_DIR``, or ``None``.""" + raw = os.environ.get("WOLFBOT_VOICE_DEBUG_DIR") + return Path(raw) if raw else None + + +def _sanitize(s: str) -> str: + """Sanitize a single path component (game_id or segment_id).""" + cleaned = _SAFE_RE.sub("_", s).strip("_") + return cleaned or "x" + + +def _format_txt(record: SegmentDumpRecord) -> str: + """Build a human-readable .txt sidecar for the matching .wav. + + Plain text rather than JSON so the transcript line is what an + operator sees first when they open the file in Finder/quick-look — + they're typically debugging "did Whisper hear me say X?" and the + answer should not be buried under metadata fields. + """ + duration_s = (record.audio_end_ms - record.audio_start_ms) / 1000.0 + lines: list[str] = [] + if record.result is not None and record.result.text: + lines.append(f"transcript: {record.result.text}") + elif record.failure_reason: + lines.append(f"transcript: ") + else: + lines.append("transcript: ") + lines.append("") + lines.append(f"game_id : {record.game_id}") + lines.append(f"phase_id : {record.phase_id}") + lines.append(f"segment_id : {record.segment_id}") + lines.append(f"seat_no : {record.seat_no}") + lines.append(f"speaker_uid : {record.speaker_user_id}") + lines.append(f"audio_window : {record.audio_start_ms} → {record.audio_end_ms} ms ({duration_s:.2f}s)") + lines.append( + f"pcm_format : {record.pcm_sample_rate}Hz " + f"{record.pcm_channels}ch {record.pcm_sample_width * 8}bit" + ) + lines.append(f"audio_bytes : {record.audio_bytes}") + if record.result is not None: + lines.append(f"asr_conf : {record.result.confidence:.3f}") + lines.append(f"duration_ms : {record.result.duration_ms}") + if record.result.summary: + lines.append(f"summary : {record.result.summary}") + if record.result.co_declaration: + lines.append(f"co_declaration: {record.result.co_declaration}") + if record.result.addressed_name: + lines.append(f"addressed_name: {record.result.addressed_name}") + if record.failure_reason and not ( + record.result is not None and record.result.text + ): + lines.append(f"failure_reason: {record.failure_reason}") + return "\n".join(lines) + "\n" + + +async def dump_segment(record: SegmentDumpRecord, pcm: bytes) -> None: + """Write ``seg_.wav`` and ``seg_.txt`` to the debug dir. + + No-op when the debug dir env var is unset, so call sites can leave + this in place unconditionally. Write failures are logged and + swallowed — debug dumping must never break the voice path. + """ + base = debug_dir() + if base is None: + return + try: + wav = pcm_to_wav( + pcm, + sample_rate=record.pcm_sample_rate, + channels=record.pcm_channels, + sample_width=record.pcm_sample_width, + ) + txt = _format_txt(record) + game_dir = base / _sanitize(record.game_id) + seg_stem = _sanitize(record.segment_id) + await asyncio.to_thread(_write_pair, game_dir, seg_stem, wav, txt) + except Exception: + log.exception( + "voice_debug_dump_failed game=%s segment=%s", + record.game_id, + record.segment_id, + ) + + +def _write_pair(game_dir: Path, seg_stem: str, wav: bytes, txt: str) -> None: + """Synchronous file writer — runs inside ``asyncio.to_thread``.""" + game_dir.mkdir(parents=True, exist_ok=True) + (game_dir / f"{seg_stem}.wav").write_bytes(wav) + (game_dir / f"{seg_stem}.txt").write_text(txt, encoding="utf-8") + + +__all__ = [ + "SegmentDumpRecord", + "debug_dir", + "dump_segment", +] diff --git a/src/wolfbot/master/voice_ingest_service.py b/src/wolfbot/master/voice_ingest_service.py index 8cf74b3..fcd2a08 100644 --- a/src/wolfbot/master/voice_ingest_service.py +++ b/src/wolfbot/master/voice_ingest_service.py @@ -58,6 +58,13 @@ class VoiceIngestConfig: stt_language: str = "ja-JP" vad_finalization_timeout_ms: int = 4000 heartbeat_interval_s: float = 5.0 + # PCM format of bytes accumulated in ``_OpenSegment.audio_buffer``. + # Defaults match discord-ext-voice_recv's opus decoder output and + # are surfaced here so the optional debug dump produces a playable + # WAV without round-tripping through STT-provider config. + pcm_sample_rate: int = 48_000 + pcm_channels: int = 2 + pcm_sample_width: int = 2 @dataclass @@ -237,9 +244,42 @@ async def _run_stt_inner( *, audio_end_ms: int, ) -> None: + from wolfbot.master.voice_debug_dump import ( + SegmentDumpRecord, + debug_dir, + dump_segment, + ) + + # Snapshot the audio bytes once. The buffer is later truncated by + # ``end_segment`` cleanup; capturing here keeps the debug dump + # aligned with what was actually sent to Whisper. + pcm_snapshot = bytes(seg.audio_buffer) + dump_enabled = debug_dir() is not None + + def _build_dump( + *, + result: SttResult | None, + failure_reason: str | None, + ) -> SegmentDumpRecord: + return SegmentDumpRecord( + game_id=game_id, + phase_id=phase_id, + segment_id=seg.segment_id, + seat_no=seg.seat_no, + speaker_user_id=seg.speaker_user_id, + audio_start_ms=seg.audio_start_ms, + audio_end_ms=audio_end_ms, + pcm_sample_rate=self.config.pcm_sample_rate, + pcm_channels=self.config.pcm_channels, + pcm_sample_width=self.config.pcm_sample_width, + audio_bytes=len(pcm_snapshot), + result=result, + failure_reason=failure_reason, + ) + try: result: SttResult = await self.stt.transcribe( - audio=bytes(seg.audio_buffer), + audio=pcm_snapshot, language=self.config.stt_language, timeout_s=self.config.stt_timeout_s, ) @@ -252,6 +292,11 @@ async def _run_stt_inner( seg.segment_id, exc.failure_reason, ) + if dump_enabled: + await dump_segment( + _build_dump(result=None, failure_reason=exc.failure_reason), + pcm_snapshot, + ) await self.master_client.send_stt_failed( SttFailed( ts=self._now_ms(), @@ -271,6 +316,11 @@ async def _run_stt_inner( game_id, seg.segment_id, ) + if dump_enabled: + await dump_segment( + _build_dump(result=None, failure_reason="stt_provider_error"), + pcm_snapshot, + ) await self.master_client.send_stt_failed( SttFailed( ts=self._now_ms(), @@ -293,6 +343,11 @@ async def _run_stt_inner( seg.segment_id, result.confidence, ) + if dump_enabled: + await dump_segment( + _build_dump(result=result, failure_reason="stt_low_confidence"), + pcm_snapshot, + ) await self.master_client.send_stt_failed( SttFailed( ts=self._now_ms(), @@ -312,6 +367,10 @@ async def _run_stt_inner( if result.co_declaration in CO_CLAIM_VALUES else None ) + if dump_enabled: + await dump_segment( + _build_dump(result=result, failure_reason=None), pcm_snapshot + ) await self.master_client.send_speech_event_payload( SpeechEventPayload( ts=self._now_ms(), diff --git a/tests/test_voice_debug_dump.py b/tests/test_voice_debug_dump.py new file mode 100644 index 0000000..f4f4446 --- /dev/null +++ b/tests/test_voice_debug_dump.py @@ -0,0 +1,239 @@ +"""Tests for the optional voice-segment debug dump. + +Verifies the on/off toggle, file naming, the .wav being a real WAV +(playable in any audio tool), the .txt sidecar's human-readable +contents, and behaviour on the four STT outcome paths (success / +low-confidence / hard-fail / unexpected exception). Also asserts that +write failures don't propagate to the caller — a flaky disk must +never break the voice path. +""" + +from __future__ import annotations + +import wave +from pathlib import Path + +import pytest + +from wolfbot.master.stt_service import SttResult +from wolfbot.master.voice_debug_dump import ( + SegmentDumpRecord, + debug_dir, + dump_segment, +) + + +def _record( + *, + game_id: str = "g_dbg", + segment_id: str = "seg_001", + seat_no: int = 1, + audio_bytes: int = 9_600, + result: SttResult | None = None, + failure_reason: str | None = None, +) -> SegmentDumpRecord: + return SegmentDumpRecord( + game_id=game_id, + phase_id=f"{game_id}::day1::DAY_DISCUSSION::1", + segment_id=segment_id, + seat_no=seat_no, + speaker_user_id="753109683971293296", + audio_start_ms=1_700_000_000_000, + audio_end_ms=1_700_000_001_000, + pcm_sample_rate=48_000, + pcm_channels=2, + pcm_sample_width=2, + audio_bytes=audio_bytes, + result=result, + failure_reason=failure_reason, + ) + + +@pytest.fixture +def enabled_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("WOLFBOT_VOICE_DEBUG_DIR", str(tmp_path)) + return tmp_path + + +async def test_disabled_when_env_unset( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Without the env var, ``dump_segment`` must touch nothing — the + call site is unconditional, so any disk I/O here would land in + production logs.""" + monkeypatch.delenv("WOLFBOT_VOICE_DEBUG_DIR", raising=False) + assert debug_dir() is None + await dump_segment(_record(), pcm=b"\x00" * 4_000) + assert list(tmp_path.iterdir()) == [] + + +async def test_writes_wav_and_txt_pair_on_success(enabled_dir: Path) -> None: + """Both files land under ``//`` with the segment id as + a shared stem, so an operator scanning Finder sees ``.wav`` / + ``.txt`` next to each other.""" + pcm = b"\x01\x02\x03\x04" * 1_000 # 4 KB ≈ 20 ms @ 48 kHz stereo + result = SttResult( + text="席3が怪しい", + confidence=0.92, + duration_ms=21, + summary="席3への投票表明", + co_declaration=None, + addressed_name=None, + ) + await dump_segment(_record(result=result), pcm=pcm) + + wav_path = enabled_dir / "g_dbg" / "seg_001.wav" + txt_path = enabled_dir / "g_dbg" / "seg_001.txt" + assert wav_path.exists() and txt_path.exists() + + +async def test_dumped_wav_is_a_real_wav_file(enabled_dir: Path) -> None: + """A reader using stdlib ``wave`` must accept the file — confirms + the WAV header isn't garbage and the format matches what we wrote.""" + pcm = b"\x00\x01" * 9_600 # ~50 ms @ 48 kHz stereo + await dump_segment(_record(audio_bytes=len(pcm)), pcm=pcm) + + wav_path = enabled_dir / "g_dbg" / "seg_001.wav" + with wave.open(str(wav_path), "rb") as r: + assert r.getframerate() == 48_000 + assert r.getnchannels() == 2 + assert r.getsampwidth() == 2 + # frames = bytes / (channels * sample_width); for our 19_200 byte + # blob that's 19_200 / 4 = 4_800. + assert r.getnframes() == len(pcm) // 4 + + +async def test_txt_leads_with_transcript_for_quick_inspection( + enabled_dir: Path, +) -> None: + """Operator's first line of business is "did Whisper hear me say + X?" — keep the answer at the top of the file.""" + result = SttResult( + text="占いCO 席7白", + confidence=0.95, + duration_ms=2_000, + summary="占いCO", + co_declaration="seer", + addressed_name=None, + ) + await dump_segment(_record(result=result), pcm=b"\x00" * 4_000) + body = (enabled_dir / "g_dbg" / "seg_001.txt").read_text(encoding="utf-8") + first = body.splitlines()[0] + assert first == "transcript: 占いCO 席7白" + assert "asr_conf : 0.950" in body + assert "co_declaration: seer" in body + + +async def test_low_confidence_path_dumps_with_failure_reason( + enabled_dir: Path, +) -> None: + """A low-confidence segment carries BOTH the (suspicious) transcript + and the failure reason — operator wants to listen to the audio and + see whether Whisper hallucinated, and the gate that suppressed the + speech event needs to be visible.""" + result = SttResult( + text="ご視聴ありがとうございました", + confidence=0.05, + duration_ms=500, + summary=None, + co_declaration=None, + addressed_name=None, + ) + await dump_segment( + _record(result=result, failure_reason="stt_low_confidence"), + pcm=b"\x00" * 4_000, + ) + body = (enabled_dir / "g_dbg" / "seg_001.txt").read_text(encoding="utf-8") + assert "transcript: ご視聴ありがとうございました" in body + assert "asr_conf : 0.050" in body + + +async def test_hard_failure_path_dumps_audio_with_failure_reason( + enabled_dir: Path, +) -> None: + """When STT itself errored we have no transcript, but the audio is + the most valuable artifact — let the operator listen to it and + confirm what the upstream provider was rejecting.""" + await dump_segment( + _record(result=None, failure_reason="groq_http_400"), + pcm=b"\x00" * 4_000, + ) + body = (enabled_dir / "g_dbg" / "seg_001.txt").read_text(encoding="utf-8") + first = body.splitlines()[0] + assert first == "transcript: " + assert "failure_reason: groq_http_400" in body + + +async def test_path_components_sanitized(enabled_dir: Path) -> None: + """Game IDs and segment IDs come from external sources; a malicious + or buggy id must not escape the debug directory.""" + rec = _record(game_id="../escape/../danger", segment_id="../etc/passwd") + await dump_segment(rec, pcm=b"\x00" * 100) + # Nothing should have been written outside our tmp dir + assert not (enabled_dir.parent / "escape").exists() + # And the sanitized files DO exist somewhere under our tmp dir + found = list(enabled_dir.rglob("*.wav")) + assert len(found) == 1 + assert ".." not in str(found[0]) + + +async def test_write_failure_swallowed( + enabled_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A flaky disk (or read-only mount) must never break the voice + path — the dump is best-effort observability only.""" + import wolfbot.master.voice_debug_dump as mod + + def boom(*_a: object, **_kw: object) -> None: + raise OSError("disk full") + + monkeypatch.setattr(mod, "_write_pair", boom) + # Should not raise. + await dump_segment(_record(), pcm=b"\x00" * 100) + + +async def test_overrides_pcm_format_for_downsampled_callers( + enabled_dir: Path, +) -> None: + """A future caller that down-mixes to 16 kHz mono before dump can + pass non-default format kwargs and the WAV reflects them.""" + rec = SegmentDumpRecord( + game_id="g_dbg", + phase_id="g_dbg::day1::DAY_DISCUSSION::1", + segment_id="seg_mono", + seat_no=1, + speaker_user_id="u", + audio_start_ms=0, + audio_end_ms=1_000, + pcm_sample_rate=16_000, + pcm_channels=1, + pcm_sample_width=2, + audio_bytes=32_000, + result=None, + ) + await dump_segment(rec, pcm=b"\x00" * 32_000) + with wave.open(str(enabled_dir / "g_dbg" / "seg_mono.wav"), "rb") as r: + assert r.getframerate() == 16_000 + assert r.getnchannels() == 1 + + +async def test_dumped_txt_is_human_readable_not_json(enabled_dir: Path) -> None: + """Format is plain ``key: value`` lines, not JSON — operator-friendly + when opened in a viewer that doesn't pretty-print JSON. The first + line is always ``transcript: ...`` (or a clear failure marker).""" + await dump_segment( + _record( + result=SttResult( + text="hi", + confidence=1.0, + duration_ms=100, + ), + ), + pcm=b"\x00" * 100, + ) + body = (enabled_dir / "g_dbg" / "seg_001.txt").read_text(encoding="utf-8") + # No JSON braces should appear at the top level. + assert not body.startswith("{") + # The structure is colon-delimited key/value lines. + assert "transcript: hi" in body + assert "audio_window : " in body From dabc9592528b9700b65daa4ded6813b802f2afc1 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 16:59:51 +0900 Subject: [PATCH 039/133] feat(voice): include full analyzer JSON in debug dump .txt sidecar The dump exposed only ``summary`` / ``co_declaration`` / ``addressed_name`` from ``SttResult``, dropping ``vote_target_seat`` and ``stance`` on the floor. Operator debugging "did the analyzer correctly extract the vote intent?" had to cross-reference the JSONL trace. Add ``raw_analysis: dict | None`` to ``SttResult`` (populated by both the Groq two-step pipeline and the legacy Gemini multimodal path) and pretty-print it under an ``analysis (raw):`` block at the bottom of each .txt. Japanese values stay unescaped for readability; the headline ``transcript: ...`` line is still first so the answer to "what did Whisper hear?" remains obvious at a glance. --- src/wolfbot/master/stt_service.py | 13 ++++++- src/wolfbot/master/voice_debug_dump.py | 14 +++++++ tests/test_master_stt_groq.py | 6 +++ tests/test_voice_debug_dump.py | 54 ++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/wolfbot/master/stt_service.py b/src/wolfbot/master/stt_service.py index 47b89ae..72dfa6d 100644 --- a/src/wolfbot/master/stt_service.py +++ b/src/wolfbot/master/stt_service.py @@ -19,7 +19,7 @@ import logging from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Protocol, runtime_checkable +from typing import Any, Protocol, runtime_checkable from wolfbot.domain.enums import ( CO_CLAIM_VALUES, @@ -80,6 +80,14 @@ class SttResult: extracted from the utterance by providers that infer it (Gemini's `co_claim`). Authoritative when set; otherwise the discussion service falls back to substring matching on `text` for legacy compatibility. + + `raw_analysis` carries the full parsed JSON dict from the analyzer + LLM (or the multimodal analyzer's structured output). This is + pure debug visibility — production code paths read the typed + fields above. The optional debug dump uses it to surface + ``vote_target_seat``, ``stance``, and any future analyzer fields + in the ``.txt`` sidecar without needing a separate cross-reference + to the JSONL trace. """ text: str @@ -88,6 +96,7 @@ class SttResult: summary: str | None = None co_declaration: str | None = None addressed_name: str | None = None + raw_analysis: dict[str, Any] | None = None class SttProviderError(RuntimeError): @@ -349,6 +358,7 @@ async def transcribe( summary=summary_str, co_declaration=co_declaration, addressed_name=addressed_name, + raw_analysis=parsed or None, ) except SttProviderError: @@ -548,6 +558,7 @@ async def transcribe( summary=summary_str, co_declaration=co_decl, addressed_name=addressed_name, + raw_analysis=analysis or None, ) async def _whisper( diff --git a/src/wolfbot/master/voice_debug_dump.py b/src/wolfbot/master/voice_debug_dump.py index e7dc29e..2272ce4 100644 --- a/src/wolfbot/master/voice_debug_dump.py +++ b/src/wolfbot/master/voice_debug_dump.py @@ -84,7 +84,15 @@ def _format_txt(record: SegmentDumpRecord) -> str: operator sees first when they open the file in Finder/quick-look — they're typically debugging "did Whisper hear me say X?" and the answer should not be buried under metadata fields. + + The full analyzer-LLM JSON output is appended at the bottom under + ``analysis (raw):`` so structured fields like ``vote_target_seat`` + and ``stance`` are visible without cross-referencing the JSONL + trace. Pretty-printed with ``ensure_ascii=False`` so Japanese + field values stay readable. """ + import json + duration_s = (record.audio_end_ms - record.audio_start_ms) / 1000.0 lines: list[str] = [] if record.result is not None and record.result.text: @@ -118,6 +126,12 @@ def _format_txt(record: SegmentDumpRecord) -> str: record.result is not None and record.result.text ): lines.append(f"failure_reason: {record.failure_reason}") + if record.result is not None and record.result.raw_analysis: + lines.append("") + lines.append("analysis (raw):") + lines.append( + json.dumps(record.result.raw_analysis, ensure_ascii=False, indent=2) + ) return "\n".join(lines) + "\n" diff --git a/tests/test_master_stt_groq.py b/tests/test_master_stt_groq.py index db75fde..1d1502b 100644 --- a/tests/test_master_stt_groq.py +++ b/tests/test_master_stt_groq.py @@ -161,6 +161,12 @@ async def test_full_pipeline_success_propagates_structured_fields( assert result.summary == "席3への投票表明" assert result.co_declaration is None assert result.addressed_name is None + # ``raw_analysis`` carries the full analyzer JSON for debug + # visibility — fields the typed result drops (vote_target_seat / + # stance) must round-trip here. + assert result.raw_analysis is not None + assert result.raw_analysis["vote_target_seat"] == 3 + assert result.raw_analysis["stance"] == {"3": "negative"} # 4000 PCM bytes at Discord's native 48 kHz stereo 16-bit # (192_000 bytes/sec) ≈ 20 ms. assert 15 < result.duration_ms < 30 diff --git a/tests/test_voice_debug_dump.py b/tests/test_voice_debug_dump.py index f4f4446..e77e1e6 100644 --- a/tests/test_voice_debug_dump.py +++ b/tests/test_voice_debug_dump.py @@ -124,6 +124,60 @@ async def test_txt_leads_with_transcript_for_quick_inspection( assert "co_declaration: seer" in body +async def test_raw_analyzer_json_appended_for_full_visibility( + enabled_dir: Path, +) -> None: + """The analyzer LLM emits more fields than ``SttResult`` exposes + (vote_target_seat, stance, future fields). Operator wants to see + ALL of them in the .txt to validate the analysis without + cross-referencing the JSONL trace.""" + result = SttResult( + text="席3が怪しいから投票", + confidence=0.95, + duration_ms=2_000, + summary="席3への投票表明", + co_declaration=None, + addressed_name=None, + raw_analysis={ + "summary": "席3への投票表明", + "co_claim": None, + "vote_target_seat": 3, + "stance": {"3": "negative", "5": "positive"}, + "addressed_name": None, + }, + ) + await dump_segment(_record(result=result), pcm=b"\x00" * 4_000) + body = (enabled_dir / "g_dbg" / "seg_001.txt").read_text(encoding="utf-8") + # First line is still the transcript — analyzer fields don't push + # the headline answer below the fold. + assert body.splitlines()[0] == "transcript: 席3が怪しいから投票" + # The raw analyzer JSON section must include fields that aren't + # surfaced via SttResult's typed fields. + assert "analysis (raw):" in body + assert '"vote_target_seat": 3' in body + assert '"stance":' in body + assert '"3": "negative"' in body + # Japanese values are NOT escaped to ASCII (\uXXXX) — operator + # readability matters more than wire compactness here. + assert "席3への投票表明" in body + + +async def test_raw_analyzer_section_omitted_when_no_analysis( + enabled_dir: Path, +) -> None: + """If ``raw_analysis`` is None (e.g. STT-only provider, hard + failure), the section is omitted entirely — no empty headers.""" + result = SttResult( + text="hi", + confidence=1.0, + duration_ms=100, + raw_analysis=None, + ) + await dump_segment(_record(result=result), pcm=b"\x00" * 100) + body = (enabled_dir / "g_dbg" / "seg_001.txt").read_text(encoding="utf-8") + assert "analysis (raw):" not in body + + async def test_low_confidence_path_dumps_with_failure_reason( enabled_dir: Path, ) -> None: From 2f86b7ddaeddafcd598730457c1fe212718a3a67 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 20:38:42 +0900 Subject: [PATCH 040/133] fix(voice): pad opus gaps with silence + group dumps by speaker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord stops sending opus packets during speaker pauses, so WolfbotAudioSink was concatenating only the spoken frames — the resulting WAV played back time-compressed (audio_window 2.22s ⇒ PCM duration 1.39s in observed dumps), Whisper saw garbled audio and fell back to its boilerplate hallucinations. Wrap the sink with voice_recv.SilenceGeneratorSink so the library injects 20ms silence frames during transmission downtime. Also restructure the debug dump layout from /seg_.{wav,txt} to //seg_... so an operator can browse one player's full transcript history in a single directory. display_name is captured at speaking_start (before member is GC'd), threaded through begin_segment → _OpenSegment → SegmentDumpRecord, and sanitised with a unicode-preserving rule that still blocks Windows-illegal chars and path traversal. Falls back to the Discord user ID when display_name is unknown. --- src/wolfbot/main.py | 10 ++- src/wolfbot/master/audio_sink.py | 10 ++- src/wolfbot/master/voice_debug_dump.py | 59 ++++++++++++-- src/wolfbot/master/voice_ingest_service.py | 15 +++- tests/test_voice_debug_dump.py | 90 ++++++++++++++++++---- 5 files changed, 160 insertions(+), 24 deletions(-) diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 9337c10..53e4107 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -180,7 +180,15 @@ async def _master_join_vc_for_game(game: Any) -> None: sink = WolfbotAudioSink( voice_ingest, loop=asyncio.get_running_loop() ) - vc_client.listen(sink) + # Wrap with SilenceGeneratorSink so gaps in Discord's + # opus stream (the user pausing mid-utterance) get + # padded with synthetic 20ms silence frames. Without + # this, our PCM buffer concatenates only the spoken + # frames and the resulting WAV plays back time- + # compressed — Whisper sees garbled audio and falls + # back to its boilerplate hallucinations + # ("ご視聴ありがとうございました"). + vc_client.listen(voice_recv.SilenceGeneratorSink(sink)) master_vc_ref[0] = vc_client log.info( "master_vc_joined channel=%s game=%s", channel_id, game.id diff --git a/src/wolfbot/master/audio_sink.py b/src/wolfbot/master/audio_sink.py index a75ef50..6b69831 100644 --- a/src/wolfbot/master/audio_sink.py +++ b/src/wolfbot/master/audio_sink.py @@ -62,8 +62,16 @@ def cleanup(self) -> None: @voice_recv.AudioSink.listener() # type: ignore[untyped-decorator] def on_voice_member_speaking_start(self, member: discord.Member) -> None: uid = str(member.id) + # Snapshot the display name now — by the time the coroutine + # runs the member object may have been GC'd or had its nick + # changed, and we want the name as it was when speech began. + display_name = getattr(member, "display_name", None) or getattr( + member, "name", None + ) asyncio.run_coroutine_threadsafe( - self._ingest.begin_segment(speaker_user_id=uid), + self._ingest.begin_segment( + speaker_user_id=uid, display_name=display_name + ), self._loop, ) diff --git a/src/wolfbot/master/voice_debug_dump.py b/src/wolfbot/master/voice_debug_dump.py index 2272ce4..867bf95 100644 --- a/src/wolfbot/master/voice_debug_dump.py +++ b/src/wolfbot/master/voice_debug_dump.py @@ -13,8 +13,13 @@ $WOLFBOT_VOICE_DEBUG_DIR/ {game_id}/ - seg_{id}.wav # 48 kHz stereo 16-bit (Discord native) - seg_{id}.txt # transcript + metadata, paired with the .wav + {speaker_name}/ + seg_{id}.wav # 48 kHz stereo 16-bit (Discord native) + seg_{id}.txt # transcript + metadata, paired with the .wav + +The speaker subdirectory uses ``display_name`` when known so an operator +can browse the dumps grouped by player; falls back to the Discord user +ID when the display_name isn't available. Disabled by default — without the env var set, dumping is a no-op so production deployments can leave the call site in place. File writes @@ -37,6 +42,11 @@ _SAFE_RE = re.compile(r"[^A-Za-z0-9_-]+") +# Username sanitizer — preserves unicode (so "🌙 セツ" stays readable) +# and only strips characters that are unsafe on Windows / macOS / Linux +# filesystems or that would create traversal hazards. +_UNSAFE_PATH_CHARS = re.compile(r"[<>:\"/\\|?*\x00-\x1f]+") + @dataclass(frozen=True) class SegmentDumpRecord: @@ -61,6 +71,7 @@ class SegmentDumpRecord: pcm_channels: int pcm_sample_width: int audio_bytes: int + display_name: str | None = None result: SttResult | None = None failure_reason: str | None = None @@ -77,6 +88,33 @@ def _sanitize(s: str) -> str: return cleaned or "x" +def _sanitize_username(name: str | None, fallback: str) -> str: + """Sanitize a display_name for use as a directory component. + + Lenient compared to :func:`_sanitize` — preserves Japanese / emoji + so the dump tree stays human-browsable. Replaces only characters + that are illegal on common filesystems or that would enable path + traversal. Falls back to ``fallback`` (typically the Discord user + ID) when the input is empty / None / sanitizes to nothing. + """ + if not name: + return _sanitize(fallback) + # Drop unsafe chars first so traversal segments like "../" can't + # survive even if the rest of the string is harmless. + cleaned = _UNSAFE_PATH_CHARS.sub("_", name) + # Strip leading/trailing dots and whitespace — `.` and ` ` directory + # names are legal but visually confusing and (on Windows) trimmed + # silently by the OS, which would collapse two distinct names. + cleaned = cleaned.strip(" .\t\n\r") + # Defensive: any residual ".." sequences get neutralised. + cleaned = cleaned.replace("..", "_") + # Keep filenames at a sane length — most filesystems cap each + # component at 255 bytes; UTF-8 Japanese is 3 bytes/char. + if len(cleaned.encode("utf-8")) > 80: + cleaned = cleaned[:40] + return cleaned or _sanitize(fallback) + + def _format_txt(record: SegmentDumpRecord) -> str: """Build a human-readable .txt sidecar for the matching .wav. @@ -107,6 +145,8 @@ def _format_txt(record: SegmentDumpRecord) -> str: lines.append(f"segment_id : {record.segment_id}") lines.append(f"seat_no : {record.seat_no}") lines.append(f"speaker_uid : {record.speaker_user_id}") + if record.display_name: + lines.append(f"speaker_name : {record.display_name}") lines.append(f"audio_window : {record.audio_start_ms} → {record.audio_end_ms} ms ({duration_s:.2f}s)") lines.append( f"pcm_format : {record.pcm_sample_rate}Hz " @@ -153,9 +193,12 @@ async def dump_segment(record: SegmentDumpRecord, pcm: bytes) -> None: sample_width=record.pcm_sample_width, ) txt = _format_txt(record) - game_dir = base / _sanitize(record.game_id) + speaker_dir = _sanitize_username( + record.display_name, fallback=record.speaker_user_id + ) + target_dir = base / _sanitize(record.game_id) / speaker_dir seg_stem = _sanitize(record.segment_id) - await asyncio.to_thread(_write_pair, game_dir, seg_stem, wav, txt) + await asyncio.to_thread(_write_pair, target_dir, seg_stem, wav, txt) except Exception: log.exception( "voice_debug_dump_failed game=%s segment=%s", @@ -164,11 +207,11 @@ async def dump_segment(record: SegmentDumpRecord, pcm: bytes) -> None: ) -def _write_pair(game_dir: Path, seg_stem: str, wav: bytes, txt: str) -> None: +def _write_pair(target_dir: Path, seg_stem: str, wav: bytes, txt: str) -> None: """Synchronous file writer — runs inside ``asyncio.to_thread``.""" - game_dir.mkdir(parents=True, exist_ok=True) - (game_dir / f"{seg_stem}.wav").write_bytes(wav) - (game_dir / f"{seg_stem}.txt").write_text(txt, encoding="utf-8") + target_dir.mkdir(parents=True, exist_ok=True) + (target_dir / f"{seg_stem}.wav").write_bytes(wav) + (target_dir / f"{seg_stem}.txt").write_text(txt, encoding="utf-8") __all__ = [ diff --git a/src/wolfbot/master/voice_ingest_service.py b/src/wolfbot/master/voice_ingest_service.py index fcd2a08..4838c6e 100644 --- a/src/wolfbot/master/voice_ingest_service.py +++ b/src/wolfbot/master/voice_ingest_service.py @@ -75,6 +75,7 @@ class _OpenSegment: seat_no: int speaker_user_id: str audio_start_ms: int + display_name: str | None = None audio_buffer: bytearray = field(default_factory=bytearray) @@ -142,9 +143,19 @@ async def handle_voice_packet(self, *, speaker_user_id: str, pcm: bytes) -> bool # ---------------------------------------------------------- VAD lifecycle - async def begin_segment(self, *, speaker_user_id: str) -> str | None: + async def begin_segment( + self, + *, + speaker_user_id: str, + display_name: str | None = None, + ) -> str | None: """Open a VAD window for `speaker_user_id` and notify Master. + ``display_name`` is the speaker's Discord display name as known + at VAD-start time. It's stored on the segment so the optional + debug dump can group files per-speaker by name. Optional — + unit tests typically don't pass it. + Returns the new `segment_id`, or None if a session is not active (no game phase / unknown seat). """ @@ -164,6 +175,7 @@ async def begin_segment(self, *, speaker_user_id: str) -> str | None: seat_no=seat_no, speaker_user_id=speaker_user_id, audio_start_ms=now, + display_name=display_name, ) msg = VadSpeechStarted( ts=now, @@ -273,6 +285,7 @@ def _build_dump( pcm_channels=self.config.pcm_channels, pcm_sample_width=self.config.pcm_sample_width, audio_bytes=len(pcm_snapshot), + display_name=seg.display_name, result=result, failure_reason=failure_reason, ) diff --git a/tests/test_voice_debug_dump.py b/tests/test_voice_debug_dump.py index e77e1e6..367c9d9 100644 --- a/tests/test_voice_debug_dump.py +++ b/tests/test_voice_debug_dump.py @@ -29,6 +29,7 @@ def _record( segment_id: str = "seg_001", seat_no: int = 1, audio_bytes: int = 9_600, + display_name: str | None = "🌙 セツ", result: SttResult | None = None, failure_reason: str | None = None, ) -> SegmentDumpRecord: @@ -44,6 +45,7 @@ def _record( pcm_channels=2, pcm_sample_width=2, audio_bytes=audio_bytes, + display_name=display_name, result=result, failure_reason=failure_reason, ) @@ -68,9 +70,9 @@ async def test_disabled_when_env_unset( async def test_writes_wav_and_txt_pair_on_success(enabled_dir: Path) -> None: - """Both files land under ``//`` with the segment id as - a shared stem, so an operator scanning Finder sees ``.wav`` / - ``.txt`` next to each other.""" + """Both files land under ``///`` with the + segment id as a shared stem, so an operator scanning Finder sees + ``.wav`` / ``.txt`` next to each other, grouped by player.""" pcm = b"\x01\x02\x03\x04" * 1_000 # 4 KB ≈ 20 ms @ 48 kHz stereo result = SttResult( text="席3が怪しい", @@ -82,8 +84,8 @@ async def test_writes_wav_and_txt_pair_on_success(enabled_dir: Path) -> None: ) await dump_segment(_record(result=result), pcm=pcm) - wav_path = enabled_dir / "g_dbg" / "seg_001.wav" - txt_path = enabled_dir / "g_dbg" / "seg_001.txt" + wav_path = enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.wav" + txt_path = enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.txt" assert wav_path.exists() and txt_path.exists() @@ -93,7 +95,7 @@ async def test_dumped_wav_is_a_real_wav_file(enabled_dir: Path) -> None: pcm = b"\x00\x01" * 9_600 # ~50 ms @ 48 kHz stereo await dump_segment(_record(audio_bytes=len(pcm)), pcm=pcm) - wav_path = enabled_dir / "g_dbg" / "seg_001.wav" + wav_path = enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.wav" with wave.open(str(wav_path), "rb") as r: assert r.getframerate() == 48_000 assert r.getnchannels() == 2 @@ -117,7 +119,7 @@ async def test_txt_leads_with_transcript_for_quick_inspection( addressed_name=None, ) await dump_segment(_record(result=result), pcm=b"\x00" * 4_000) - body = (enabled_dir / "g_dbg" / "seg_001.txt").read_text(encoding="utf-8") + body = (enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.txt").read_text(encoding="utf-8") first = body.splitlines()[0] assert first == "transcript: 占いCO 席7白" assert "asr_conf : 0.950" in body @@ -147,7 +149,7 @@ async def test_raw_analyzer_json_appended_for_full_visibility( }, ) await dump_segment(_record(result=result), pcm=b"\x00" * 4_000) - body = (enabled_dir / "g_dbg" / "seg_001.txt").read_text(encoding="utf-8") + body = (enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.txt").read_text(encoding="utf-8") # First line is still the transcript — analyzer fields don't push # the headline answer below the fold. assert body.splitlines()[0] == "transcript: 席3が怪しいから投票" @@ -174,7 +176,7 @@ async def test_raw_analyzer_section_omitted_when_no_analysis( raw_analysis=None, ) await dump_segment(_record(result=result), pcm=b"\x00" * 100) - body = (enabled_dir / "g_dbg" / "seg_001.txt").read_text(encoding="utf-8") + body = (enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.txt").read_text(encoding="utf-8") assert "analysis (raw):" not in body @@ -197,7 +199,7 @@ async def test_low_confidence_path_dumps_with_failure_reason( _record(result=result, failure_reason="stt_low_confidence"), pcm=b"\x00" * 4_000, ) - body = (enabled_dir / "g_dbg" / "seg_001.txt").read_text(encoding="utf-8") + body = (enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.txt").read_text(encoding="utf-8") assert "transcript: ご視聴ありがとうございました" in body assert "asr_conf : 0.050" in body @@ -212,7 +214,7 @@ async def test_hard_failure_path_dumps_audio_with_failure_reason( _record(result=None, failure_reason="groq_http_400"), pcm=b"\x00" * 4_000, ) - body = (enabled_dir / "g_dbg" / "seg_001.txt").read_text(encoding="utf-8") + body = (enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.txt").read_text(encoding="utf-8") first = body.splitlines()[0] assert first == "transcript: " assert "failure_reason: groq_http_400" in body @@ -231,6 +233,67 @@ async def test_path_components_sanitized(enabled_dir: Path) -> None: assert ".." not in str(found[0]) +async def test_display_name_groups_segments_by_speaker( + enabled_dir: Path, +) -> None: + """Segments from the same speaker land in the same per-name + subdirectory so an operator can ``ls`` to see one player's full + transcript history.""" + pcm = b"\x00" * 200 + await dump_segment( + _record(segment_id="seg_a", display_name="🌙 セツ"), pcm=pcm + ) + await dump_segment( + _record(segment_id="seg_b", display_name="🌙 セツ"), pcm=pcm + ) + speaker_dir = enabled_dir / "g_dbg" / "🌙 セツ" + assert (speaker_dir / "seg_a.wav").exists() + assert (speaker_dir / "seg_b.wav").exists() + + +async def test_display_name_falls_back_to_user_id(enabled_dir: Path) -> None: + """Empty / missing display_name falls back to the Discord user ID + so dumps still group together — better than dropping into a single + 'unknown' bucket where two speakers would collide.""" + await dump_segment(_record(display_name=None), pcm=b"\x00" * 200) + expected = enabled_dir / "g_dbg" / "753109683971293296" / "seg_001.wav" + assert expected.exists() + + +async def test_display_name_with_path_traversal_sanitized( + enabled_dir: Path, +) -> None: + """A nick like ``../etc`` must NOT escape the game subdirectory — + display_name comes from Discord and is fully attacker-controlled.""" + rec = _record(display_name="../../../etc/passwd") + await dump_segment(rec, pcm=b"\x00" * 100) + # Path components above the debug root must not exist. + assert not (enabled_dir.parent / "etc").exists() + found = list(enabled_dir.rglob("*.wav")) + assert len(found) == 1 + assert ".." not in str(found[0]) + # The dump still landed inside the game subdirectory. + assert (enabled_dir / "g_dbg") in found[0].parents + + +async def test_txt_includes_speaker_name_when_known( + enabled_dir: Path, +) -> None: + """Operator-friendly: the txt sidecar surfaces the human name so + they don't need to map seat_no → player every time.""" + await dump_segment( + _record( + display_name="🌙 セツ", + result=SttResult(text="hi", confidence=1.0, duration_ms=100), + ), + pcm=b"\x00" * 100, + ) + body = ( + enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.txt" + ).read_text(encoding="utf-8") + assert "speaker_name : 🌙 セツ" in body + + async def test_write_failure_swallowed( enabled_dir: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -266,7 +329,8 @@ async def test_overrides_pcm_format_for_downsampled_callers( result=None, ) await dump_segment(rec, pcm=b"\x00" * 32_000) - with wave.open(str(enabled_dir / "g_dbg" / "seg_mono.wav"), "rb") as r: + # display_name unset → falls back to the speaker_user_id ("u"). + with wave.open(str(enabled_dir / "g_dbg" / "u" / "seg_mono.wav"), "rb") as r: assert r.getframerate() == 16_000 assert r.getnchannels() == 1 @@ -285,7 +349,7 @@ async def test_dumped_txt_is_human_readable_not_json(enabled_dir: Path) -> None: ), pcm=b"\x00" * 100, ) - body = (enabled_dir / "g_dbg" / "seg_001.txt").read_text(encoding="utf-8") + body = (enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.txt").read_text(encoding="utf-8") # No JSON braces should appear at the top level. assert not body.startswith("{") # The structure is colon-delimited key/value lines. From de05a810d1fc2b55f30fc13e5090a55b05b45a08 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 20:41:15 +0900 Subject: [PATCH 041/133] ci: install libopus + lazy-construct OpusError in resilience tests Pre-existing CI failure (unrelated to this branch's voice-pad fix): the ubuntu runner doesn't ship libopus, so discord.opus._lib stays None and `OpusError(-1)` evaluated at parametrize-decorator time crashes inside `_lib.opus_strerror`, taking the entire test_voice_recv_resilience module down at collection. Two layers of defense: - Install libopus0 on the runner so the C lib is loadable. - Wrap the parametrize values in lambdas so OpusError construction is deferred until test run time. A future libopus-less env now only loses that single parametrization, not the whole module. --- .github/workflows/ci.yml | 7 +++++++ tests/test_voice_recv_resilience.py | 19 +++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e308a2..5f08c7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,13 @@ jobs: - name: Set up Python 3.11 run: uv python install 3.11 + # discord.py loads libopus via ctypes for VC tests. The ubuntu + # runner image doesn't ship it by default, so OpusError(...) + # crashes inside `_lib.opus_strerror` and breaks test collection + # in tests/test_voice_recv_resilience.py. + - name: Install libopus (for voice tests) + run: sudo apt-get update && sudo apt-get install -y libopus0 + - name: Sync deps run: uv sync --all-extras --dev diff --git a/tests/test_voice_recv_resilience.py b/tests/test_voice_recv_resilience.py index 4dfeef4..b9ec820 100644 --- a/tests/test_voice_recv_resilience.py +++ b/tests/test_voice_recv_resilience.py @@ -12,6 +12,7 @@ import threading import time +from collections.abc import Callable from types import SimpleNamespace from typing import Any @@ -173,10 +174,24 @@ def test_none_pop_data_is_ignored_without_calling_sink() -> None: assert captured == [] -@pytest.mark.parametrize("exc", [ValueError("oops"), KeyError("k"), OpusError(-1)]) -def test_arbitrary_pop_data_exception_is_swallowed(exc: BaseException) -> None: +@pytest.mark.parametrize( + "exc_factory", + [ + # Wrap in lambdas so OpusError(-1) is only constructed at test + # run time, not at collection time. Without this, environments + # without libopus loaded crash in `_lib.opus_strerror(...)` and + # take the entire module's collection down with them. + pytest.param(lambda: ValueError("oops"), id="value-error"), + pytest.param(lambda: KeyError("k"), id="key-error"), + pytest.param(lambda: OpusError(-1), id="opus-error"), + ], +) +def test_arbitrary_pop_data_exception_is_swallowed( + exc_factory: Callable[[], BaseException], +) -> None: """OpusError is the known failure mode; any other unexpected exception must also be contained so the thread keeps draining packets.""" + exc = exc_factory() apply_packet_router_resilience() end = threading.Event() From f1d96a394d38c0bfd0da9ec49d7504ba1faf1b5d Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 21:24:25 +0900 Subject: [PATCH 042/133] fix(viewer): dedupe speech rows + surface LLM traces per source - game_export filters PLAYER_SPEECH log rows; speech_events is the canonical one-row-per-utterance source (live LLM context still reads PLAYER_SPEECH from logs_public, so the write path is unchanged). - trace_context() in voice_ingest / NPC generators now passes day, parsed via new parse_day_from_phase_id helper. _load_trace backfills day from phase_id for pre-fix JSONL lines so re-export repairs old games. - text_analyzer logs every Gemini call as role=text_analysis, and on_message wraps the analyzer call in trace_context so human typing is traced too. - viewer matchTraceForSpeech routes per source (npc_speech / voice_stt / text_analysis) instead of excluding voice_stt outright; voice_stt picks the analyze step by user_prompt match. --- src/wolfbot/master/text_analyzer.py | 77 +++++++++++++++++-- src/wolfbot/master/voice_ingest_service.py | 6 +- src/wolfbot/npc/gemini_generator.py | 2 + .../npc/openai_compatible_generator.py | 2 + src/wolfbot/services/discord_service.py | 18 ++++- src/wolfbot/services/game_export.py | 24 +++++- src/wolfbot/services/game_export_types.py | 2 +- src/wolfbot/services/llm_trace.py | 23 ++++++ tests/test_game_export.py | 41 ++++++++++ tests/test_llm_trace.py | 12 +++ viewer/sample-data/export-schema.json | 3 +- viewer/src/components/PhaseSection.tsx | 62 +++++++++++++-- viewer/src/lib/types.ts | 2 +- 13 files changed, 254 insertions(+), 20 deletions(-) diff --git a/src/wolfbot/master/text_analyzer.py b/src/wolfbot/master/text_analyzer.py index 61be94a..7db7bf5 100644 --- a/src/wolfbot/master/text_analyzer.py +++ b/src/wolfbot/master/text_analyzer.py @@ -134,6 +134,12 @@ def __init__( async def analyze(self, *, text: str, timeout_s: float) -> TextAnalysis: import httpx + from wolfbot.services.llm_trace import ( + CallTimer, + extract_gemini_rest_tokens, + log_llm_call, + ) + effective_timeout = min(timeout_s, self.timeout_s) body = { "contents": [ @@ -153,11 +159,26 @@ async def analyze(self, *, text: str, timeout_s: float) -> TextAnalysis: f"{self.api_base}/models/{self.model}:generateContent" f"?key={self.api_key}" ) + timer = CallTimer() + raw_text = "" + tokens: dict[str, int | None] | None = None + err: str | None = None try: async with httpx.AsyncClient(timeout=effective_timeout) as client: resp = await client.post(url, json=body) if resp.status_code != 200: - raise TextAnalyzerError(f"gemini_http_{resp.status_code}") + err = f"gemini_http_{resp.status_code}" + await log_llm_call( + role="text_analysis", + provider="gemini", + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + ) + raise TextAnalyzerError(err) resp_json = resp.json() raw_text = ( resp_json.get("candidates", [{}])[0] @@ -165,17 +186,61 @@ async def analyze(self, *, text: str, timeout_s: float) -> TextAnalysis: .get("parts", [{}])[0] .get("text", "") ) + tokens = extract_gemini_rest_tokens(resp_json) parsed = self._parse_response(raw_text) except TextAnalyzerError: raise except httpx.TimeoutException as exc: - raise TextAnalyzerError("gemini_timeout") from exc + err = "gemini_timeout" + await log_llm_call( + role="text_analysis", + provider="gemini", + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + ) + raise TextAnalyzerError(err) from exc except httpx.ConnectError as exc: - raise TextAnalyzerError("gemini_connection_refused") from exc + err = "gemini_connection_refused" + await log_llm_call( + role="text_analysis", + provider="gemini", + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + ) + raise TextAnalyzerError(err) from exc except Exception as exc: - raise TextAnalyzerError( - f"gemini_unexpected_{type(exc).__name__}" - ) from exc + err = f"gemini_unexpected_{type(exc).__name__}" + await log_llm_call( + role="text_analysis", + provider="gemini", + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + ) + raise TextAnalyzerError(err) from exc + + await log_llm_call( + role="text_analysis", + provider="gemini", + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=raw_text, + latency_ms=timer.elapsed_ms, + error=None, + tokens=tokens, + ) co_raw = parsed.get("co_claim") co_declaration = co_raw if co_raw in CO_CLAIM_VALUES else None diff --git a/src/wolfbot/master/voice_ingest_service.py b/src/wolfbot/master/voice_ingest_service.py index 4838c6e..e8ce1e1 100644 --- a/src/wolfbot/master/voice_ingest_service.py +++ b/src/wolfbot/master/voice_ingest_service.py @@ -231,7 +231,10 @@ async def _run_stt( *, audio_end_ms: int, ) -> None: - from wolfbot.services.llm_trace import trace_context + from wolfbot.services.llm_trace import ( + parse_day_from_phase_id, + trace_context, + ) actor = ( f"speaker_user_id={seg.speaker_user_id} seat={seg.seat_no} " @@ -240,6 +243,7 @@ async def _run_stt( with trace_context( game_id=game_id, phase=phase_id, + day=parse_day_from_phase_id(phase_id), actor=actor, metadata={ "segment_id": seg.segment_id, diff --git a/src/wolfbot/npc/gemini_generator.py b/src/wolfbot/npc/gemini_generator.py index 7733116..35a24a0 100644 --- a/src/wolfbot/npc/gemini_generator.py +++ b/src/wolfbot/npc/gemini_generator.py @@ -83,6 +83,7 @@ async def generate( CallTimer, extract_gemini_vertex_tokens, log_llm_call, + parse_day_from_phase_id, parse_game_id_from_phase_id, trace_context, ) @@ -113,6 +114,7 @@ async def generate( with trace_context( game_id=parse_game_id_from_phase_id(request.phase_id), phase=request.phase_id, + day=parse_day_from_phase_id(request.phase_id), actor=actor, metadata={ "request_id": request.request_id, diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index 806d2c6..0e0dfe8 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -229,6 +229,7 @@ async def generate( CallTimer, extract_openai_tokens, log_llm_call, + parse_day_from_phase_id, parse_game_id_from_phase_id, trace_context, ) @@ -279,6 +280,7 @@ async def generate( with trace_context( game_id=parse_game_id_from_phase_id(request.phase_id), phase=request.phase_id, + day=parse_day_from_phase_id(request.phase_id), actor=actor, metadata={ "request_id": request.request_id, diff --git a/src/wolfbot/services/discord_service.py b/src/wolfbot/services/discord_service.py index 749c264..bcfd97e 100644 --- a/src/wolfbot/services/discord_service.py +++ b/src/wolfbot/services/discord_service.py @@ -717,10 +717,22 @@ async def on_message(self, message: discord.Message) -> None: addressed_seat_no: int | None = None co_declaration: str | None = None if self._text_analyzer is not None: + from wolfbot.services.llm_trace import trace_context + try: - analysis = await self._text_analyzer.analyze( - text=message.content, timeout_s=8.0 - ) + with trace_context( + game_id=game.id, + phase=phase_id, + day=game.day_number, + actor=( + f"author_user_id={message.author.id} " + f"seat={author_seat}" + ), + metadata={"channel_id": channel_id}, + ): + analysis = await self._text_analyzer.analyze( + text=message.content, timeout_s=8.0 + ) except Exception: log.exception( "text_analyzer_failed game=%s seat=%s", diff --git a/src/wolfbot/services/game_export.py b/src/wolfbot/services/game_export.py index f9d3b94..056918b 100644 --- a/src/wolfbot/services/game_export.py +++ b/src/wolfbot/services/game_export.py @@ -112,10 +112,18 @@ async def _build_payload( "SELECT * FROM seats WHERE game_id = ? ORDER BY seat_no", (game_id,), )] + # PLAYER_SPEECH log rows duplicate the canonical speech_events rows + # (DiscussionService.record() inserts both at write time so live LLM + # context-builder prompts can keep reading from logs_public). For + # replay we only need the speech_events row — it carries source + + # speaker_seat + summary, which the viewer attributes to the player. + # Excluding the duplicates here keeps the timeline clean and avoids + # the "NPC text shown twice (PLAYER_SPEECH log + speech_event)" issue. public_log_rows = [dict(r) for r in await _fetch_all( db, "SELECT day, phase, kind, actor_seat, text, created_at " - "FROM logs_public WHERE game_id = ? ORDER BY id ASC", + "FROM logs_public WHERE game_id = ? AND kind != 'PLAYER_SPEECH' " + "ORDER BY id ASC", (game_id,), )] vote_rows = [dict(r) for r in await _fetch_all( @@ -350,7 +358,17 @@ def _load_trace(trace_root: Path, game_id: str) -> list[TraceEntry]: games). One bad line is logged and skipped — not fatal. A schema- violating line (missing required field, wrong type) is also logged and skipped rather than failing the whole export. + + Backfill: pre-fix voice_stt / NPC trace lines were written with + ``day=null`` (the call sites passed only ``phase=phase_id`` to + ``trace_context``). The viewer's ``matchTraceForSpeech`` requires + ``t.day === phase.day`` to attach a trace to a speech event, so + null-day lines never surfaced the LLM prompt UI. We recover the day + here by parsing ``dayN`` out of the canonical phase_id format + ``"{gid}::dayN::PHASE::seq"`` whenever ``day`` is missing. """ + from wolfbot.services.llm_trace import parse_day_from_phase_id + game_dir = trace_root / game_id if not game_dir.is_dir(): return [] @@ -370,6 +388,10 @@ def _load_trace(trace_root: Path, game_id: str) -> list[TraceEntry]: ) continue obj.setdefault("file_stem", stem) + if obj.get("day") is None: + recovered = parse_day_from_phase_id(obj.get("phase")) + if recovered is not None: + obj["day"] = recovered try: entries.append(TraceEntry.model_validate(obj)) except ValidationError: diff --git a/src/wolfbot/services/game_export_types.py b/src/wolfbot/services/game_export_types.py index 8713109..a29684f 100644 --- a/src/wolfbot/services/game_export_types.py +++ b/src/wolfbot/services/game_export_types.py @@ -47,7 +47,7 @@ # Re-export the canonical wire/storage form from domain/enums so the # viewer schema stays aligned with runtime validators in lockstep. CoDeclaration = _CoDeclaration -TraceRole = Literal["gameplay", "npc_speech", "voice_stt"] +TraceRole = Literal["gameplay", "npc_speech", "voice_stt", "text_analysis"] Victory = Literal["village", "wolf"] diff --git a/src/wolfbot/services/llm_trace.py b/src/wolfbot/services/llm_trace.py index e7bdb77..8d3e396 100644 --- a/src/wolfbot/services/llm_trace.py +++ b/src/wolfbot/services/llm_trace.py @@ -95,6 +95,28 @@ def parse_game_id_from_phase_id(phase_id: str | None) -> str | None: return head if sep else None +def parse_day_from_phase_id(phase_id: str | None) -> int | None: + """Extract day number from canonical phase_id ``"{gid}::dayN::PHASE::seq"``. + + Returns ``None`` when the phase_id is missing the ``dayN`` segment or + the segment is not parseable. Used by call sites that only have a + phase_id string available so trace entries carry the same ``day`` + field the gameplay-LLM path emits. + """ + if not phase_id: + return None + parts = phase_id.split("::") + if len(parts) < 2: + return None + seg = parts[1] + if not seg.startswith("day"): + return None + try: + return int(seg[3:]) + except ValueError: + return None + + @contextmanager def trace_context( *, @@ -271,6 +293,7 @@ def _sanitize(s: str) -> str: "extract_gemini_vertex_tokens", "extract_openai_tokens", "log_llm_call", + "parse_day_from_phase_id", "parse_game_id_from_phase_id", "trace_base_dir", "trace_context", diff --git a/tests/test_game_export.py b/tests/test_game_export.py index a07c746..fc4129d 100644 --- a/tests/test_game_export.py +++ b/tests/test_game_export.py @@ -248,6 +248,47 @@ async def test_export_game_inlines_jsonl_trace( assert trace[1]["file_stem"] == "gameplay" +async def test_export_game_filters_player_speech_logs( + fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path +) -> None: + """PLAYER_SPEECH log rows duplicate speech_events rows; the export omits them. + + DiscussionService.record() inserts both at write time so the live LLM + context-builder keeps reading PLAYER_SPEECH from logs_public, but the + viewer should only see one canonical speech entry per utterance. + """ + repo, db_path = fixture_repo + await _seed_minimal_game(repo) + + # Add a PLAYER_SPEECH log row that duplicates a npc_generated speech_event. + await repo.insert_log_public( + LogEntry( + game_id=GAME_ID, + day=1, + phase=Phase.DAY_DISCUSSION, + kind="PLAYER_SPEECH", + actor_seat=2, + visibility="PUBLIC", + text="seat 2's npc speech", + created_at=1_700_000_200, + ) + ) + + out = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=tmp_path / "out", + ) + payload = json.loads(out.read_text(encoding="utf-8")) + all_log_kinds = { + log["kind"] + for phase in payload["phases"] + for log in phase["public_logs"] + } + assert "PLAYER_SPEECH" not in all_log_kinds + + async def test_export_game_raises_for_unknown_game( fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path ) -> None: diff --git a/tests/test_llm_trace.py b/tests/test_llm_trace.py index cd72e66..aec906d 100644 --- a/tests/test_llm_trace.py +++ b/tests/test_llm_trace.py @@ -9,6 +9,7 @@ from wolfbot.services.llm_trace import ( log_llm_call, + parse_day_from_phase_id, parse_game_id_from_phase_id, trace_context, ) @@ -269,6 +270,17 @@ def test_parse_game_id_from_phase_id() -> None: assert parse_game_id_from_phase_id("noseparator") is None +def test_parse_day_from_phase_id() -> None: + assert parse_day_from_phase_id("g_abc::day1::DAY_DISCUSSION::1") == 1 + assert parse_day_from_phase_id("g_abc::day12::NIGHT::3") == 12 + assert parse_day_from_phase_id("g_abc::day0::NIGHT_0::1") == 0 + assert parse_day_from_phase_id(None) is None + assert parse_day_from_phase_id("") is None + assert parse_day_from_phase_id("g_abc") is None + assert parse_day_from_phase_id("g_abc::notaday::DAY_DISCUSSION::1") is None + assert parse_day_from_phase_id("g_abc::dayX::DAY_DISCUSSION::1") is None + + async def test_concurrent_writes_interleave_at_line_boundaries( trace_dir: Path, ) -> None: diff --git a/viewer/sample-data/export-schema.json b/viewer/sample-data/export-schema.json index 29dd2f2..4bea60c 100644 --- a/viewer/sample-data/export-schema.json +++ b/viewer/sample-data/export-schema.json @@ -459,7 +459,8 @@ "enum": [ "gameplay", "npc_speech", - "voice_stt" + "voice_stt", + "text_analysis" ], "title": "Role", "type": "string" diff --git a/viewer/src/components/PhaseSection.tsx b/viewer/src/components/PhaseSection.tsx index 4c2da73..6ae6a66 100644 --- a/viewer/src/components/PhaseSection.tsx +++ b/viewer/src/components/PhaseSection.tsx @@ -143,16 +143,62 @@ function matchTraceForSpeech( trace: TraceEntry[], ): TraceEntry | null { if (sp.speaker_seat == null) return null; - // Heuristic: pick the trace entry whose actor mentions the same seat # and - // whose phase matches and that hasn't been claimed yet by a closer event. + // Per-source matcher. Each speech source maps to a specific trace role + // because the LLM call shape differs: + // npc_generated → role=npc_speech, response is a JSON envelope whose + // `text` field equals the spoken utterance. + // voice_stt → role=voice_stt, the analyzer step has the + // transcription as user_prompt and JSON analysis as + // response. Match by user_prompt+seat+segment. + // text → role=text_analysis, user_prompt IS the typed message. + const phaseMatches = (t: TraceEntry) => + t.day === phase.day && + (t.phase === phase.phase || t.phase?.includes(phase.phase)); + const needle = sp.text.slice(0, 20); + + if (sp.source === "npc_generated") { + return ( + trace.find( + (t) => + t.role === "npc_speech" && + phaseMatches(t) && + actorSeat(t) === sp.speaker_seat && + responseContains(t, needle), + ) ?? null + ); + } + if (sp.source === "voice_stt") { + // The voice path emits two trace lines per segment (transcribe + analyze). + // Prefer the analyze step because it carries both the transcript and the + // structured fields the user wants to inspect. + const analyze = + trace.find( + (t) => + t.role === "voice_stt" && + phaseMatches(t) && + actorSeat(t) === sp.speaker_seat && + t.metadata?.step === "analyze" && + userPromptContains(t, needle), + ) ?? null; + if (analyze !== null) return analyze; + return ( + trace.find( + (t) => + t.role === "voice_stt" && + phaseMatches(t) && + actorSeat(t) === sp.speaker_seat && + userPromptContains(t, needle), + ) ?? null + ); + } + // source === "text" return ( trace.find( (t) => - t.role !== "voice_stt" && - t.day === phase.day && - (t.phase === phase.phase || t.phase?.includes(phase.phase)) && + t.role === "text_analysis" && + phaseMatches(t) && actorSeat(t) === sp.speaker_seat && - responseContains(t, sp.text.slice(0, 20)), + userPromptContains(t, needle), ) ?? null ); } @@ -205,6 +251,10 @@ function responseContains(t: TraceEntry, needle: string): boolean { return t.response != null && t.response.includes(needle); } +function userPromptContains(t: TraceEntry, needle: string): boolean { + return typeof t.user_prompt === "string" && t.user_prompt.includes(needle); +} + function EventRow({ event, seats, diff --git a/viewer/src/lib/types.ts b/viewer/src/lib/types.ts index 95a4ba7..bb4ad59 100644 --- a/viewer/src/lib/types.ts +++ b/viewer/src/lib/types.ts @@ -17,7 +17,7 @@ export type SpeechSource = "text" | "voice_stt" | "npc_generated"; export type CoDeclaration = "seer" | "medium" | "knight" | null; -export type TraceRole = "gameplay" | "npc_speech" | "voice_stt"; +export type TraceRole = "gameplay" | "npc_speech" | "voice_stt" | "text_analysis"; export interface Seat { seat_no: number; From dacc53b143d01ac0e78ce836cd1b517216a1028b Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 21:46:06 +0900 Subject: [PATCH 043/133] feat(reactive_voice): close NPC prompt parity gap with rounds mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reactive_voice NPC prompt was a small subset of rounds-mode's: it dropped the persona's address_style / forbidden_overuse / silent_gesture narration_mode, all 5 judgment_profile axes, the seat's role + role_specific strategy block, the alive/dead seat lists, and any prior public utterance. Output: NPCs without context, ignoring character data that personas.py spent paragraphs defining. Wire that data through: - domain/ws_messages: LogicPacket gains `recent_speeches`; SpeakRequest gains `role`, `role_strategy`, `alive_seats`, `dead_seats`. All have default-empty values so older Master ↔ NPC builds stay compatible. - master/logic_service: build_logic_packet accepts recent_speeches. - master/speak_arbiter: new _collect_request_context loads the trailing 20 SpeechEvents (with display names attached), the alive/dead seat split, and the seat's Role → role_strategy via the public build_strategy_block helper. Each load is independently best-effort — a transient repo glitch falls back to empty rather than blocking dispatch. - npc/openai_compatible_generator + npc/gemini_generator: _build_system now mirrors rounds-mode by calling build_speech_profile_block + build_judgment_profile_block from prompt_builder, and surfaces the optional role + role_strategy. _build_user renders a `## 直近の発言` block (with [テキスト]/[音声]/[NPC発話] tags) plus alive/dead seat lists when populated. - llm/prompt_builder: rename the formerly underscored `_build_speech_profile_block`, `_build_judgment_profile_block`, `_build_strategy_block` to public names, with underscore aliases kept so existing tests/callers continue to work. Tests cover (a) the new SpeakArbiter dispatch path attaching recent speeches + role + role_strategy + alive_seats, (b) the NPC system prompt including the full speech/judgment blocks and silent-gesture narration_mode for kukrushka, and (c) the user prompt rendering recent speeches and alive/dead seats from the SpeakRequest. --- src/wolfbot/domain/ws_messages.py | 56 +++++++++ src/wolfbot/llm/prompt_builder.py | 29 ++++- src/wolfbot/master/logic_service.py | 4 +- src/wolfbot/master/speak_arbiter.py | 119 +++++++++++++++++- src/wolfbot/npc/gemini_generator.py | 7 +- .../npc/openai_compatible_generator.py | 81 ++++++++++-- tests/test_npc_seat_assignment.py | 92 ++++++++++++++ tests/test_reactive_voice_master.py | 88 +++++++++++++ 8 files changed, 455 insertions(+), 21 deletions(-) diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index 556b97b..192e25f 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -111,6 +111,23 @@ class LogicCandidate(BaseModel): counter: tuple[str, ...] = () +class RecentSpeech(BaseModel): + """Compact rendering of a past public utterance for NPC context. + + Built from `SpeechEvent` rows by the SpeakArbiter and sent to the NPC + bot as part of `LogicPacket.recent_speeches` so the NPC's prompt can + surface recent dialogue (the parity gap with rounds-mode prompts that + fed `PLAYER_SPEECH` log lines into `build_user_context`). + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + seat_no: int + display_name: str + source: Literal["text", "voice_stt", "npc_generated"] + text: str + + class LogicPacket(BaseEnvelope): type: Literal["logic_packet"] = "logic_packet" packet_id: str @@ -120,6 +137,14 @@ class LogicPacket(BaseEnvelope): logic_candidates: tuple[LogicCandidate, ...] = () pressure: dict[int, float] = Field(default_factory=dict) expires_at_ms: int + recent_speeches: tuple[RecentSpeech, ...] = Field( + default_factory=tuple, + description=( + "Recent public utterances in the current phase, oldest-first. " + "Defaults to empty so older Master builds talking to newer NPC " + "bots stay schema-compatible." + ), + ) class SpeakRequest(BaseEnvelope): @@ -134,6 +159,37 @@ class SpeakRequest(BaseEnvelope): max_duration_ms: int = 8000 priority: int = 0 expires_at_ms: int + role: str | None = Field( + default=None, + description=( + "Role of the seat this NPC is bound to (VILLAGER / WEREWOLF / ...). " + "Sent so the NPC's system prompt can surface role-specific " + "strategy text. Optional for back-compat with older Master builds." + ), + ) + role_strategy: str | None = Field( + default=None, + description=( + "Pre-rendered role-specific strategy markdown produced by Master's " + "rounds-mode `build_strategy_block(role)`. Sent verbatim so the " + "NPC bot doesn't need to import gameplay-LLM strategy data." + ), + ) + alive_seats: tuple[tuple[int, str], ...] = Field( + default_factory=tuple, + description=( + "(seat_no, display_name) pairs for every still-alive seat. Lets " + "the NPC's prompt list 'who is alive' without an extra round-trip. " + "Empty for back-compat with older Master builds." + ), + ) + dead_seats: tuple[tuple[int, str], ...] = Field( + default_factory=tuple, + description=( + "(seat_no, display_name) pairs for dead seats. Same back-compat " + "story as `alive_seats`." + ), + ) class SpeakResult(BaseEnvelope): diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index 703d1c4..7a9a98e 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -659,7 +659,7 @@ def _build_game_rules_block() -> str: } -def _build_strategy_block(role: Role) -> str: +def build_strategy_block(role: Role) -> str: """Return role-specific tips for the given role only. Caller must pass a non-None Role; `build_system_prompt` is invoked after @@ -670,6 +670,12 @@ def _build_strategy_block(role: Role) -> str: return _ROLE_STRATEGIES[role] +# Underscore alias retained for the historical "private" import path used +# inside this module; new external callers (Master arbiter → SpeakRequest) +# use the public `build_strategy_block` name. +_build_strategy_block = build_strategy_block + + def _band(value: float, *, low: str, mid_low: str, mid: str, mid_high: str, high: str) -> str: """Map a 0.0-1.0 axis to one of five qualitative bands. @@ -688,7 +694,7 @@ def _band(value: float, *, low: str, mid_low: str, mid: str, mid_high: str, high return high -def _build_judgment_profile_block(persona: Persona) -> str: +def build_judgment_profile_block(persona: Persona) -> str: """Render `JudgmentProfile` axes as labeled tendency bands. Each axis is mapped to a qualitative band so the LLM has a concrete @@ -750,9 +756,13 @@ def _build_judgment_profile_block(persona: Persona) -> str: ) -def _build_speech_profile_block(persona: Persona) -> str: +def build_speech_profile_block(persona: Persona) -> str: """Render the persona's structured speech profile as a bullet block. + Public function (renamed from the historical underscored name) so the + Master arbiter can render the same block for the reactive_voice NPC + prompt as rounds-mode uses. + Dispatches on `narration_mode`: silent-gesture personas (kukrushka) get a structurally different block — no `一人称` line, gesture examples instead — so callers can assert the structural difference in tests. Per-persona @@ -785,6 +795,13 @@ def _build_speech_profile_block(persona: Persona) -> str: ) +# Underscore aliases for callers (and tests) that imported the historical +# private names. The reactive_voice NPC system-prompt builder calls the +# public names directly. +_build_speech_profile_block = build_speech_profile_block +_build_judgment_profile_block = build_judgment_profile_block + + def build_system_prompt( persona: Persona, role: Role, @@ -805,10 +822,10 @@ def build_system_prompt( return ( template.replace("{game_rules_block}", _build_game_rules_block()) .replace("{persona_block}", persona_block) - .replace("{judgment_profile_block}", _build_judgment_profile_block(persona)) - .replace("{speech_profile_block}", _build_speech_profile_block(persona)) + .replace("{judgment_profile_block}", build_judgment_profile_block(persona)) + .replace("{speech_profile_block}", build_speech_profile_block(persona)) .replace("{role_block}", role_block) - .replace("{strategy_block}", _build_strategy_block(role)) + .replace("{strategy_block}", build_strategy_block(role)) .replace("{phase_block}", phase_block) .replace("{task_block}", task_text) ) diff --git a/src/wolfbot/master/logic_service.py b/src/wolfbot/master/logic_service.py index 006d3df..ba3c5a6 100644 --- a/src/wolfbot/master/logic_service.py +++ b/src/wolfbot/master/logic_service.py @@ -24,7 +24,7 @@ from collections.abc import Iterable from wolfbot.domain.discussion import PublicDiscussionState -from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket +from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, RecentSpeech def _new_packet_id() -> str: @@ -39,6 +39,7 @@ def build_logic_packet( now_ms: int, pressure: dict[int, float] | None = None, additional_candidates: Iterable[LogicCandidate] = (), + recent_speeches: Iterable[RecentSpeech] = (), ) -> LogicPacket: """Construct a `LogicPacket` for `recipient_npc_id`. @@ -91,6 +92,7 @@ def build_logic_packet( logic_candidates=tuple(candidates), pressure=pressure or {}, expires_at_ms=expires_at_ms, + recent_speeches=tuple(recent_speeches), ) diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index c4007f2..2b9ecb7 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -28,6 +28,7 @@ import uuid from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass +from typing import Literal, cast from wolfbot.domain.discussion import ( PublicDiscussionState, @@ -35,16 +36,18 @@ SpeechSource, make_phase_id, ) -from wolfbot.domain.enums import Phase +from wolfbot.domain.enums import Phase, Role from wolfbot.domain.ws_messages import ( PlaybackAuthorized, PlaybackFailed, PlaybackFinished, + RecentSpeech, SpeakRequest, SpeakResult, TtsFailed, TtsFinished, ) +from wolfbot.llm.prompt_builder import build_strategy_block from wolfbot.master.logic_service import build_logic_packet from wolfbot.master.npc_registry import NpcRegistry from wolfbot.persistence.sqlite_repo import SqliteRepo @@ -59,6 +62,12 @@ log = logging.getLogger(__name__) +# Last N speeches included in the LogicPacket. Mirrors the rounds-mode +# prompt builder which surfaces the trailing 40 PLAYER_SPEECH log lines; +# 20 is plenty for an 80-char reactive reply and keeps WS frames small. +_RECENT_SPEECH_CAP = 20 + + @dataclass class SpeakArbiterConfig: max_chars_reactive: int = 80 @@ -208,6 +217,15 @@ async def dispatch_request( ) return (None, "npc_offline") + # Resolve seat data: recent_speeches (with display names attached), + # alive/dead seat lists, and the candidate's role+strategy. All + # best-effort — if a load fails we fall back to empty/null and + # still dispatch (the prompt degrades gracefully to the historical + # minimal shape). + recent_speeches, alive_seats, dead_seats, role_name, role_strategy = ( + await self._collect_request_context(state, seat_no) + ) + # Build LogicPacket (sent first so the NPC has context for the # subsequent speak_request). packet = build_logic_packet( @@ -215,6 +233,7 @@ async def dispatch_request( recipient_npc_id=candidate_npc_id, expires_at_ms=now + self.config.request_ttl_ms, now_ms=now, + recent_speeches=recent_speeches, ) try: await entry.send(packet.model_dump_json()) @@ -235,6 +254,10 @@ async def dispatch_request( max_duration_ms=self.config.playback_deadline_ms, priority=0, expires_at_ms=now + self.config.request_ttl_ms, + role=role_name, + role_strategy=role_strategy, + alive_seats=alive_seats, + dead_seats=dead_seats, ) await self.repo.insert_npc_speak_request( @@ -280,6 +303,100 @@ async def dispatch_request( ) return (request, None) + # --------------------------------------------------- request context loader + + async def _collect_request_context( + self, + state: PublicDiscussionState, + seat_no: int, + ) -> tuple[ + tuple[RecentSpeech, ...], + tuple[tuple[int, str], ...], + tuple[tuple[int, str], ...], + str | None, + str | None, + ]: + """Load the data the NPC prompt needs but the arbiter doesn't already have. + + Returns ``(recent_speeches, alive_seats, dead_seats, role_name, + role_strategy)``. Each piece is independently best-effort: a failed + DB read for one slot logs and falls back to an empty value while + the others still populate. The intent is that a transient repo + glitch must NOT block dispatching — the NPC then sees the older + minimal prompt shape rather than no prompt at all. + """ + recent: tuple[RecentSpeech, ...] = () + try: + events = await self.discussion.load_phase(state.game_id, state.phase_id) + seats = await self.repo.load_seats(state.game_id) + seat_name_by_no = {s.seat_no: s.display_name for s in seats} + recent_list: list[RecentSpeech] = [] + for ev in events: + if ev.source == SpeechSource.PHASE_BASELINE: + continue + if ev.speaker_seat is None or not ev.text: + continue + name = seat_name_by_no.get(ev.speaker_seat, f"席{ev.speaker_seat}") + # Trim very long speeches; the NPC only needs the gist. + snippet = ev.text.strip().replace("\n", " ") + if len(snippet) > 200: + snippet = snippet[:200] + "…" + # `SpeechSource.value` is one of the four runtime values, but + # ``phase_baseline`` is filtered above so the literal narrows + # to the three viewer-facing values that `RecentSpeech.source` + # accepts. + source_value = cast( + Literal["text", "voice_stt", "npc_generated"], + ev.source.value, + ) + recent_list.append( + RecentSpeech( + seat_no=ev.speaker_seat, + display_name=name, + source=source_value, + text=snippet, + ) + ) + # Cap to last N so the prompt stays compact even on long phases. + recent = tuple(recent_list[-_RECENT_SPEECH_CAP:]) + except Exception: + log.exception("recent_speeches_load_failed phase_id=%s", state.phase_id) + + alive_seats: tuple[tuple[int, str], ...] = () + dead_seats: tuple[tuple[int, str], ...] = () + role_name: str | None = None + try: + seats = await self.repo.load_seats(state.game_id) + players = await self.repo.load_players(state.game_id) + seat_name_by_no = {s.seat_no: s.display_name for s in seats} + alive_list: list[tuple[int, str]] = [] + dead_list: list[tuple[int, str]] = [] + for p in players: + name = seat_name_by_no.get(p.seat_no, f"席{p.seat_no}") + if p.alive: + alive_list.append((p.seat_no, name)) + else: + dead_list.append((p.seat_no, name)) + if p.seat_no == seat_no and p.role is not None: + role_name = p.role.value + alive_seats = tuple(sorted(alive_list)) + dead_seats = tuple(sorted(dead_list)) + except Exception: + log.exception( + "seat_role_load_failed game_id=%s seat=%s", + state.game_id, seat_no, + ) + + role_strategy: str | None = None + if role_name is not None: + try: + role_strategy = build_strategy_block(Role(role_name)) + except Exception: + log.exception("role_strategy_build_failed role=%s", role_name) + role_strategy = None + + return recent, alive_seats, dead_seats, role_name, role_strategy + # ------------------------------------------------------------- handle result async def handle_speak_result( diff --git a/src/wolfbot/npc/gemini_generator.py b/src/wolfbot/npc/gemini_generator.py index 35a24a0..d8c9229 100644 --- a/src/wolfbot/npc/gemini_generator.py +++ b/src/wolfbot/npc/gemini_generator.py @@ -94,7 +94,12 @@ async def generate( "each NPC bot must declare its persona at startup." ) persona = NPC_PERSONAS_BY_KEY[self._persona_key] - system = _build_system(persona, max_chars=request.max_chars) + system = _build_system( + persona, + max_chars=request.max_chars, + role=request.role, + role_strategy=request.role_strategy, + ) user = _build_user(logic, request) client = genai.Client( diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index 0e0dfe8..dbb0217 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -41,6 +41,10 @@ from wolfbot.domain.enums import CO_CLAIM_VALUES from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, SpeakRequest from wolfbot.llm.persona_base import Persona +from wolfbot.llm.prompt_builder import ( + build_judgment_profile_block, + build_speech_profile_block, +) from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY from wolfbot.npc.speech_service import NpcGeneratedSpeech @@ -72,20 +76,40 @@ } -def _build_system(persona: Persona, max_chars: int) -> str: - sp = persona.speech_profile - speech_block = ( - f"一人称: {sp.first_person}\n" - f"語尾/文体: {sp.sentence_style}\n" - f"間の取り方: {sp.pause_style}\n" - ) - if sp.signature_phrases: - speech_block += f"特徴語(低頻度): {'、'.join(sp.signature_phrases)}\n" +def _build_system( + persona: Persona, + max_chars: int, + *, + role: str | None = None, + role_strategy: str | None = None, +) -> str: + """Build the NPC's system prompt. + + Mirrors rounds-mode `build_system_prompt` for the persona-shaping blocks + (`build_speech_profile_block`, `build_judgment_profile_block`) so the + reactive_voice NPC carries the same character data — `narration_mode`, + `address_style`, `forbidden_overuse`, the 5 judgment axes, etc. — + instead of the small subset the historical NPC prompt sent. + + `role` + `role_strategy` are optional: when Master sends them on the + SpeakRequest, the NPC sees its role and the role-specific strategy + block. Older Master builds that don't send them produce a prompt that + silently omits the role section (back-compat). + """ + role_block = "" + if role: + role_block = f"## あなたの役職\nあなたの役職は『{role}』です。役職に見える情報だけを根拠にしてください。\n\n" + strategy_block = "" + if role_strategy: + strategy_block = f"## 役職別の戦術ヒント\n{role_strategy}\n\n" return ( "あなたは人狼ゲームに参加中のプレイヤーです。\n" f"キャラクター名: {persona.display_name}\n" - f"性格: {persona.style_guide}\n" - f"## 話法\n{speech_block}\n" + f"性格: {persona.style_guide}\n\n" + f"## 話法\n{build_speech_profile_block(persona)}\n\n" + f"## 判断のクセ\n{build_judgment_profile_block(persona)}\n\n" + f"{role_block}" + f"{strategy_block}" "## ルール\n" "- 日本語のみ。メタ発言禁止。AIであることに言及しない。\n" f"- `text` は {max_chars} 文字以内の短い発言。\n" @@ -110,11 +134,29 @@ def _build_system(persona: Persona, max_chars: int) -> str: def _build_user(logic: LogicPacket, request: SpeakRequest) -> str: lines = [ f"フェイズ: {request.phase_id}", + f"あなたの席: 席{request.seat_no}", f"提案意図: {request.suggested_intent}", "", "## 場の状況", logic.public_state_summary or "(情報なし)", ] + if request.alive_seats: + alive_str = "、".join( + f"席{seat_no} {name}" for seat_no, name in request.alive_seats + ) + lines.append("") + lines.append(f"## 生存者\n{alive_str}") + if request.dead_seats: + dead_str = "、".join( + f"席{seat_no} {name}" for seat_no, name in request.dead_seats + ) + lines.append(f"## 死亡者\n{dead_str}") + if logic.recent_speeches: + lines.append("") + lines.append("## 直近の発言 (古い順)") + for sp in logic.recent_speeches: + tag = _SOURCE_TAG.get(sp.source, sp.source) + lines.append(f"- 席{sp.seat_no} {sp.display_name} [{tag}]: {sp.text}") if logic.logic_candidates: lines.append("") lines.append("## 論点候補") @@ -130,6 +172,16 @@ def _build_user(logic: LogicPacket, request: SpeakRequest) -> str: return "\n".join(lines) +# Friendly Japanese tags for the recent-speech source bracket. The NPC sees +# "[テキスト]" for typed messages, "[音声]" for STT output, "[NPC発話]" for +# other NPC bots — matches how human players naturally distinguish them. +_SOURCE_TAG: dict[str, str] = { + "text": "テキスト", + "voice_stt": "音声", + "npc_generated": "NPC発話", +} + + def _format_candidate(c: LogicCandidate) -> str: parts = [f"- [{c.id}] {c.claim}"] if c.support: @@ -240,7 +292,12 @@ async def generate( "each NPC bot must declare its persona at startup." ) persona = NPC_PERSONAS_BY_KEY[self._persona_key] - system = _build_system(persona, max_chars=request.max_chars) + system = _build_system( + persona, + max_chars=request.max_chars, + role=request.role, + role_strategy=request.role_strategy, + ) if self.config.mode == "json_object": system += _DEEPSEEK_JSON_CONTRACT_SUFFIX user = _build_user(logic, request) diff --git a/tests/test_npc_seat_assignment.py b/tests/test_npc_seat_assignment.py index 64da3d9..fed22a8 100644 --- a/tests/test_npc_seat_assignment.py +++ b/tests/test_npc_seat_assignment.py @@ -249,6 +249,98 @@ def test_format_candidate_with_all_fields() -> None: assert "反論X" in out +def test_build_system_prompt_includes_full_persona_and_optional_role() -> None: + """The reactive_voice NPC system prompt mirrors rounds-mode by rendering + the full speech_profile + judgment_profile for the persona, and surfaces + the role + role_strategy when Master sends them on the SpeakRequest. + """ + persona = PERSONAS_BY_KEY["setsu"] + + # Without role/role_strategy: still has full persona blocks. + sys_no_role = _build_system(persona, max_chars=80) + sp = persona.speech_profile + assert sp.address_style in sys_no_role + assert "判断のクセ" in sys_no_role + # Bands rendered from judgment_profile axes: + assert "攻撃性" in sys_no_role and "流れへの追従度" in sys_no_role + assert "あなたの役職" not in sys_no_role + assert "戦術ヒント" not in sys_no_role + + # With role + strategy. + sys_with_role = _build_system( + persona, + max_chars=80, + role="SEER", + role_strategy="占い師の戦術メモ\n- 結果は出す", + ) + assert "SEER" in sys_with_role + assert "占い師の戦術メモ" in sys_with_role + + +def test_build_system_prompt_silent_gesture_persona() -> None: + """Kukrushka's `narration_mode=silent_gesture` must round-trip through + the NPC system prompt — previously dropped because the historical + `_build_system` only handled the standard speech profile fields. + """ + persona = PERSONAS_BY_KEY["kukrushka"] + sys_msg = _build_system(persona, max_chars=80) + # The silent_gesture branch in build_speech_profile_block emits this + # exact label; if it's missing the NPC will speak normally. + assert "ほぼ無言" in sys_msg or "叙述モード" in sys_msg + + +def test_build_user_prompt_renders_recent_speeches_and_seats() -> None: + """Recent speeches + alive/dead seats from the SpeakRequest land in + the user prompt with the speaker's display name attached.""" + from wolfbot.domain.ws_messages import RecentSpeech + + logic = LogicPacket( + ts=1, + trace_id="t", + packet_id="lp", + phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="alive=[1,2,3]", + recent_speeches=( + RecentSpeech( + seat_no=1, + display_name="Alice", + source="text", + text="占いの結果が気になる", + ), + RecentSpeech( + seat_no=3, + display_name="🌙セツ", + source="npc_generated", + text="まだ静かですね", + ), + ), + expires_at_ms=9999, + ) + request = SpeakRequest( + ts=1, + trace_id="t", + request_id="sr", + npc_id="npc_1", + phase_id="ph", + seat_no=2, + logic_packet_id="lp", + suggested_intent="speak", + max_chars=80, + expires_at_ms=5000, + alive_seats=((1, "Alice"), (2, "Bob"), (3, "🌙セツ")), + dead_seats=((4, "故人"),), + ) + user_msg = _build_user(logic, request) + assert "## 直近の発言" in user_msg + assert "席1 Alice" in user_msg and "占いの結果が気になる" in user_msg + assert "席3 🌙セツ" in user_msg and "まだ静かですね" in user_msg + assert "[テキスト]" in user_msg and "[NPC発話]" in user_msg + assert "## 生存者" in user_msg + assert "席1 Alice、席2 Bob、席3 🌙セツ" in user_msg + assert "## 死亡者" in user_msg and "席4 故人" in user_msg + + def test_build_user_prompt_no_candidates_no_pressure() -> None: logic = LogicPacket( ts=1, diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index e339195..86a73c6 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -188,6 +188,94 @@ async def test_human_speaking_blocks_dispatch(repo: SqliteRepo) -> None: assert req is not None and reason is None +async def test_dispatch_attaches_context_to_logic_packet_and_speak_request( + repo: SqliteRepo, +) -> None: + """SpeakArbiter loads recent speeches, alive/dead lists, and the seat's + role+strategy and forwards them on the WS payload so the NPC's prompt + builder has the same context that rounds-mode build_user_context does. + """ + from wolfbot.domain.discussion import ( + SpeakerKind, + SpeechEvent, + make_phase_id, + ) + from wolfbot.domain.ws_messages import LogicPacket, SpeakRequest + from wolfbot.services.discussion_service import ( + new_event_id, + ) + from wolfbot.services.discussion_service import ( + now_ms as discussion_now_ms, + ) + + game, _seats = await _seed_game(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + await discussion.record( + make_phase_baseline( + game_id=game.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2], + ) + ) + await discussion.record( + SpeechEvent( + event_id=new_event_id(), + game_id=game.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + source=SpeechSource.TEXT, + speaker_kind=SpeakerKind.HUMAN, + speaker_seat=1, + text="占いの結果が気になる", + created_at_ms=discussion_now_ms(), + ) + ) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500 + ) + state = _seed_state(game.id) + request, reason = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert reason is None and request is not None + + packet = LogicPacket.model_validate_json(npc_buf[0]) + speak = SpeakRequest.model_validate_json(npc_buf[1]) + + # Recent speech surfaces with the speaker's display name attached. + assert len(packet.recent_speeches) == 1 + assert packet.recent_speeches[0].seat_no == 1 + assert packet.recent_speeches[0].display_name == "Alice" + assert packet.recent_speeches[0].source == "text" + assert "占いの結果が気になる" in packet.recent_speeches[0].text + + # Role + role_strategy of seat 2 (SEER per _seed_game) is forwarded. + assert speak.role == "SEER" + assert speak.role_strategy is not None and len(speak.role_strategy) > 0 + + # Both seats alive at this point — alive_seats has both, dead_seats empty. + assert speak.alive_seats == ((1, "Alice"), (2, "セツ")) + assert speak.dead_seats == () + + async def test_speak_result_accepted_emits_authorized_and_writes_speech_event( repo: SqliteRepo, ) -> None: From bc10d7deb28395e1914818f3913b77e4420b619b Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 23:09:28 +0900 Subject: [PATCH 044/133] =?UTF-8?q?feat(reactive=5Fvoice):=20Phase-D=20sca?= =?UTF-8?q?ffold=20=E2=80=94=20NPC=20bot=20owns=20per-seat=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for "NPC = embodied agent per seat" architecture (Option D). Adds the WS schema, NPC-side state mirror, and Master-side snapshot push without changing live behaviour: decision request handlers return stub abstain/skip until the LLM-backed logic lands in follow-up commits. Wire schema (domain/ws_messages.py): - PrivateStateSnapshot (Master → NPC, full replace at game start + re-register): role, persona_key, alive/dead seats, partner_wolves (wolf only), seer/medium/guard/wolf-chat history. - PrivateStateUpdate (Master → NPC, append/patch): seer_result / medium_result / guard_entry / guard_resolved / wolf_chat / alive_changed / day_advanced. - DecideVoteRequest + VoteDecision (Master ↔ NPC). - DecideNightActionRequest + NightActionDecision (Master ↔ NPC). - WolfChatSend (NPC → Master, wolves only). NPC bot: - npc/game_state.py: NpcGameState dataclass, state_from_snapshot, apply_update with strict payload coercion (TypeError on bool->int collapse). Unknown update_kind silently dropped for forward-compat. - npc/client.py: per-(game_id) game_states dict; routes the new message types to the state mirror; decision request handlers reply with phase-D stub decisions (target=None) so the wire is testable end-to-end before LLM logic lands. Master: - master/private_state.py: pure factories that turn DB rows + Role into PrivateStateSnapshot / PrivateStateUpdate payloads. - main.py: _push_private_state_snapshot called right after SeatAssigned dispatch in _assign_online_npcs_to_seats so the NPC has its full Phase-D state before any decision request lands. Best-effort — a failed snapshot send is logged and skipped. Tests cover (a) snapshot round-trip via state_from_snapshot, (b) each apply_update branch including the guard_resolved retroactive fill, (c) malformed payload drop, (d) unknown update_kind no-op, (e) NpcClient routing of all four new message types, (f) misrouted state messages dropped, (g) snapshot builder partner_wolves correctly excludes self / dead / non-wolf, (h) every update factory round-trips through state_from_snapshot + apply_update. --- src/wolfbot/domain/ws_messages.py | 190 +++++++++++++++++ src/wolfbot/main.py | 72 +++++++ src/wolfbot/master/private_state.py | 310 +++++++++++++++++++++++++++ src/wolfbot/npc/client.py | 129 ++++++++++++ src/wolfbot/npc/game_state.py | 187 +++++++++++++++++ tests/test_master_private_state.py | 239 +++++++++++++++++++++ tests/test_npc_game_state.py | 315 ++++++++++++++++++++++++++++ 7 files changed, 1442 insertions(+) create mode 100644 src/wolfbot/master/private_state.py create mode 100644 src/wolfbot/npc/game_state.py create mode 100644 tests/test_master_private_state.py create mode 100644 tests/test_npc_game_state.py diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index 192e25f..0e0e34b 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -212,6 +212,196 @@ class SpeakResult(BaseEnvelope): ) +# ------------------------- Phase D: per-seat decision delegation +# Reactive_voice mode is migrating to "NPC bot is an embodied agent for its +# seat". The bot owns its role + private results in-memory and decides +# speech / vote / night-action via its own `NPC_LLM_*`. Master pushes the +# private state at /wolf start (and on NPC re-register) and asks for +# decisions when a phase needs them. None of these messages are required +# for back-compat — Master falls back to the historical Master-decides +# path (LLMAdapter / SpeakRequest) when the NPC bot is on an older build. + + +class SeerResult(BaseModel): + """Past seer divination result a seer NPC needs to recall in speech.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + day: int + target_seat: int + target_name: str + is_wolf: bool + + +class MediumResult(BaseModel): + """Past medium post-execution result.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + day: int + target_seat: int + target_name: str + is_wolf: bool | None = None # None when no execution happened + + +class GuardEntry(BaseModel): + """Past knight guard target. ``peaceful_morning`` lets the NPC tell a + truthful story about which guards led to which outcomes.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + day: int + target_seat: int + target_name: str + peaceful_morning: bool | None = None # None until morning resolves + + +class WolfChatLine(BaseModel): + """One line of the wolves-only chat history.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + day: int + speaker_seat: int + speaker_name: str + text: str + + +class PrivateStateSnapshot(BaseEnvelope): + """Master → NPC: full private game state for the seat this NPC plays. + + Sent at game start (after roles assigned) and on NPC re-register so the + NPC bot can rebuild its in-memory state without persisting anything to + disk locally. Idempotent — the NPC replaces its state for that game_id + with the snapshot's contents. + """ + + type: Literal["private_state_snapshot"] = "private_state_snapshot" + npc_id: str + game_id: str + seat_no: int + persona_key: str + role: str # canonical Role enum value (VILLAGER / WEREWOLF / ...) + day_number: int = 0 + alive_seats: tuple[tuple[int, str], ...] = () + dead_seats: tuple[tuple[int, str], ...] = () + # Wolf-only — empty for non-wolves. + partner_wolves: tuple[tuple[int, str], ...] = () + # Role-specific result history. Empty for roles without these powers. + seer_results: tuple[SeerResult, ...] = () + medium_results: tuple[MediumResult, ...] = () + guard_history: tuple[GuardEntry, ...] = () + wolf_chat_history: tuple[WolfChatLine, ...] = () + + +class PrivateStateUpdate(BaseEnvelope): + """Master → NPC: incremental update to the seat's private state. + + Push semantics, append-only on the NPC's local state. ``payload`` shape + depends on ``update_kind``: + + * ``seer_result`` → SeerResult-shaped fields + * ``medium_result`` → MediumResult-shaped fields + * ``guard_entry`` → GuardEntry-shaped fields + * ``guard_resolved`` → ``{"day": int, "peaceful_morning": bool}`` — + retroactively fills the previous night's `peaceful_morning` flag. + * ``wolf_chat`` → WolfChatLine-shaped fields + * ``alive_changed`` → ``{"alive_seats": [...], "dead_seats": [...]}`` + * ``day_advanced`` → ``{"day_number": int}`` + """ + + type: Literal["private_state_update"] = "private_state_update" + npc_id: str + game_id: str + seat_no: int + update_kind: Literal[ + "seer_result", + "medium_result", + "guard_entry", + "guard_resolved", + "wolf_chat", + "alive_changed", + "day_advanced", + ] + payload: dict[str, object] = Field(default_factory=dict) + + +class DecideVoteRequest(BaseEnvelope): + """Master → NPC: decide a vote target for ``round_`` (0=regular, 1=runoff). + + ``candidate_seats`` is the legal target set the state machine produced + (alive seats minus self). NPC may return ``target_seat=None`` to + abstain. + """ + + type: Literal["decide_vote_request"] = "decide_vote_request" + request_id: str + npc_id: str + seat_no: int + game_id: str + phase_id: str + round_: int = 0 + candidate_seats: tuple[tuple[int, str], ...] + public_state_summary: str = "" + expires_at_ms: int + + +class VoteDecision(BaseEnvelope): + """NPC → Master: vote target choice. ``target_seat=None`` = abstain.""" + + type: Literal["vote_decision"] = "vote_decision" + request_id: str + npc_id: str + seat_no: int + target_seat: int | None + reason_summary: str = "" + + +class DecideNightActionRequest(BaseEnvelope): + """Master → NPC: decide a night action target. + + ``action_kind`` determines the seat universe: wolf_attack / seer_divine + / knight_guard. ``candidate_seats`` is the legal target set. + """ + + type: Literal["decide_night_action_request"] = "decide_night_action_request" + request_id: str + npc_id: str + seat_no: int + game_id: str + phase_id: str + action_kind: Literal["wolf_attack", "seer_divine", "knight_guard"] + candidate_seats: tuple[tuple[int, str], ...] + public_state_summary: str = "" + expires_at_ms: int + + +class NightActionDecision(BaseEnvelope): + """NPC → Master: night-action target choice. ``target_seat=None`` = + skip. Master writes the action to ``night_actions`` and applies the + rules at phase resolution.""" + + type: Literal["night_action_decision"] = "night_action_decision" + request_id: str + npc_id: str + seat_no: int + action_kind: Literal["wolf_attack", "seer_divine", "knight_guard"] + target_seat: int | None + reason_summary: str = "" + + +class WolfChatSend(BaseEnvelope): + """NPC (wolf seat only) → Master: post a line to the wolves' private + chat. Master persists it as a `WOLF_CHAT` private LogEntry and pushes + a `wolf_chat` PrivateStateUpdate to every other live wolf seat's NPC.""" + + type: Literal["wolf_chat_send"] = "wolf_chat_send" + npc_id: str + seat_no: int + game_id: str + text: str + + class PlaybackAuthorized(BaseEnvelope): type: Literal["playback_authorized"] = "playback_authorized" request_id: str diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 53e4107..c9df53b 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -215,6 +215,67 @@ async def _master_leave_vc() -> None: finally: master_vc_ref[0] = None + async def _push_private_state_snapshot( + npc_send: Any, + *, + npc_id: str, + game_id: str, + seat_no: int, + persona_key: str, + ) -> None: + """Build and push a `PrivateStateSnapshot` for the seat the NPC plays. + + Called after `SeatAssigned` so the NPC bot has its full Phase-D + state (role, partner wolves, role-result histories) before any + decision is requested. Best-effort — a failed send is logged and + skipped; the NPC will see the historical empty-state behavior on + decision requests until the next snapshot opportunity (re-register). + """ + from wolfbot.master.private_state import build_snapshot_for_seat + + try: + game = await repo.load_game(game_id) + seats = await repo.load_seats(game_id) + players = await repo.load_players(game_id) + except Exception: + log.exception( + "private_state_snapshot_load_failed npc=%s seat=%d game=%s", + npc_id, seat_no, game_id, + ) + return + if game is None: + log.warning( + "private_state_snapshot_skip_no_game npc=%s seat=%d game=%s", + npc_id, seat_no, game_id, + ) + return + me = next((p for p in players if p.seat_no == seat_no), None) + if me is None or me.role is None: + log.warning( + "private_state_snapshot_skip_no_role npc=%s seat=%d game=%s", + npc_id, seat_no, game_id, + ) + return + snapshot = build_snapshot_for_seat( + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + persona_key=persona_key, + role=me.role, + day_number=game.day_number, + players=players, + seats=seats, + ts=int(asyncio.get_running_loop().time() * 1000), + trace_id=f"snapshot-{game_id}-{seat_no}", + ) + try: + await npc_send(snapshot.model_dump_json()) + except Exception: + log.exception( + "private_state_snapshot_send_failed npc=%s seat=%d", + npc_id, seat_no, + ) + async def _assign_online_npcs_to_seats(game_id: str) -> int: """Pair online NPC bots with unassigned LLM seats and tell them to join VC. Idempotent (already-assigned NPCs are skipped). Returns @@ -264,6 +325,17 @@ async def _assign_online_npcs_to_seats(game_id: str) -> int: npc_entry.npc_id, seat.seat_no, ) + continue + # Phase-D: push the seat's full private state right after + # SeatAssigned so the NPC bot can rebuild its in-memory + # game state before the first decision request lands. + await _push_private_state_snapshot( + npc_entry.send, + npc_id=npc_entry.npc_id, + game_id=game_id, + seat_no=seat.seat_no, + persona_key=npc_entry.persona_key or "", + ) return dispatched async def _wait_for_npcs_in_vc(expected_count: int, timeout: float = 5.0) -> None: diff --git a/src/wolfbot/master/private_state.py b/src/wolfbot/master/private_state.py new file mode 100644 index 0000000..4887d64 --- /dev/null +++ b/src/wolfbot/master/private_state.py @@ -0,0 +1,310 @@ +"""Master-side builder for `PrivateStateSnapshot` / `PrivateStateUpdate`. + +In Phase-D (`reactive_voice` mode), each NPC bot is the embodied agent +for its seat and decides speech / vote / night-action via its own LLM. +The bot needs role + role-specific result history + wolf chat to make +those decisions; Master is the source of truth for all of it. + +This module turns Master DB rows into the WS payloads: + +* :func:`build_snapshot_for_seat` — full state replace, sent at game + start and on NPC re-register. +* update factories (e.g. :func:`make_seer_result_update`) — per-event + patches sent as Master computes new private results. + +Pure functions of repo data + ws_messages — no I/O, no asyncio. The +callers (main.py / arbiter / state-machine glue) own the WS send. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from wolfbot.domain.enums import Role +from wolfbot.domain.models import Player, Seat +from wolfbot.domain.ws_messages import ( + GuardEntry, + MediumResult, + PrivateStateSnapshot, + PrivateStateUpdate, + SeerResult, + WolfChatLine, +) + + +def _seat_pairs( + players: Sequence[Player], + seats_by_no: dict[int, Seat], + *, + alive: bool, +) -> tuple[tuple[int, str], ...]: + """``(seat_no, display_name)`` pairs filtered by alive state, sorted by seat.""" + return tuple( + sorted( + (p.seat_no, seats_by_no[p.seat_no].display_name) + for p in players + if p.alive is alive and p.seat_no in seats_by_no + ) + ) + + +def _partner_wolves( + players: Sequence[Player], + seats_by_no: dict[int, Seat], + *, + self_seat: int, +) -> tuple[tuple[int, str], ...]: + """Wolves other than ``self_seat``, alive only, sorted by seat. + + Empty for non-wolves; the caller must only invoke this after + confirming the recipient is `Role.WEREWOLF`. + """ + return tuple( + sorted( + (p.seat_no, seats_by_no[p.seat_no].display_name) + for p in players + if p.role is Role.WEREWOLF + and p.seat_no != self_seat + and p.alive + and p.seat_no in seats_by_no + ) + ) + + +def build_snapshot_for_seat( + *, + npc_id: str, + game_id: str, + seat_no: int, + persona_key: str, + role: Role, + day_number: int, + players: Sequence[Player], + seats: Sequence[Seat], + seer_results: Sequence[SeerResult] = (), + medium_results: Sequence[MediumResult] = (), + guard_history: Sequence[GuardEntry] = (), + wolf_chat_history: Sequence[WolfChatLine] = (), + ts: int, + trace_id: str, +) -> PrivateStateSnapshot: + """Compose the full snapshot the NPC bot rebuilds its state from.""" + seats_by_no = {s.seat_no: s for s in seats} + return PrivateStateSnapshot( + ts=ts, + trace_id=trace_id, + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + persona_key=persona_key, + role=role.value, + day_number=day_number, + alive_seats=_seat_pairs(players, seats_by_no, alive=True), + dead_seats=_seat_pairs(players, seats_by_no, alive=False), + partner_wolves=( + _partner_wolves(players, seats_by_no, self_seat=seat_no) + if role is Role.WEREWOLF + else () + ), + seer_results=tuple(seer_results), + medium_results=tuple(medium_results), + guard_history=tuple(guard_history), + wolf_chat_history=tuple(wolf_chat_history), + ) + + +def make_seer_result_update( + *, + npc_id: str, + game_id: str, + seat_no: int, + day: int, + target_seat: int, + target_name: str, + is_wolf: bool, + ts: int, + trace_id: str, +) -> PrivateStateUpdate: + return PrivateStateUpdate( + ts=ts, + trace_id=trace_id, + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + update_kind="seer_result", + payload={ + "day": day, + "target_seat": target_seat, + "target_name": target_name, + "is_wolf": is_wolf, + }, + ) + + +def make_medium_result_update( + *, + npc_id: str, + game_id: str, + seat_no: int, + day: int, + target_seat: int, + target_name: str, + is_wolf: bool | None, + ts: int, + trace_id: str, +) -> PrivateStateUpdate: + return PrivateStateUpdate( + ts=ts, + trace_id=trace_id, + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + update_kind="medium_result", + payload={ + "day": day, + "target_seat": target_seat, + "target_name": target_name, + "is_wolf": is_wolf, + }, + ) + + +def make_guard_entry_update( + *, + npc_id: str, + game_id: str, + seat_no: int, + day: int, + target_seat: int, + target_name: str, + ts: int, + trace_id: str, +) -> PrivateStateUpdate: + return PrivateStateUpdate( + ts=ts, + trace_id=trace_id, + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + update_kind="guard_entry", + payload={ + "day": day, + "target_seat": target_seat, + "target_name": target_name, + }, + ) + + +def make_guard_resolved_update( + *, + npc_id: str, + game_id: str, + seat_no: int, + day: int, + peaceful_morning: bool, + ts: int, + trace_id: str, +) -> PrivateStateUpdate: + return PrivateStateUpdate( + ts=ts, + trace_id=trace_id, + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + update_kind="guard_resolved", + payload={ + "day": day, + "peaceful_morning": peaceful_morning, + }, + ) + + +def make_wolf_chat_update( + *, + npc_id: str, + game_id: str, + seat_no: int, + day: int, + speaker_seat: int, + speaker_name: str, + text: str, + ts: int, + trace_id: str, +) -> PrivateStateUpdate: + return PrivateStateUpdate( + ts=ts, + trace_id=trace_id, + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + update_kind="wolf_chat", + payload={ + "day": day, + "speaker_seat": speaker_seat, + "speaker_name": speaker_name, + "text": text, + }, + ) + + +def make_alive_changed_update( + *, + npc_id: str, + game_id: str, + seat_no: int, + players: Sequence[Player], + seats: Sequence[Seat], + ts: int, + trace_id: str, +) -> PrivateStateUpdate: + seats_by_no = {s.seat_no: s for s in seats} + return PrivateStateUpdate( + ts=ts, + trace_id=trace_id, + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + update_kind="alive_changed", + payload={ + "alive_seats": [ + [pair[0], pair[1]] + for pair in _seat_pairs(players, seats_by_no, alive=True) + ], + "dead_seats": [ + [pair[0], pair[1]] + for pair in _seat_pairs(players, seats_by_no, alive=False) + ], + }, + ) + + +def make_day_advanced_update( + *, + npc_id: str, + game_id: str, + seat_no: int, + day_number: int, + ts: int, + trace_id: str, +) -> PrivateStateUpdate: + return PrivateStateUpdate( + ts=ts, + trace_id=trace_id, + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + update_kind="day_advanced", + payload={"day_number": day_number}, + ) + + +__all__ = [ + "build_snapshot_for_seat", + "make_alive_changed_update", + "make_day_advanced_update", + "make_guard_entry_update", + "make_guard_resolved_update", + "make_medium_result_update", + "make_seer_result_update", + "make_wolf_chat_update", +] diff --git a/src/wolfbot/npc/client.py b/src/wolfbot/npc/client.py index 4ac98f8..3b0051c 100644 --- a/src/wolfbot/npc/client.py +++ b/src/wolfbot/npc/client.py @@ -23,21 +23,28 @@ from dataclasses import dataclass, field from wolfbot.domain.ws_messages import ( + DecideNightActionRequest, + DecideVoteRequest, Heartbeat, LogicPacket, + NightActionDecision, NpcRegister, NpcRegistered, PlaybackAuthorized, PlaybackFailed, PlaybackFinished, PlaybackRejected, + PrivateStateSnapshot, + PrivateStateUpdate, SeatAssigned, SeatReleased, SetMuteState, SpeakRequest, TtsFailed, TtsFinished, + VoteDecision, ) +from wolfbot.npc.game_state import NpcGameState, apply_update, state_from_snapshot from wolfbot.npc.playback import ( VoicePlayback, VoicePlaybackError, @@ -109,6 +116,11 @@ class NpcClient: assigned_seat: int | None = None assigned_game_id: str | None = None assigned_phase_id: str | None = None + # Phase-D: per-(game_id) private state mirror. Master pushes via + # PrivateStateSnapshot (full replace) at game start + NPC re-register, + # then PrivateStateUpdate (append/patch) for incremental events. The + # NPC bot uses this in vote / night / speech decision handlers. + game_states: dict[str, NpcGameState] = field(default_factory=dict) # ---------------------------------------------------------- registration @@ -157,6 +169,22 @@ async def process_message(self, raw_json: str) -> None: await self._on_playback_authorized(PlaybackAuthorized.model_validate(payload)) elif t == "playback_rejected": self._on_playback_rejected(PlaybackRejected.model_validate(payload)) + elif t == "private_state_snapshot": + self._on_private_state_snapshot( + PrivateStateSnapshot.model_validate(payload) + ) + elif t == "private_state_update": + self._on_private_state_update( + PrivateStateUpdate.model_validate(payload) + ) + elif t == "decide_vote_request": + await self._on_decide_vote_request( + DecideVoteRequest.model_validate(payload) + ) + elif t == "decide_night_action_request": + await self._on_decide_night_action_request( + DecideNightActionRequest.model_validate(payload) + ) else: log.info("npc_client_unhandled_type type=%s", t) @@ -364,6 +392,107 @@ def _on_playback_rejected(self, msg: PlaybackRejected) -> None: msg.failure_reason, ) + # ---------------------------------------------------------- phase-D state + + def _on_private_state_snapshot(self, snapshot: PrivateStateSnapshot) -> None: + """Replace the per-game state for ``snapshot.game_id`` wholesale. + + Targeted at this NPC only — Master sends one snapshot per NPC at + game start and on re-register. Receiving one for a different + ``npc_id`` is a routing bug; we log and drop. + """ + if snapshot.npc_id != self.config.npc_id: + log.warning( + "npc_private_state_snapshot_misrouted self=%s got=%s", + self.config.npc_id, snapshot.npc_id, + ) + return + self.game_states[snapshot.game_id] = state_from_snapshot(snapshot) + log.info( + "npc_private_state_snapshot game=%s seat=%d role=%s persona=%s " + "alive=%d wolf_partners=%d seer=%d medium=%d guards=%d wolf_chat=%d", + snapshot.game_id, + snapshot.seat_no, + snapshot.role, + snapshot.persona_key, + len(snapshot.alive_seats), + len(snapshot.partner_wolves), + len(snapshot.seer_results), + len(snapshot.medium_results), + len(snapshot.guard_history), + len(snapshot.wolf_chat_history), + ) + + def _on_private_state_update(self, update: PrivateStateUpdate) -> None: + """Mutate the per-game state for ``update.game_id`` in place. + + A missing snapshot for that game is treated as a Master-side bug + (snapshot must precede every update). We log and drop rather than + synthesize state from incomplete information. + """ + if update.npc_id != self.config.npc_id: + log.warning( + "npc_private_state_update_misrouted self=%s got=%s", + self.config.npc_id, update.npc_id, + ) + return + state = self.game_states.get(update.game_id) + if state is None: + log.warning( + "npc_private_state_update_no_snapshot game=%s kind=%s", + update.game_id, update.update_kind, + ) + return + apply_update(state, update) + log.info( + "npc_private_state_update game=%s kind=%s", + update.game_id, update.update_kind, + ) + + async def _on_decide_vote_request(self, req: DecideVoteRequest) -> None: + """Phase-D: NPC decides its own vote target. + + This first cut is a placeholder — it returns ``target_seat=None`` + (abstain) so the wire is fully exercised end-to-end before the + LLM-backed decision logic lands. Master records the abstention and + the viewer surfaces the seat as silent for that vote. + """ + if req.npc_id != self.config.npc_id: + return + decision = VoteDecision( + ts=self.now_ms(), + trace_id=req.trace_id, + request_id=req.request_id, + npc_id=self.config.npc_id, + seat_no=req.seat_no, + target_seat=None, + reason_summary="phase_d_stub_abstain", + ) + await self.send(decision.model_dump_json()) + + async def _on_decide_night_action_request( + self, req: DecideNightActionRequest + ) -> None: + """Phase-D: NPC decides its own night action. + + Stub identical to the vote handler — returns ``target_seat=None`` + (skip) so Master's resolution path can be wired up first. The + LLM-backed decision lands in a follow-up commit. + """ + if req.npc_id != self.config.npc_id: + return + decision = NightActionDecision( + ts=self.now_ms(), + trace_id=req.trace_id, + request_id=req.request_id, + npc_id=self.config.npc_id, + seat_no=req.seat_no, + action_kind=req.action_kind, + target_seat=None, + reason_summary="phase_d_stub_skip", + ) + await self.send(decision.model_dump_json()) + __all__ = ["NpcClient", "NpcClientConfig"] diff --git a/src/wolfbot/npc/game_state.py b/src/wolfbot/npc/game_state.py new file mode 100644 index 0000000..24622cb --- /dev/null +++ b/src/wolfbot/npc/game_state.py @@ -0,0 +1,187 @@ +"""Per-game in-memory state held by an NPC bot. + +Phase-D architecture (`reactive_voice` mode): each NPC bot is the embodied +agent for its seat. It owns the role + role-specific results + wolf chat +history and uses them when deciding speech / vote / night action via its +own `NPC_LLM_*`. Master pushes state via `PrivateStateSnapshot` (full +replace) and `PrivateStateUpdate` (append/patch) so the NPC never reads +the Master DB or persists state locally — a process restart re-hydrates +from a Master-sent snapshot at re-register. + +This module is the pure data container. The dispatch / WS handlers live +in :mod:`wolfbot.npc.client`; the prompt-building consumers live in the +NPC LLM generators. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from wolfbot.domain.ws_messages import ( + GuardEntry, + MediumResult, + PrivateStateSnapshot, + PrivateStateUpdate, + SeerResult, + WolfChatLine, +) + + +def _as_int(value: Any) -> int: + """Coerce a JSON-decoded payload value to int. Raises ``TypeError`` on bool + (which would otherwise silently be treated as 0/1) and missing values.""" + if isinstance(value, bool) or value is None: + raise TypeError(f"expected int-like, got {type(value).__name__}") + if isinstance(value, int): + return value + if isinstance(value, str): + return int(value) + raise TypeError(f"expected int-like, got {type(value).__name__}") + + +def _as_str(value: Any) -> str: + if not isinstance(value, str): + raise TypeError(f"expected str, got {type(value).__name__}") + return value + + +def _as_bool(value: Any) -> bool: + if not isinstance(value, bool): + raise TypeError(f"expected bool, got {type(value).__name__}") + return value + + +def _opt_bool(value: Any) -> bool | None: + if value is None: + return None + return _as_bool(value) + + +@dataclass +class NpcGameState: + """Per-(game, seat) state mirror. + + The NPC bot keeps one of these per active game. Snapshots replace it + wholesale; updates mutate it in place via ``apply_update``. Mutability + is a deliberate choice — the WS handler fast path stays cheap and the + state is process-local. + """ + + game_id: str + seat_no: int + persona_key: str + role: str + day_number: int = 0 + alive_seats: list[tuple[int, str]] = field(default_factory=list) + dead_seats: list[tuple[int, str]] = field(default_factory=list) + partner_wolves: list[tuple[int, str]] = field(default_factory=list) + seer_results: list[SeerResult] = field(default_factory=list) + medium_results: list[MediumResult] = field(default_factory=list) + guard_history: list[GuardEntry] = field(default_factory=list) + wolf_chat_history: list[WolfChatLine] = field(default_factory=list) + + +def state_from_snapshot(snapshot: PrivateStateSnapshot) -> NpcGameState: + """Rebuild state from a `PrivateStateSnapshot` (full replace semantics).""" + return NpcGameState( + game_id=snapshot.game_id, + seat_no=snapshot.seat_no, + persona_key=snapshot.persona_key, + role=snapshot.role, + day_number=snapshot.day_number, + alive_seats=list(snapshot.alive_seats), + dead_seats=list(snapshot.dead_seats), + partner_wolves=list(snapshot.partner_wolves), + seer_results=list(snapshot.seer_results), + medium_results=list(snapshot.medium_results), + guard_history=list(snapshot.guard_history), + wolf_chat_history=list(snapshot.wolf_chat_history), + ) + + +def apply_update(state: NpcGameState, update: PrivateStateUpdate) -> None: + """Mutate `state` in place by `update`. Unknown kinds are silently + ignored so a newer Master can add update kinds without breaking older + NPC builds. + + Each branch tolerates malformed payloads (missing/typed-wrong keys) + by skipping the update — the in-memory state stays consistent rather + than crashing the bot. + """ + kind = update.update_kind + payload = update.payload + try: + if kind == "seer_result": + state.seer_results.append( + SeerResult( + day=_as_int(payload["day"]), + target_seat=_as_int(payload["target_seat"]), + target_name=_as_str(payload["target_name"]), + is_wolf=_as_bool(payload["is_wolf"]), + ) + ) + elif kind == "medium_result": + state.medium_results.append( + MediumResult( + day=_as_int(payload["day"]), + target_seat=_as_int(payload["target_seat"]), + target_name=_as_str(payload["target_name"]), + is_wolf=_opt_bool(payload.get("is_wolf")), + ) + ) + elif kind == "guard_entry": + state.guard_history.append( + GuardEntry( + day=_as_int(payload["day"]), + target_seat=_as_int(payload["target_seat"]), + target_name=_as_str(payload["target_name"]), + peaceful_morning=_opt_bool(payload.get("peaceful_morning")), + ) + ) + elif kind == "guard_resolved": + day = _as_int(payload["day"]) + peaceful = _as_bool(payload["peaceful_morning"]) + state.guard_history = [ + entry.model_copy(update={"peaceful_morning": peaceful}) + if entry.day == day and entry.peaceful_morning is None + else entry + for entry in state.guard_history + ] + elif kind == "wolf_chat": + state.wolf_chat_history.append( + WolfChatLine( + day=_as_int(payload["day"]), + speaker_seat=_as_int(payload["speaker_seat"]), + speaker_name=_as_str(payload["speaker_name"]), + text=_as_str(payload["text"]), + ) + ) + elif kind == "alive_changed": + alive = payload.get("alive_seats", []) + dead = payload.get("dead_seats", []) + if isinstance(alive, list): + state.alive_seats = [ + (_as_int(s[0]), _as_str(s[1])) + for s in alive + if isinstance(s, (list, tuple)) and len(s) >= 2 + ] + if isinstance(dead, list): + state.dead_seats = [ + (_as_int(s[0]), _as_str(s[1])) + for s in dead + if isinstance(s, (list, tuple)) and len(s) >= 2 + ] + elif kind == "day_advanced": + state.day_number = _as_int(payload["day_number"]) + # Unknown kinds: silently skip for forward-compat. + except (KeyError, TypeError, ValueError): + # Best-effort — malformed payload from Master shouldn't crash the bot. + pass + + +__all__ = [ + "NpcGameState", + "apply_update", + "state_from_snapshot", +] diff --git a/tests/test_master_private_state.py b/tests/test_master_private_state.py new file mode 100644 index 0000000..8c99617 --- /dev/null +++ b/tests/test_master_private_state.py @@ -0,0 +1,239 @@ +"""Master-side `PrivateStateSnapshot` / `PrivateStateUpdate` factories. + +Pure-data tests — no DB or asyncio. Verifies that the WS payloads sent to +NPC bots over Phase-D include the right per-seat shape: + +* alive/dead seat lists are correctly partitioned + sorted. +* `partner_wolves` only fills for `WEREWOLF` and excludes the recipient. +* Update factories produce well-typed `payload` dicts that the NPC + bot's `apply_update` can fold (round-trip via `state_from_snapshot` + + `apply_update` reproduces the expected state). +""" + +from __future__ import annotations + +from wolfbot.domain.enums import Role +from wolfbot.domain.models import Player, Seat +from wolfbot.master.private_state import ( + build_snapshot_for_seat, + make_alive_changed_update, + make_day_advanced_update, + make_guard_entry_update, + make_guard_resolved_update, + make_medium_result_update, + make_seer_result_update, + make_wolf_chat_update, +) +from wolfbot.npc.game_state import apply_update, state_from_snapshot + + +def _seats() -> list[Seat]: + return [ + Seat(seat_no=1, display_name="Alice", is_llm=False, persona_key=None, + discord_user_id="u1"), + Seat(seat_no=2, display_name="Bob", is_llm=True, persona_key="setsu", + discord_user_id=None), + Seat(seat_no=3, display_name="Carol", is_llm=True, persona_key="gina", + discord_user_id=None), + Seat(seat_no=4, display_name="Dave", is_llm=True, persona_key="jonas", + discord_user_id=None), + ] + + +def _players_with_one_wolf_dead() -> list[Player]: + """Two wolves total, one alive (seat 2), one dead (seat 3).""" + return [ + Player(seat_no=1, role=Role.VILLAGER, alive=True), + Player(seat_no=2, role=Role.WEREWOLF, alive=True), + Player(seat_no=3, role=Role.WEREWOLF, alive=False, + death_cause=None, death_day=1), + Player(seat_no=4, role=Role.SEER, alive=True), + ] + + +def test_snapshot_partitions_alive_dead_and_sorts() -> None: + snap = build_snapshot_for_seat( + npc_id="npc_setsu", + game_id="g1", + seat_no=2, + persona_key="setsu", + role=Role.WEREWOLF, + day_number=2, + players=_players_with_one_wolf_dead(), + seats=_seats(), + ts=1000, + trace_id="t", + ) + assert snap.alive_seats == ((1, "Alice"), (2, "Bob"), (4, "Dave")) + assert snap.dead_seats == ((3, "Carol"),) + assert snap.role == "WEREWOLF" + assert snap.day_number == 2 + + +def test_snapshot_partner_wolves_only_for_werewolf_excludes_self_and_dead() -> None: + # Recipient is the alive wolf (seat 2) — partner is seat 3 but dead. + snap = build_snapshot_for_seat( + npc_id="npc_setsu", + game_id="g1", + seat_no=2, + persona_key="setsu", + role=Role.WEREWOLF, + day_number=2, + players=_players_with_one_wolf_dead(), + seats=_seats(), + ts=1000, + trace_id="t", + ) + # Dead partner is excluded; recipient is excluded; nothing left. + assert snap.partner_wolves == () + + +def test_snapshot_partner_wolves_excludes_self() -> None: + players = [ + Player(seat_no=1, role=Role.VILLAGER, alive=True), + Player(seat_no=2, role=Role.WEREWOLF, alive=True), + Player(seat_no=3, role=Role.WEREWOLF, alive=True), + ] + seats = [ + Seat(seat_no=n, display_name=name, is_llm=True, persona_key="x", + discord_user_id=None) + for n, name in [(1, "Alice"), (2, "Bob"), (3, "Carol")] + ] + snap = build_snapshot_for_seat( + npc_id="npc_x", + game_id="g1", + seat_no=2, + persona_key="setsu", + role=Role.WEREWOLF, + day_number=1, + players=players, + seats=seats, + ts=1000, + trace_id="t", + ) + # Only seat 3 (the other living wolf) is in partner_wolves. + assert snap.partner_wolves == ((3, "Carol"),) + + +def test_snapshot_non_wolf_role_has_no_partners() -> None: + snap = build_snapshot_for_seat( + npc_id="npc_jonas", + game_id="g1", + seat_no=4, + persona_key="jonas", + role=Role.SEER, + day_number=2, + players=_players_with_one_wolf_dead(), + seats=_seats(), + ts=1000, + trace_id="t", + ) + assert snap.partner_wolves == () + + +def test_seer_result_update_round_trips_into_state() -> None: + snap = build_snapshot_for_seat( + npc_id="npc_jonas", game_id="g1", seat_no=4, persona_key="jonas", + role=Role.SEER, day_number=1, + players=_players_with_one_wolf_dead(), seats=_seats(), + ts=1000, trace_id="t", + ) + state = state_from_snapshot(snap) + upd = make_seer_result_update( + npc_id="npc_jonas", game_id="g1", seat_no=4, + day=1, target_seat=2, target_name="Bob", is_wolf=True, + ts=2000, trace_id="t2", + ) + apply_update(state, upd) + assert len(state.seer_results) == 1 + sr = state.seer_results[0] + assert sr.target_seat == 2 and sr.is_wolf is True + + +def test_medium_result_update_can_carry_null_is_wolf() -> None: + snap = build_snapshot_for_seat( + npc_id="npc_x", game_id="g1", seat_no=1, persona_key="x", + role=Role.MEDIUM, day_number=1, + players=[Player(seat_no=1, role=Role.MEDIUM, alive=True)], + seats=[Seat(seat_no=1, display_name="Alice", is_llm=True, persona_key="x", discord_user_id=None)], + ts=1, trace_id="t", + ) + state = state_from_snapshot(snap) + upd = make_medium_result_update( + npc_id="npc_x", game_id="g1", seat_no=1, + day=1, target_seat=2, target_name="Bob", is_wolf=None, + ts=2, trace_id="t2", + ) + apply_update(state, upd) + assert state.medium_results[0].is_wolf is None + + +def test_guard_entry_then_resolved_fills_peaceful_flag() -> None: + snap = build_snapshot_for_seat( + npc_id="npc_x", game_id="g1", seat_no=1, persona_key="x", + role=Role.KNIGHT, day_number=1, + players=[Player(seat_no=1, role=Role.KNIGHT, alive=True)], + seats=[Seat(seat_no=1, display_name="Alice", is_llm=True, persona_key="x", discord_user_id=None)], + ts=1, trace_id="t", + ) + state = state_from_snapshot(snap) + apply_update(state, make_guard_entry_update( + npc_id="npc_x", game_id="g1", seat_no=1, + day=1, target_seat=2, target_name="Bob", ts=2, trace_id="t2", + )) + apply_update(state, make_guard_resolved_update( + npc_id="npc_x", game_id="g1", seat_no=1, + day=1, peaceful_morning=True, ts=3, trace_id="t3", + )) + assert state.guard_history[0].peaceful_morning is True + + +def test_wolf_chat_update_appends() -> None: + snap = build_snapshot_for_seat( + npc_id="npc_x", game_id="g1", seat_no=2, persona_key="x", + role=Role.WEREWOLF, day_number=1, + players=_players_with_one_wolf_dead(), seats=_seats(), + ts=1, trace_id="t", + ) + state = state_from_snapshot(snap) + apply_update(state, make_wolf_chat_update( + npc_id="npc_x", game_id="g1", seat_no=2, + day=1, speaker_seat=3, speaker_name="Carol", + text="席1を狙おう", ts=2, trace_id="t2", + )) + assert state.wolf_chat_history[-1].text == "席1を狙おう" + + +def test_alive_changed_update_replaces_lists() -> None: + snap = build_snapshot_for_seat( + npc_id="npc_x", game_id="g1", seat_no=1, persona_key="x", + role=Role.VILLAGER, day_number=1, + players=_players_with_one_wolf_dead(), seats=_seats(), + ts=1, trace_id="t", + ) + state = state_from_snapshot(snap) + new_players = list(_players_with_one_wolf_dead()) + new_players[0] = Player(seat_no=1, role=Role.VILLAGER, alive=False, + death_cause=None, death_day=2) + apply_update(state, make_alive_changed_update( + npc_id="npc_x", game_id="g1", seat_no=1, + players=new_players, seats=_seats(), ts=2, trace_id="t2", + )) + alive_set = {p[0] for p in state.alive_seats} + dead_set = {p[0] for p in state.dead_seats} + assert 1 in dead_set and 1 not in alive_set + + +def test_day_advanced_update_increments_day() -> None: + snap = build_snapshot_for_seat( + npc_id="npc_x", game_id="g1", seat_no=1, persona_key="x", + role=Role.VILLAGER, day_number=1, + players=_players_with_one_wolf_dead(), seats=_seats(), + ts=1, trace_id="t", + ) + state = state_from_snapshot(snap) + apply_update(state, make_day_advanced_update( + npc_id="npc_x", game_id="g1", seat_no=1, + day_number=3, ts=2, trace_id="t2", + )) + assert state.day_number == 3 diff --git a/tests/test_npc_game_state.py b/tests/test_npc_game_state.py new file mode 100644 index 0000000..6d571a3 --- /dev/null +++ b/tests/test_npc_game_state.py @@ -0,0 +1,315 @@ +"""Phase-D NPC bot per-game state — snapshot rebuild + update fold. + +Covers: +* `state_from_snapshot` rebuilds an `NpcGameState` 1:1 from a snapshot. +* `apply_update` correctly mutates state for each `update_kind`. +* Malformed payloads are dropped without crashing. +* `NpcClient` routes the new WS message types to the state mirror, and + the decision request handlers reply with the stub abstain/skip + decisions used until the LLM-backed logic lands. +""" + +from __future__ import annotations + +import pytest + +from wolfbot.domain.ws_messages import ( + DecideNightActionRequest, + DecideVoteRequest, + GuardEntry, + MediumResult, + NightActionDecision, + PrivateStateSnapshot, + PrivateStateUpdate, + SeerResult, + VoteDecision, + WolfChatLine, +) +from wolfbot.npc.client import NpcClient, NpcClientConfig +from wolfbot.npc.game_state import ( + NpcGameState, + apply_update, + state_from_snapshot, +) + + +def _snapshot(**overrides: object) -> PrivateStateSnapshot: + base: dict[str, object] = { + "ts": 1000, + "trace_id": "snap-1", + "npc_id": "npc_setsu", + "game_id": "g1", + "seat_no": 3, + "persona_key": "setsu", + "role": "WEREWOLF", + "day_number": 1, + "alive_seats": ((1, "Alice"), (3, "セツ"), (5, "Bob")), + "dead_seats": (), + "partner_wolves": ((5, "Bob"),), + "seer_results": (), + "medium_results": (), + "guard_history": (), + "wolf_chat_history": ( + WolfChatLine( + day=0, speaker_seat=5, speaker_name="Bob", text="夜は静かだな" + ), + ), + } + base.update(overrides) + return PrivateStateSnapshot(**base) # type: ignore[arg-type] + + +def test_state_from_snapshot_round_trips_fields() -> None: + snap = _snapshot() + state = state_from_snapshot(snap) + assert state.game_id == "g1" + assert state.seat_no == 3 + assert state.role == "WEREWOLF" + assert state.persona_key == "setsu" + assert state.day_number == 1 + assert state.alive_seats == [(1, "Alice"), (3, "セツ"), (5, "Bob")] + assert state.partner_wolves == [(5, "Bob")] + assert len(state.wolf_chat_history) == 1 + assert state.wolf_chat_history[0].text == "夜は静かだな" + + +def test_apply_update_seer_result_appends() -> None: + state = state_from_snapshot(_snapshot(role="SEER", partner_wolves=())) + upd = PrivateStateUpdate( + ts=2000, + trace_id="upd-1", + npc_id="npc_setsu", + game_id="g1", + seat_no=3, + update_kind="seer_result", + payload={ + "day": 1, "target_seat": 5, "target_name": "Bob", "is_wolf": True, + }, + ) + apply_update(state, upd) + assert state.seer_results == [ + SeerResult(day=1, target_seat=5, target_name="Bob", is_wolf=True) + ] + + +def test_apply_update_medium_result_handles_null_is_wolf() -> None: + state = state_from_snapshot(_snapshot(role="MEDIUM", partner_wolves=())) + upd = PrivateStateUpdate( + ts=3000, trace_id="t", npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="medium_result", + payload={"day": 1, "target_seat": 7, "target_name": "Carol", "is_wolf": None}, + ) + apply_update(state, upd) + assert state.medium_results == [ + MediumResult(day=1, target_seat=7, target_name="Carol", is_wolf=None) + ] + + +def test_apply_update_guard_resolved_fills_peaceful_flag() -> None: + state = state_from_snapshot( + _snapshot( + role="KNIGHT", + partner_wolves=(), + guard_history=( + GuardEntry(day=1, target_seat=5, target_name="Bob"), + ), + ) + ) + upd = PrivateStateUpdate( + ts=4000, trace_id="t", npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="guard_resolved", + payload={"day": 1, "peaceful_morning": True}, + ) + apply_update(state, upd) + assert state.guard_history[0].peaceful_morning is True + + +def test_apply_update_wolf_chat_appends() -> None: + state = state_from_snapshot(_snapshot()) + upd = PrivateStateUpdate( + ts=5000, trace_id="t", npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="wolf_chat", + payload={ + "day": 1, "speaker_seat": 5, "speaker_name": "Bob", "text": "席1を狙おう", + }, + ) + apply_update(state, upd) + assert state.wolf_chat_history[-1].text == "席1を狙おう" + + +def test_apply_update_alive_changed_replaces_lists() -> None: + state = state_from_snapshot(_snapshot()) + upd = PrivateStateUpdate( + ts=6000, trace_id="t", npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="alive_changed", + payload={ + "alive_seats": [[1, "Alice"], [3, "セツ"]], + "dead_seats": [[5, "Bob"]], + }, + ) + apply_update(state, upd) + assert state.alive_seats == [(1, "Alice"), (3, "セツ")] + assert state.dead_seats == [(5, "Bob")] + + +def test_apply_update_day_advanced_updates_counter() -> None: + state = state_from_snapshot(_snapshot()) + upd = PrivateStateUpdate( + ts=7000, trace_id="t", npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="day_advanced", payload={"day_number": 2}, + ) + apply_update(state, upd) + assert state.day_number == 2 + + +def test_apply_update_malformed_payload_drops_silently() -> None: + state = state_from_snapshot(_snapshot()) + before = list(state.seer_results) + upd = PrivateStateUpdate( + ts=8000, trace_id="t", npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="seer_result", + payload={"day": "not-a-number", "target_seat": 5}, + ) + apply_update(state, upd) + assert state.seer_results == before + + +def test_apply_update_unknown_kind_is_noop() -> None: + state = state_from_snapshot(_snapshot()) + snapshot_before = ( + list(state.seer_results), + list(state.wolf_chat_history), + state.day_number, + ) + # Bypass Pydantic's Literal validation by reaching for `model_construct` + # — emulates a Master that emits a future ``update_kind`` we don't know. + upd = PrivateStateUpdate.model_construct( + ts=9000, trace_id="t", type="private_state_update", + npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="future_kind_not_yet_defined", # type: ignore[arg-type] + payload={"foo": "bar"}, + ) + apply_update(state, upd) + assert ( + list(state.seer_results), + list(state.wolf_chat_history), + state.day_number, + ) == snapshot_before + + +# --------------------------------------------- NpcClient WS routing + + +def _make_client_with_capture() -> tuple[NpcClient, list[str]]: + """Lightweight NpcClient + outbound capture buffer. + + The phase-D message paths only use `send`, `now_ms`, and `config.npc_id`, + so the heavier collaborators (TTS, playback, speech service) can be + minimal stubs — they're never exercised by these tests. + """ + sent: list[str] = [] + + async def _send(msg: str) -> None: + sent.append(msg) + + class _StubSpeech: + async def respond(self, **kwargs: object) -> None: # pragma: no cover + raise AssertionError("speech service should not be invoked") + + class _StubTts: + async def synthesize(self, *args: object, **kwargs: object) -> None: # pragma: no cover + raise AssertionError("TTS should not be invoked") + + class _StubPlayback: + async def play(self, **kwargs: object) -> tuple[int, int]: # pragma: no cover + raise AssertionError("playback should not be invoked") + + client = NpcClient( + config=NpcClientConfig( + npc_id="npc_setsu", + discord_bot_user_id="bot1", + persona_key="setsu", + voice_id="2", + ), + speech=_StubSpeech(), # type: ignore[arg-type] + tts=_StubTts(), # type: ignore[arg-type] + playback=_StubPlayback(), # type: ignore[arg-type] + send=_send, + now_ms=lambda: 999, + ) + return client, sent + + +@pytest.mark.asyncio +async def test_client_processes_snapshot_and_update_and_decision_requests() -> None: + client, sent = _make_client_with_capture() + + await client.process_message(_snapshot().model_dump_json()) + assert "g1" in client.game_states + assert client.game_states["g1"].role == "WEREWOLF" + + upd = PrivateStateUpdate( + ts=2000, trace_id="t", npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="day_advanced", payload={"day_number": 4}, + ) + await client.process_message(upd.model_dump_json()) + assert client.game_states["g1"].day_number == 4 + + vote_req = DecideVoteRequest( + ts=3000, trace_id="t-vote", + request_id="rv1", npc_id="npc_setsu", seat_no=3, + game_id="g1", phase_id="g1::day1::DAY_VOTE::1", + candidate_seats=((1, "Alice"), (5, "Bob")), + expires_at_ms=10_000, + ) + await client.process_message(vote_req.model_dump_json()) + decisions = [VoteDecision.model_validate_json(m) for m in sent if '"vote_decision"' in m] + assert len(decisions) == 1 + assert decisions[0].request_id == "rv1" + assert decisions[0].target_seat is None # phase-D stub abstains for now + + night_req = DecideNightActionRequest( + ts=4000, trace_id="t-night", + request_id="rn1", npc_id="npc_setsu", seat_no=3, + game_id="g1", phase_id="g1::day1::NIGHT::1", + action_kind="wolf_attack", + candidate_seats=((1, "Alice"),), + expires_at_ms=20_000, + ) + await client.process_message(night_req.model_dump_json()) + night_decisions = [ + NightActionDecision.model_validate_json(m) for m in sent if '"night_action_decision"' in m + ] + assert len(night_decisions) == 1 + assert night_decisions[0].request_id == "rn1" + assert night_decisions[0].target_seat is None + assert night_decisions[0].action_kind == "wolf_attack" + + +@pytest.mark.asyncio +async def test_client_drops_misrouted_state_messages() -> None: + client, _sent = _make_client_with_capture() + other = _snapshot(npc_id="npc_other") + await client.process_message(other.model_dump_json()) + assert "g1" not in client.game_states + + +@pytest.mark.asyncio +async def test_client_drops_update_when_no_snapshot_seen() -> None: + client, _sent = _make_client_with_capture() + upd = PrivateStateUpdate( + ts=5000, trace_id="t", npc_id="npc_setsu", game_id="never_snapshotted", + seat_no=3, update_kind="day_advanced", payload={"day_number": 9}, + ) + await client.process_message(upd.model_dump_json()) + assert "never_snapshotted" not in client.game_states + + +def test_npcgamestate_constructs_with_defaults() -> None: + """Sanity: the dataclass instantiates with defaults so test fixtures + can build empty state without going through a snapshot.""" + state = NpcGameState( + game_id="g", seat_no=1, persona_key="setsu", role="VILLAGER" + ) + assert state.game_id == "g" + assert state.seer_results == [] From 6b09a6cfdd42867a6813c867d7c5ddb84af0af2b Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 23:29:57 +0900 Subject: [PATCH 045/133] =?UTF-8?q?feat(reactive=5Fvoice):=20Phase-D=20vot?= =?UTF-8?q?e=20path=20=E2=80=94=20NPC=20bot=20decides=20its=20own=20vote?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vote decisions for reactive_voice games now route through each seat's NPC bot via WS instead of the Master gameplay LLM. The NPC bot reads its `NpcGameState` mirror (role + private results + wolf chat) and calls its own `NPC_LLM_*` with a strict JSON schema; the same provider/ credentials drive both speech and decision so no new env wiring is needed. Master: - master/decision_dispatcher.py: NpcDecisionDispatcher fans out DecideVoteRequest / DecideNightActionRequest, parks an asyncio.Future per request_id, resolves on VoteDecision/NightActionDecision arrival, defaults to None on offline NPC / send-failure / TTL timeout. - master/ws_server.py: routes the two new inbound message types to the dispatcher's `on_*_decision` handlers. - main.py: instantiates the dispatcher alongside SpeakArbiter and attaches `MasterHandlers.on_vote_decision` / `on_night_action_decision` callbacks; injects the dispatcher into LLMAdapter. - services/llm_service.py: `submit_llm_votes` branches on `game.discussion_mode` — for reactive_voice games it dispatches via the NPC bot; for rounds the historical gameplay-LLM path runs unchanged. Illegal NPC targets default to abstain (None). NPC bot: - npc/decision_service.py: per-seat prompt builders that wrap the bot's NpcGameState into a Japanese prompt block (role, partner wolves, seer/medium/guard history, wolf chat) plus persona + role_strategy from prompt_builder. parse_decision validates the LLM response against the legal seat set; bool / illegal / non-int / malformed-JSON responses fall back to abstain. - npc/openai_compatible_generator.py: `decide_json` method reuses the speech generator's client + provider config for structured decision calls. Logged under role="npc_decision" in the LLM trace. - npc/client.py: vote / night handlers now call decision_service instead of returning the placeholder; missing state, missing LLM, or LLM error all fall back to None target with a logged reason. - npc/main.py: wires `decision_llm` for the OpenAI-compatible backend. Mock / Gemini generators leave it None until they grow a decide_json of their own (back-compat — bot still functions, just abstains on every decision). Tests cover the dispatcher (online resolve, offline → None, send failure → None, timeout → None, action_kind round-trip, payload shape), parse_decision (illegal target / bool-as-int / null target / bad JSON), prompt builders (state + candidates surfaces correctly), and the LLMAdapter reactive_voice branch (gameplay decider not invoked when dispatcher is wired; falls back to decider when dispatcher is None for back-compat). --- src/wolfbot/main.py | 20 + src/wolfbot/master/decision_dispatcher.py | 368 ++++++++++++++++++ src/wolfbot/master/ws_server.py | 24 ++ src/wolfbot/npc/client.py | 104 ++++- src/wolfbot/npc/decision_service.py | 311 +++++++++++++++ src/wolfbot/npc/main.py | 10 + .../npc/openai_compatible_generator.py | 97 +++++ src/wolfbot/services/llm_service.py | 139 ++++++- tests/test_llm_service.py | 96 ++++- tests/test_master_decision_dispatcher.py | 298 ++++++++++++++ tests/test_npc_decision_service.py | 130 +++++++ 11 files changed, 1579 insertions(+), 18 deletions(-) create mode 100644 src/wolfbot/master/decision_dispatcher.py create mode 100644 src/wolfbot/npc/decision_service.py create mode 100644 tests/test_master_decision_dispatcher.py create mode 100644 tests/test_npc_decision_service.py diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index c9df53b..2f1719a 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -589,6 +589,18 @@ async def _on_speech_recorded(game_id: str) -> None: _reactive_phase_cb.append(arbiter) recovery._reactive_voice_sweep = arbiter.reactive_voice_recovery_sweep + # Phase-D: NPC bot dispatcher for vote / night-action decisions. + # Wired into LLMAdapter so reactive_voice games skip the gameplay + # decider and ask the NPC bot for its own seat's vote / night + # action via WS. + from wolfbot.master.decision_dispatcher import NpcDecisionDispatcher + + decision_dispatcher = NpcDecisionDispatcher( + registry=npc_registry, + now_ms=lambda: int(time.time() * 1000), + ) + llm_adapter._npc_decision_dispatcher = decision_dispatcher + async def _reactive_voice_reenter(game_id: str) -> None: # On Master restart, reactive_voice games still in # DAY_DISCUSSION need their VC joined again before the @@ -909,6 +921,12 @@ async def _on_vad_ended(msg: Any, _ctx: Any) -> None: # mark_pending_stt records the segment for finalization timeout. arbiter.mark_pending_stt(segment_id) + async def _on_vote_decision(msg: Any, _ctx: Any) -> None: + await decision_dispatcher.on_vote_decision(msg) + + async def _on_night_action_decision(msg: Any, _ctx: Any) -> None: + await decision_dispatcher.on_night_action_decision(msg) + master_handlers = MasterHandlers( registry=npc_registry, on_speak_result=_on_speak_result, @@ -920,6 +938,8 @@ async def _on_vad_ended(msg: Any, _ctx: Any) -> None: on_vad_started=_on_vad_started, on_vad_ended=_on_vad_ended, on_stt_failed=_on_stt_failed, + on_vote_decision=_on_vote_decision, + on_night_action_decision=_on_night_action_decision, ) host, port_str = settings.MASTER_WS_LISTEN.rsplit(":", 1) diff --git a/src/wolfbot/master/decision_dispatcher.py b/src/wolfbot/master/decision_dispatcher.py new file mode 100644 index 0000000..64763d0 --- /dev/null +++ b/src/wolfbot/master/decision_dispatcher.py @@ -0,0 +1,368 @@ +"""Master-side fan-out of vote / night-action decision requests to NPC bots. + +Phase-D: in `reactive_voice` mode each NPC bot is the embodied agent for +its seat and decides votes / night actions via its own `NPC_LLM_*`. This +dispatcher: + +1. Builds a `DecideVoteRequest` (or `DecideNightActionRequest`) per LLM + seat, sends it over WS, and parks an `asyncio.Future` per + `request_id`. +2. Resolves the matching future when `VoteDecision` / + `NightActionDecision` arrives on the WS handler chain. +3. Hits a deadline → resolves the future with ``target_seat=None`` + (= abstain / skip), logs the timeout, and the seat appears as a + no-decision row in the viewer (per the user's "log it so the viewer + shows the seat went silent" rule). + +The dispatcher is decoupled from `LLMAdapter` and `SpeakArbiter` so the +rounds-mode gameplay-LLM path stays untouched. `LLMAdapter.submit_llm_*` +methods branch on `game.discussion_mode` and delegate here for +reactive_voice. +""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass + +from wolfbot.domain.discussion import make_phase_id +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Player, Seat +from wolfbot.domain.ws_messages import ( + DecideNightActionRequest, + DecideVoteRequest, + NightActionDecision, + VoteDecision, +) +from wolfbot.master.npc_registry import NpcEntry, NpcRegistry + +log = logging.getLogger(__name__) + + +@dataclass +class DecisionDispatcherConfig: + """TTLs are deliberately conservative — the NPC bot pipeline is: + + register → snapshot in memory → LLM call (~2-4s with reasoning off) + → reply over WS. A typical decision round-trips in well under 10s + even at the bad tail; we set 12s so a slow provider still resolves + before the deadline. + """ + + request_ttl_ms: int = 12_000 + + +@dataclass +class _PendingDecision: + """Per-request bookkeeping for the response side of the WS round-trip.""" + + future: asyncio.Future[int | None] + seat_no: int + npc_id: str + request_id: str + + +class NpcDecisionDispatcher: + """Send DecideVoteRequest / DecideNightActionRequest to NPC bots and + collect their decisions. + + Stateless across games — the only mutable state is `_pending`, which + maps in-flight `request_id` → future. The same dispatcher instance + serves every game on the Master process. + """ + + def __init__( + self, + registry: NpcRegistry, + *, + config: DecisionDispatcherConfig | None = None, + now_ms: Callable[[], int] = lambda: 0, + ) -> None: + self.registry = registry + self.config = config or DecisionDispatcherConfig() + self._now_ms = now_ms + # request_id → pending future. Cleaned up on resolution / timeout. + self._pending: dict[str, _PendingDecision] = {} + + # ------------------------------------------------- public dispatch entry + + async def dispatch_votes( + self, + *, + game_id: str, + day: int, + round_: int, + voters: Sequence[Player], + seats: Sequence[Seat], + candidate_seats: Sequence[int], + public_state_summary: str = "", + ) -> dict[int, int | None]: + """Fan out vote requests to every alive LLM voter; collect results. + + Returns ``{voter_seat_no: target_seat_or_None}``. A voter whose + NPC is offline / never replies before the deadline maps to + ``None`` (abstain). + """ + seats_by_no = {s.seat_no: s for s in seats} + candidate_pairs = tuple( + (no, seats_by_no[no].display_name) + for no in candidate_seats + if no in seats_by_no + ) + phase = Phase.DAY_RUNOFF if round_ >= 1 else Phase.DAY_VOTE + phase_id = make_phase_id(game_id, day, phase) + deadline = self._now_ms() + self.config.request_ttl_ms + + async def _one(voter: Player) -> tuple[int, int | None]: + target = await self._dispatch_one_vote( + voter=voter, + seats_by_no=seats_by_no, + candidate_pairs=candidate_pairs, + game_id=game_id, + phase_id=phase_id, + round_=round_, + expires_at_ms=deadline, + public_state_summary=public_state_summary, + ) + return voter.seat_no, target + + results = await asyncio.gather( + *(_one(v) for v in voters), return_exceptions=False + ) + return dict(results) + + async def dispatch_night_actions( + self, + *, + game_id: str, + day: int, + action_kind: str, + actors: Sequence[Player], + seats: Sequence[Seat], + candidate_seats: Sequence[int], + public_state_summary: str = "", + ) -> dict[int, int | None]: + """Fan out night-action requests; collect targets per actor seat. + + ``action_kind`` is one of ``wolf_attack`` / ``seer_divine`` / + ``knight_guard``. Master is responsible for selecting the right + ``actors`` (e.g. only alive wolves for wolf_attack) and the + legal ``candidate_seats`` set. + """ + seats_by_no = {s.seat_no: s for s in seats} + candidate_pairs = tuple( + (no, seats_by_no[no].display_name) + for no in candidate_seats + if no in seats_by_no + ) + phase_id = make_phase_id( + game_id, day, Phase.NIGHT_0 if day == 0 else Phase.NIGHT + ) + deadline = self._now_ms() + self.config.request_ttl_ms + + async def _one(actor: Player) -> tuple[int, int | None]: + target = await self._dispatch_one_night( + actor=actor, + seats_by_no=seats_by_no, + candidate_pairs=candidate_pairs, + game_id=game_id, + phase_id=phase_id, + action_kind=action_kind, + expires_at_ms=deadline, + public_state_summary=public_state_summary, + ) + return actor.seat_no, target + + results = await asyncio.gather( + *(_one(a) for a in actors), return_exceptions=False + ) + return dict(results) + + # ------------------------------------------------- WS handler hooks + + async def on_vote_decision(self, msg: VoteDecision) -> None: + """Resolve the pending future for ``msg.request_id``. Stale or + unknown ids are dropped silently — they were already timed out.""" + pending = self._pending.pop(msg.request_id, None) + if pending is None: + log.info( + "vote_decision_unknown_request request=%s npc=%s", + msg.request_id, msg.npc_id, + ) + return + if not pending.future.done(): + pending.future.set_result(msg.target_seat) + log.info( + "vote_decision_received request=%s npc=%s seat=%d target=%s reason=%s", + msg.request_id, msg.npc_id, msg.seat_no, msg.target_seat, + msg.reason_summary or "(none)", + ) + + async def on_night_action_decision(self, msg: NightActionDecision) -> None: + pending = self._pending.pop(msg.request_id, None) + if pending is None: + log.info( + "night_action_decision_unknown_request request=%s npc=%s", + msg.request_id, msg.npc_id, + ) + return + if not pending.future.done(): + pending.future.set_result(msg.target_seat) + log.info( + "night_action_decision_received request=%s npc=%s seat=%d " + "kind=%s target=%s reason=%s", + msg.request_id, msg.npc_id, msg.seat_no, msg.action_kind, + msg.target_seat, msg.reason_summary or "(none)", + ) + + # ------------------------------------------------- internals + + async def _dispatch_one_vote( + self, + *, + voter: Player, + seats_by_no: dict[int, Seat], + candidate_pairs: tuple[tuple[int, str], ...], + game_id: str, + phase_id: str, + round_: int, + expires_at_ms: int, + public_state_summary: str, + ) -> int | None: + seat = seats_by_no.get(voter.seat_no) + if seat is None or not seat.is_llm: + return None + entry = self._find_npc_for_seat(game_id, voter.seat_no) + if entry is None or entry.send is None: + log.info( + "vote_dispatch_skip_no_npc game=%s seat=%d", game_id, voter.seat_no + ) + return None + request_id = f"rv_{uuid.uuid4().hex[:12]}" + req = DecideVoteRequest( + ts=self._now_ms(), + trace_id=f"vote-{game_id}-{voter.seat_no}-r{round_}", + request_id=request_id, + npc_id=entry.npc_id, + seat_no=voter.seat_no, + game_id=game_id, + phase_id=phase_id, + round_=round_, + candidate_seats=candidate_pairs, + public_state_summary=public_state_summary, + expires_at_ms=expires_at_ms, + ) + return await self._send_and_wait( + request_id=request_id, + seat_no=voter.seat_no, + npc_id=entry.npc_id, + send=entry.send, + payload_json=req.model_dump_json(), + label="vote", + ) + + async def _dispatch_one_night( + self, + *, + actor: Player, + seats_by_no: dict[int, Seat], + candidate_pairs: tuple[tuple[int, str], ...], + game_id: str, + phase_id: str, + action_kind: str, + expires_at_ms: int, + public_state_summary: str, + ) -> int | None: + seat = seats_by_no.get(actor.seat_no) + if seat is None or not seat.is_llm: + return None + entry = self._find_npc_for_seat(game_id, actor.seat_no) + if entry is None or entry.send is None: + log.info( + "night_dispatch_skip_no_npc game=%s seat=%d kind=%s", + game_id, actor.seat_no, action_kind, + ) + return None + # Tighten the kind to the wire-level Literal — the only callers come + # from rule-validated state machine code, so anything else is a bug. + if action_kind not in ("wolf_attack", "seer_divine", "knight_guard"): + log.warning( + "night_dispatch_unknown_kind game=%s seat=%d kind=%s", + game_id, actor.seat_no, action_kind, + ) + return None + request_id = f"rn_{uuid.uuid4().hex[:12]}" + req = DecideNightActionRequest( + ts=self._now_ms(), + trace_id=f"night-{game_id}-{actor.seat_no}-{action_kind}", + request_id=request_id, + npc_id=entry.npc_id, + seat_no=actor.seat_no, + game_id=game_id, + phase_id=phase_id, + action_kind=action_kind, # type: ignore[arg-type] + candidate_seats=candidate_pairs, + public_state_summary=public_state_summary, + expires_at_ms=expires_at_ms, + ) + return await self._send_and_wait( + request_id=request_id, + seat_no=actor.seat_no, + npc_id=entry.npc_id, + send=entry.send, + payload_json=req.model_dump_json(), + label=f"night-{action_kind}", + ) + + async def _send_and_wait( + self, + *, + request_id: str, + seat_no: int, + npc_id: str, + send: Callable[[str], Awaitable[None]], + payload_json: str, + label: str, + ) -> int | None: + loop = asyncio.get_running_loop() + future: asyncio.Future[int | None] = loop.create_future() + self._pending[request_id] = _PendingDecision( + future=future, seat_no=seat_no, npc_id=npc_id, request_id=request_id, + ) + try: + await send(payload_json) + except Exception: + log.exception( + "%s_dispatch_send_failed npc=%s seat=%d", label, npc_id, seat_no, + ) + self._pending.pop(request_id, None) + return None + timeout_s = self.config.request_ttl_ms / 1000.0 + try: + return await asyncio.wait_for(future, timeout=timeout_s) + except TimeoutError: + log.info( + "%s_dispatch_timeout npc=%s seat=%d request=%s", + label, npc_id, seat_no, request_id, + ) + return None + finally: + self._pending.pop(request_id, None) + + def _find_npc_for_seat(self, game_id: str, seat_no: int) -> NpcEntry | None: + for entry in self.registry.all_online(): + if entry.assigned_seat == seat_no and entry.game_id == game_id: + return entry + return None + + +__all__ = ["DecisionDispatcherConfig", "NpcDecisionDispatcher"] + + +# Avoid unused-import lint: Role is consumed by callers that pass `actors` +# pre-filtered by role; we keep the import handy for future role-aware +# dispatch policies without forcing the call site to import it again. +_ = Role diff --git a/src/wolfbot/master/ws_server.py b/src/wolfbot/master/ws_server.py index 0634bf6..aef1aa5 100644 --- a/src/wolfbot/master/ws_server.py +++ b/src/wolfbot/master/ws_server.py @@ -39,6 +39,7 @@ from wolfbot.domain.ws_messages import ( Heartbeat, + NightActionDecision, NpcRegister, NpcRegistered, PlaybackFailed, @@ -52,6 +53,7 @@ TtsFinished, VadSpeechEnded, VadSpeechStarted, + VoteDecision, ) from wolfbot.master.npc_registry import NpcRegistry @@ -179,6 +181,12 @@ class MasterHandlers: on_vad_started: Callable[[VadSpeechStarted, ConnectionContext], Awaitable[None]] | None = None on_vad_ended: Callable[[VadSpeechEnded, ConnectionContext], Awaitable[None]] | None = None on_stt_failed: Callable[[SttFailed, ConnectionContext], Awaitable[None]] | None = None + on_vote_decision: ( + Callable[[VoteDecision, ConnectionContext], Awaitable[None]] | None + ) = None + on_night_action_decision: ( + Callable[[NightActionDecision, ConnectionContext], Awaitable[None]] | None + ) = None now_ms: Callable[[], int] = field(default=_now_ms_default) def install(self, registry_: HandlerRegistry) -> None: @@ -193,6 +201,8 @@ def install(self, registry_: HandlerRegistry) -> None: registry_.add("vad_speech_started", self._handle_vad_started) registry_.add("vad_speech_ended", self._handle_vad_ended) registry_.add("stt_failed", self._handle_stt_failed) + registry_.add("vote_decision", self._handle_vote_decision) + registry_.add("night_action_decision", self._handle_night_action_decision) async def _handle_register(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: msg = NpcRegister.model_validate(payload) @@ -270,6 +280,20 @@ async def _handle_stt_failed(self, payload: dict[str, Any], ctx: ConnectionConte if self.on_stt_failed is not None: await self.on_stt_failed(msg, ctx) + async def _handle_vote_decision( + self, payload: dict[str, Any], ctx: ConnectionContext + ) -> None: + msg = VoteDecision.model_validate(payload) + if self.on_vote_decision is not None: + await self.on_vote_decision(msg, ctx) + + async def _handle_night_action_decision( + self, payload: dict[str, Any], ctx: ConnectionContext + ) -> None: + msg = NightActionDecision.model_validate(payload) + if self.on_night_action_decision is not None: + await self.on_night_action_decision(msg, ctx) + # ---------------------------------------------------------------- real WS server diff --git a/src/wolfbot/npc/client.py b/src/wolfbot/npc/client.py index 3b0051c..dd03d48 100644 --- a/src/wolfbot/npc/client.py +++ b/src/wolfbot/npc/client.py @@ -44,6 +44,14 @@ TtsFinished, VoteDecision, ) +from wolfbot.npc.decision_service import ( + _NIGHT_SCHEMA, + _VOTE_SCHEMA, + DecisionLLM, + build_night_prompt, + build_vote_prompt, + parse_decision, +) from wolfbot.npc.game_state import NpcGameState, apply_update, state_from_snapshot from wolfbot.npc.playback import ( VoicePlayback, @@ -121,6 +129,10 @@ class NpcClient: # then PrivateStateUpdate (append/patch) for incremental events. The # NPC bot uses this in vote / night / speech decision handlers. game_states: dict[str, NpcGameState] = field(default_factory=dict) + # Phase-D: per-seat decision LLM. When None, vote / night handlers + # fall through to the historical stub (target=None) — useful for + # tests that don't want to bind a real LLM client. + decision_llm: DecisionLLM | None = None # ---------------------------------------------------------- registration @@ -450,37 +462,72 @@ def _on_private_state_update(self, update: PrivateStateUpdate) -> None: ) async def _on_decide_vote_request(self, req: DecideVoteRequest) -> None: - """Phase-D: NPC decides its own vote target. + """Phase-D: NPC decides its own vote target via NPC_LLM. - This first cut is a placeholder — it returns ``target_seat=None`` - (abstain) so the wire is fully exercised end-to-end before the - LLM-backed decision logic lands. Master records the abstention and - the viewer surfaces the seat as silent for that vote. + Falls back to ``target_seat=None`` (abstain) when state is + missing, no decision LLM is configured, or the LLM call / + response parse fails. Every fallback is logged so the viewer + surfaces the seat as silent for that round (per the user's + "log it so the viewer shows the seat went silent" rule). """ if req.npc_id != self.config.npc_id: return + target, reason = await self._decide_vote_target(req) decision = VoteDecision( ts=self.now_ms(), trace_id=req.trace_id, request_id=req.request_id, npc_id=self.config.npc_id, seat_no=req.seat_no, - target_seat=None, - reason_summary="phase_d_stub_abstain", + target_seat=target, + reason_summary=reason, ) await self.send(decision.model_dump_json()) + async def _decide_vote_target( + self, req: DecideVoteRequest + ) -> tuple[int | None, str]: + from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY + + state = self.game_states.get(req.game_id) + if state is None: + log.warning( + "npc_vote_no_state game=%s seat=%d", req.game_id, req.seat_no, + ) + return None, "no_state" + if self.decision_llm is None: + return None, "no_decision_llm" + persona = NPC_PERSONAS_BY_KEY.get(state.persona_key) + if persona is None: + log.warning( + "npc_vote_unknown_persona persona=%s", state.persona_key, + ) + return None, "unknown_persona" + legal = frozenset(seat for seat, _name in req.candidate_seats) + system, user = build_vote_prompt(state=state, persona=persona, request=req) + try: + raw = await self.decision_llm.decide_json( + system_prompt=system, user_prompt=user, schema=_VOTE_SCHEMA, + ) + except Exception: + log.exception( + "npc_vote_llm_failed game=%s seat=%d", req.game_id, req.seat_no, + ) + return None, "llm_error" + result = parse_decision(raw, legal_seats=legal) + return result.target_seat, result.reason_summary + async def _on_decide_night_action_request( self, req: DecideNightActionRequest ) -> None: - """Phase-D: NPC decides its own night action. + """Phase-D: NPC decides its own night action via NPC_LLM. - Stub identical to the vote handler — returns ``target_seat=None`` - (skip) so Master's resolution path can be wired up first. The - LLM-backed decision lands in a follow-up commit. + Same fallback shape as the vote handler — None target on missing + state, no decision LLM, LLM error, or out-of-set response. """ if req.npc_id != self.config.npc_id: return + target, reason = await self._decide_night_target(req) decision = NightActionDecision( ts=self.now_ms(), trace_id=req.trace_id, @@ -488,11 +535,42 @@ async def _on_decide_night_action_request( npc_id=self.config.npc_id, seat_no=req.seat_no, action_kind=req.action_kind, - target_seat=None, - reason_summary="phase_d_stub_skip", + target_seat=target, + reason_summary=reason, ) await self.send(decision.model_dump_json()) + async def _decide_night_target( + self, req: DecideNightActionRequest + ) -> tuple[int | None, str]: + from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY + + state = self.game_states.get(req.game_id) + if state is None: + log.warning( + "npc_night_no_state game=%s seat=%d", req.game_id, req.seat_no, + ) + return None, "no_state" + if self.decision_llm is None: + return None, "no_decision_llm" + persona = NPC_PERSONAS_BY_KEY.get(state.persona_key) + if persona is None: + return None, "unknown_persona" + legal = frozenset(seat for seat, _name in req.candidate_seats) + system, user = build_night_prompt(state=state, persona=persona, request=req) + try: + raw = await self.decision_llm.decide_json( + system_prompt=system, user_prompt=user, schema=_NIGHT_SCHEMA, + ) + except Exception: + log.exception( + "npc_night_llm_failed game=%s seat=%d kind=%s", + req.game_id, req.seat_no, req.action_kind, + ) + return None, "llm_error" + result = parse_decision(raw, legal_seats=legal) + return result.target_seat, result.reason_summary + __all__ = ["NpcClient", "NpcClientConfig"] diff --git a/src/wolfbot/npc/decision_service.py b/src/wolfbot/npc/decision_service.py new file mode 100644 index 0000000..ff4c8ea --- /dev/null +++ b/src/wolfbot/npc/decision_service.py @@ -0,0 +1,311 @@ +"""NPC bot per-seat decision LLM — votes / night actions / runoff votes. + +Phase-D: each NPC bot owns the strategic decisions for its seat. This +service is the LLM-backed decision layer; it consumes the bot's +`NpcGameState` mirror (role + role-specific results + wolf chat) plus +the request-time public-state digest, calls `NPC_LLM_*` with a strict +JSON schema, and returns the chosen target seat. + +Returns ``target_seat=None`` on: +* missing or stale game state (snapshot never received), +* model error / timeout (caller already enforces a wall-clock deadline), +* schema validation failure (model returned a non-candidate or a + malformed JSON payload). + +A None result is logged and propagated up to the WS reply — Master +records it as an abstain/skip per the user's "log it so the viewer +shows the seat went silent" rule. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +from wolfbot.domain.enums import Role +from wolfbot.domain.ws_messages import ( + DecideNightActionRequest, + DecideVoteRequest, +) +from wolfbot.llm.persona_base import Persona +from wolfbot.llm.prompt_builder import ( + build_judgment_profile_block, + build_speech_profile_block, + build_strategy_block, +) +from wolfbot.npc.game_state import NpcGameState + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class DecisionResult: + """Decision LLM output. ``target_seat=None`` means abstain / skip.""" + + target_seat: int | None + reason_summary: str = "" + + +@runtime_checkable +class DecisionLLM(Protocol): + """Provider-agnostic decision call. Returns raw JSON text. Implementations + set the JSON schema appropriately (xAI strict json_schema, DeepSeek + json_object, Gemini response_json_schema).""" + + async def decide_json( + self, + *, + system_prompt: str, + user_prompt: str, + schema: dict[str, object], + ) -> str: ... + + +# ---------------------------------------------------------------- prompt blocks + + +_VOTE_SCHEMA: dict[str, object] = { + "type": "object", + "additionalProperties": False, + "required": ["target_seat", "reason"], + "properties": { + "target_seat": { + "type": ["integer", "null"], + "description": "Seat number of the vote target, or null to abstain.", + }, + "reason": { + "type": "string", + "description": "Short internal note explaining the choice.", + }, + }, +} + + +_NIGHT_SCHEMA: dict[str, object] = { + "type": "object", + "additionalProperties": False, + "required": ["target_seat", "reason"], + "properties": { + "target_seat": { + "type": ["integer", "null"], + "description": "Seat number of the night-action target, or null to skip.", + }, + "reason": { + "type": "string", + "description": "Short internal note explaining the choice.", + }, + }, +} + + +def _build_state_block(state: NpcGameState) -> str: + """Render the bot's private state mirror as a Japanese prompt block.""" + lines = [f"あなたの席: 席{state.seat_no}", f"あなたの役職: {state.role}"] + if state.alive_seats: + alive = "、".join(f"席{s} {n}" for s, n in state.alive_seats) + lines.append(f"生存者: {alive}") + if state.dead_seats: + dead = "、".join(f"席{s} {n}" for s, n in state.dead_seats) + lines.append(f"死亡者: {dead}") + if state.partner_wolves: + partners = "、".join(f"席{s} {n}" for s, n in state.partner_wolves) + lines.append(f"仲間の人狼 (非公開): {partners}") + if state.seer_results: + lines.append("## 自分の占い結果 (非公開)") + for sr in state.seer_results: + verdict = "黒 (人狼)" if sr.is_wolf else "白 (人狼ではない)" + lines.append(f" day{sr.day}: 席{sr.target_seat} {sr.target_name} → {verdict}") + if state.medium_results: + lines.append("## 自分の霊媒結果 (非公開)") + for mr in state.medium_results: + if mr.is_wolf is None: + verdict = "結果なし (処刑なし)" + elif mr.is_wolf: + verdict = "人狼" + else: + verdict = "人狼ではない" + lines.append(f" day{mr.day}: 席{mr.target_seat} {mr.target_name} → {verdict}") + if state.guard_history: + lines.append("## 自分の護衛履歴 (非公開)") + for g in state.guard_history: + outcome = ( + "(平和な朝)" if g.peaceful_morning + else "(襲撃発生)" if g.peaceful_morning is False + else "(結果未確定)" + ) + lines.append( + f" day{g.day}: 席{g.target_seat} {g.target_name} を護衛 {outcome}" + ) + if state.wolf_chat_history: + lines.append("## 人狼チャット履歴 (狼/狂人にのみ見える)") + for line in state.wolf_chat_history[-20:]: + lines.append( + f" day{line.day} 席{line.speaker_seat} {line.speaker_name}: {line.text}" + ) + return "\n".join(lines) + + +def _build_persona_block(persona: Persona) -> str: + return ( + f"## キャラクター\n" + f"名前: {persona.display_name}\n" + f"性格指針: {persona.style_guide}\n\n" + f"## 話法\n{build_speech_profile_block(persona)}\n\n" + f"## 判断のクセ\n{build_judgment_profile_block(persona)}" + ) + + +def _build_role_block(role_str: str) -> str: + """Best-effort role-strategy block. Falls back to empty when the role + string isn't a known `Role` enum value (defensive — the snapshot + always carries a canonical name, but a future server version could + add roles).""" + try: + role = Role(role_str) + except ValueError: + return "" + return f"## 役職別の戦術ヒント\n{build_strategy_block(role)}" + + +def _format_candidates(candidates: Iterable[tuple[int, str]]) -> str: + return "、".join(f"席{seat_no} {name}" for seat_no, name in candidates) or "(なし)" + + +# ---------------------------------------------------------------- decision API + + +_VOTE_ACT_TEXT_BY_ROUND: dict[int, str] = { + 0: "通常投票", + 1: "決選投票", +} + + +def build_vote_prompt( + *, + state: NpcGameState, + persona: Persona, + request: DecideVoteRequest, +) -> tuple[str, str]: + """Compose the system + user prompt for a vote decision.""" + candidates_str = _format_candidates(request.candidate_seats) + digest = request.public_state_summary or "(情報なし)" + round_label = _VOTE_ACT_TEXT_BY_ROUND.get(request.round_, f"round={request.round_}") + state_block = _build_state_block(state) + persona_block = _build_persona_block(persona) + role_block = _build_role_block(state.role) + system = ( + "あなたは人狼ゲームの 1 プレイヤーです。" + "公開情報・自分が持つ非公開情報・性格・役職戦術に基づいて投票先を決めてください。" + "返答は JSON のみ。" + ) + user_parts = [ + f"## フェイズ: {round_label} (day {state.day_number})", + "", + persona_block, + "", + role_block, + "", + "## 自分の状況 (非公開を含む)", + state_block, + "", + "## 場の状況 (Master ダイジェスト)", + digest, + "", + f"## 投票候補席\n{candidates_str}", + "", + "上記すべてを踏まえ、この投票で誰に票を入れるかを決めてください。" + "投票しない場合 (棄権) は target_seat を null。" + "JSON は {\"target_seat\": <席番号 | null>, \"reason\": \"<短い理由>\"} の形。", + ] + return system, "\n".join(p for p in user_parts if p is not None) + + +_NIGHT_ACT_TEXT: dict[str, str] = { + "wolf_attack": "人狼の襲撃", + "seer_divine": "占い師の占い", + "knight_guard": "騎士の護衛", +} + + +def build_night_prompt( + *, + state: NpcGameState, + persona: Persona, + request: DecideNightActionRequest, +) -> tuple[str, str]: + """Compose the system + user prompt for a night-action decision.""" + candidates_str = _format_candidates(request.candidate_seats) + digest = request.public_state_summary or "(情報なし)" + state_block = _build_state_block(state) + persona_block = _build_persona_block(persona) + role_block = _build_role_block(state.role) + action_label = _NIGHT_ACT_TEXT.get(request.action_kind, request.action_kind) + system = ( + "あなたは人狼ゲームの 1 プレイヤーです。" + "夜行動の対象を性格・役職戦術・公開/非公開情報を踏まえて決めてください。" + "返答は JSON のみ。" + ) + user_parts = [ + f"## フェイズ: {action_label} (day {state.day_number})", + "", + persona_block, + "", + role_block, + "", + "## 自分の状況 (非公開を含む)", + state_block, + "", + "## 場の状況 (Master ダイジェスト)", + digest, + "", + f"## 行動候補席\n{candidates_str}", + "", + "上記すべてを踏まえ、夜の行動対象を決めてください。" + "対象を選ばない/選べない場合は target_seat を null。" + "JSON は {\"target_seat\": <席番号 | null>, \"reason\": \"<短い理由>\"} の形。", + ] + return system, "\n".join(p for p in user_parts if p is not None) + + +def parse_decision( + raw_json: str, + *, + legal_seats: frozenset[int], +) -> DecisionResult: + """Parse + validate a decision LLM response. + + Returns ``DecisionResult(target_seat=None)`` on any failure: malformed + JSON, missing fields, target outside ``legal_seats``, etc. The + abstain default keeps the bot game-legal even when the model + misbehaves. + """ + try: + data = json.loads(raw_json) + except json.JSONDecodeError: + log.warning("decision_parse_json_failed raw=%s", raw_json[:200]) + return DecisionResult(target_seat=None, reason_summary="parse_failed") + if not isinstance(data, dict): + return DecisionResult(target_seat=None, reason_summary="not_object") + raw_target = data.get("target_seat") + if raw_target is None: + return DecisionResult(target_seat=None, reason_summary=str(data.get("reason", ""))) + if not isinstance(raw_target, int) or isinstance(raw_target, bool): + return DecisionResult(target_seat=None, reason_summary="non_int_target") + if raw_target not in legal_seats: + log.info("decision_target_out_of_set target=%s legal=%s", raw_target, legal_seats) + return DecisionResult(target_seat=None, reason_summary="illegal_target") + return DecisionResult(target_seat=raw_target, reason_summary=str(data.get("reason", ""))) + + +__all__ = [ + "_NIGHT_SCHEMA", + "_VOTE_SCHEMA", + "DecisionLLM", + "DecisionResult", + "build_night_prompt", + "build_vote_prompt", + "parse_decision", +] diff --git a/src/wolfbot/npc/main.py b/src/wolfbot/npc/main.py index 83752f1..6da12d9 100644 --- a/src/wolfbot/npc/main.py +++ b/src/wolfbot/npc/main.py @@ -208,6 +208,7 @@ async def _ensure_vc_left() -> None: # ---- Build NPC pipeline ---- from wolfbot.npc.client import NpcClient, NpcClientConfig + from wolfbot.npc.decision_service import DecisionLLM from wolfbot.npc.generator_factory import make_npc_generator from wolfbot.npc.playback import DiscordVoicePlayback, VoicePlaybackError from wolfbot.npc.speech_service import NpcSpeechService @@ -217,6 +218,14 @@ async def _ensure_vc_left() -> None: settings.npc_decider_config(), persona_key=settings.NPC_PERSONA_KEY, ) + # Phase-D: same provider/credentials drive vote / night-action + # decisions as the speech generator. The OpenAI-compatible generator + # already has a `decide_json` method; for Mock / Gemini we leave the + # decision LLM unset and the NPC client falls back to the abstain + # default until those backends grow a `decide_json` of their own. + decision_llm: DecisionLLM | None = ( + generator if isinstance(generator, DecisionLLM) else None + ) speech_service = NpcSpeechService(generator=generator) tts = VoicevoxTtsService( base_url=settings.VOICEVOX_URL, @@ -298,6 +307,7 @@ async def _ws_send(msg: str) -> None: on_vc_leave=_ensure_vc_left, on_set_mute=_set_self_mute, on_post_chat=_post_to_vc_chat, + decision_llm=decision_llm, ) # Register with Master diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index dbb0217..9a745f7 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -392,6 +392,103 @@ async def generate( return _build_speech_from_json(data) + async def decide_json( + self, + *, + system_prompt: str, + user_prompt: str, + schema: dict[str, object], + ) -> str: + """Phase-D: structured-output decision call (vote / night action). + + Reuses this generator's provider config + auth so each persona's + `NPC_LLM_*` doubles as both speech and decision backend without + plumbing a separate client. The caller is `npc/decision_service.py` + — it builds the prompt and validates the parsed result. + + Returns raw response text (a JSON string). On any provider error + the exception propagates up so the dispatcher can record a + timeout / abstain. + """ + from openai import AsyncOpenAI + + from wolfbot.services.llm_trace import ( + CallTimer, + extract_openai_tokens, + log_llm_call, + ) + + client = AsyncOpenAI( + api_key=self._api_key, + base_url=self.config.base_url, + ) + kwargs: dict[str, object] = { + "model": self.config.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": self.config.temperature, + "timeout": self.config.timeout, + } + if self.config.mode == "json_schema": + kwargs["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": "decision", + "strict": True, + "schema": schema, + }, + } + else: + kwargs["response_format"] = {"type": "json_object"} + kwargs["extra_body"] = {"thinking": {"type": self.config.thinking}} + if self.config.thinking == "enabled": + kwargs["reasoning_effort"] = self.config.reasoning_effort + + provider_tag = "deepseek" if self.config.mode == "json_object" else "openai-compat" + timer = CallTimer() + content = "" + err: str | None = None + tokens: dict[str, int | None] | None = None + try: + resp = await client.chat.completions.create(**kwargs) # type: ignore[call-overload] + content = resp.choices[0].message.content or "{}" + tokens = extract_openai_tokens(resp) + except Exception as exc: + err = f"{type(exc).__name__}: {exc}" + log.exception( + "npc_decide_failed model=%s base_url=%s", + self.config.model, self.config.base_url, + ) + await log_llm_call( + role="npc_decision", + provider=provider_tag, + model=self.config.model, + system_prompt=system_prompt, + user_prompt=user_prompt, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + file_stem=f"npc_{self._persona_key}", + ) + raise + + await log_llm_call( + role="npc_decision", + provider=provider_tag, + model=self.config.model, + system_prompt=system_prompt, + user_prompt=user_prompt, + response=content, + latency_ms=timer.elapsed_ms, + error=None, + tokens=tokens, + file_stem=f"npc_{self._persona_key}", + ) + return content + + def _build_speech_from_json(data: dict[str, object]) -> NpcGeneratedSpeech | None: """Map a parsed structured-output dict to ``NpcGeneratedSpeech``. diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index e282991..5d54d43 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -548,6 +548,7 @@ def __init__( rng: random.Random | None = None, clock: Callable[[], int] | None = None, discussion_service: DiscussionService | None = None, + npc_decision_dispatcher: object | None = None, ) -> None: import time as _time @@ -558,6 +559,11 @@ def __init__( self.rng = rng or random.Random() self._clock: Callable[[], int] = clock or (lambda: int(_time.time())) self._background_tasks: set[asyncio.Task[None]] = set() + # Phase-D: when set, reactive_voice games dispatch votes / night + # actions to NPC bots via WS instead of using `self.decider`. The + # type is widened to `object` to keep this module from importing + # the master-side dispatcher (which depends on websockets). + self._npc_decision_dispatcher = npc_decision_dispatcher # Optional SpeechEvent emission. When None, behavior matches pre- # speech-event-bus rounds mode: only PLAYER_SPEECH log + Discord post # are produced. When set, every accepted utterance is also recorded @@ -798,6 +804,11 @@ async def submit_llm_votes( `restrict_to_seats` lets `resend_pending_dms` re-dispatch for only the LLM seats that still owe a vote after `/wolf extend`. + + Phase-D: when ``game.discussion_mode == "reactive_voice"`` and a + decision dispatcher is configured, votes are routed to each + seat's NPC bot over WS instead of going through the gameplay + decider. Rounds mode (the historical path) is unchanged. """ seats_by_no = {s.seat_no: s for s in seats} llm_voters = [ @@ -809,13 +820,133 @@ async def submit_llm_votes( llm_voters = [p for p in llm_voters if p.seat_no in restrict_to_seats] if not llm_voters: return - task = asyncio.create_task( - self._run_votes(game, llm_voters, players, seats, candidates, round_), - name=f"llm-votes-{game.id}-d{game.day_number}-r{round_}", - ) + if ( + game.discussion_mode == "reactive_voice" + and self._npc_decision_dispatcher is not None + ): + task = asyncio.create_task( + self._run_votes_via_npc_dispatcher( + game, llm_voters, players, seats, candidates, round_ + ), + name=f"npc-votes-{game.id}-d{game.day_number}-r{round_}", + ) + else: + task = asyncio.create_task( + self._run_votes(game, llm_voters, players, seats, candidates, round_), + name=f"llm-votes-{game.id}-d{game.day_number}-r{round_}", + ) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) + async def _run_votes_via_npc_dispatcher( + self, + game: Game, + llm_voters: Sequence[Player], + all_players: Sequence[Player], + seats: Sequence[Seat], + candidates: Sequence[int] | None, + round_: int, + ) -> None: + """Reactive_voice vote path: fan out DecideVoteRequest, persist + targets, default to abstain on offline / timeout. + """ + seats_by_no = {s.seat_no: s for s in seats} + expected_phase = Phase.DAY_VOTE if round_ == 0 else Phase.DAY_RUNOFF + # Re-load game and stop early if the phase already advanced. The + # dispatcher's per-vote round-trip is bounded by request_ttl_ms but + # a force-skip / abort can still beat it. + fresh = await self.repo.load_game(game.id) + if ( + fresh is None + or fresh.ended_at is not None + or fresh.phase is not expected_phase + or fresh.day_number != game.day_number + ): + return + # Build candidate set per voter — same shape as the decider path. + # We fan out only voters who have at least one legal target; + # voters with no legal target (e.g. last-survivor edge) abstain. + existing_votes = await self.repo.load_votes( + game.id, day=game.day_number, round_=round_ + ) + voted = {v.voter_seat for v in existing_votes} + per_voter_candidates: dict[int, list[int]] = {} + voters_to_dispatch: list[Player] = [] + for v in llm_voters: + if v.seat_no in voted: + continue + if candidates is None: + cands = [ + s.seat_no + for s in seats + if s.seat_no != v.seat_no + and any( + p.seat_no == s.seat_no and p.alive for p in all_players + ) + ] + else: + cands = [ + s.seat_no + for s in seats + if s.seat_no in set(candidates) and s.seat_no != v.seat_no + ] + per_voter_candidates[v.seat_no] = cands + voters_to_dispatch.append(v) + if not voters_to_dispatch: + return + # Use the union of all per-voter candidate seats for the dispatch + # call — the NPC bot will pick from the seats given. (Each voter's + # legal set is identical in practice for the regular vote.) + union_candidates: set[int] = set() + for cs in per_voter_candidates.values(): + union_candidates.update(cs) + dispatcher = self._npc_decision_dispatcher + try: + results = await dispatcher.dispatch_votes( # type: ignore[union-attr] + game_id=game.id, + day=game.day_number, + round_=round_, + voters=voters_to_dispatch, + seats=seats, + candidate_seats=sorted(union_candidates), + ) + except Exception: + log.exception( + "npc_vote_dispatch_failed game=%s day=%d round=%d", + game.id, game.day_number, round_, + ) + return + # Persist each result. ``None`` (offline / timeout / explicit + # abstain) becomes ``target_seat=None`` on the Vote row so the + # viewer surfaces the seat as silent. + for voter in voters_to_dispatch: + target = results.get(voter.seat_no) + # Validate target is in the voter's legal set, otherwise abstain. + if target is not None and target not in per_voter_candidates.get( + voter.seat_no, [] + ): + log.info( + "npc_vote_decision_illegal_target game=%s seat=%d target=%s", + game.id, voter.seat_no, target, + ) + target = None + try: + await self.gs.submit_vote( + game.id, + voter.seat_no, + target_seat=target, + round_=round_, + day=game.day_number, + ) + except Exception: + log.exception( + "npc_vote_submit_failed game=%s seat=%d round=%d", + game.id, voter.seat_no, round_, + ) + # Touch seats_by_no to keep the parameter live for log readers + # auditing the dispatch (lint guard). + _ = seats_by_no + async def _run_votes( self, game: Game, diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index 1b5909d..27ca8ff 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -15,7 +15,7 @@ import pytest from wolfbot.domain.enums import Phase, Role, SubmissionType -from wolfbot.domain.models import Game, LogEntry, NightAction, Seat, Vote +from wolfbot.domain.models import Game, LogEntry, NightAction, Player, Seat, Vote from wolfbot.llm.prompt_builder import task_daytime_speech, task_night_action, task_vote from wolfbot.persistence.sqlite_repo import SqliteRepo from wolfbot.services.llm_service import LLMAction, LLMAdapter @@ -124,6 +124,100 @@ async def test_submit_llm_votes_returns_before_decider_completes(repo: SqliteRep assert len(gs.votes) == 2 +async def test_submit_llm_votes_reactive_voice_routes_to_dispatcher( + repo: SqliteRepo, +) -> None: + """Phase-D: reactive_voice games must skip the gameplay decider and ask + each NPC bot for its own vote. The decider must not be invoked at all + when the dispatcher is wired.""" + game_rounds, seats = await _seed_vote_game(repo) + # Re-create the game row with discussion_mode=reactive_voice so the + # branch in submit_llm_votes triggers. + async with repo._db.execute( # type: ignore[attr-defined] + "UPDATE games SET discussion_mode=? WHERE id=?", + ("reactive_voice", game_rounds.id), + ): + pass + await repo._db.commit() # type: ignore[attr-defined] + game = (await repo.load_game(game_rounds.id)) + assert game is not None + assert game.discussion_mode == "reactive_voice" + + captured_dispatch: list[dict[int, int | None]] = [] + + class _StubDispatcher: + async def dispatch_votes( + self, *, game_id: str, day: int, round_: int, + voters: list[Player], # type: ignore[name-defined] + seats: list[Seat], + candidate_seats: list[int], + public_state_summary: str = "", + ) -> dict[int, int | None]: + # Pretend NPC bots all chose seat 1 (the human). + result = {v.seat_no: 1 for v in voters} + captured_dispatch.append(result) + return result + + gs = _FakeGameService() + + class _UnusedDecider: + async def decide(self, *args: object, **kwargs: object) -> LLMAction: + raise AssertionError("gameplay decider must not be invoked in reactive_voice") + + adapter = LLMAdapter( + repo=repo, + decider=_UnusedDecider(), # type: ignore[arg-type] + rng=random.Random(0), + npc_decision_dispatcher=_StubDispatcher(), + ) + adapter.set_game_service(gs) # type: ignore[arg-type] + + players = await repo.load_players(game.id) + await adapter.submit_llm_votes(game, players, seats, candidates=None, round_=0) + await asyncio.gather(*list(adapter._background_tasks), return_exceptions=True) + + # Both LLM seats voted seat 1 via the dispatcher. + assert len(captured_dispatch) == 1 + voted_seats = {v[1] for v in gs.votes} + assert voted_seats == {2, 3} + targets = {v[1]: v[2] for v in gs.votes} + assert targets[2] == 1 and targets[3] == 1 + + +async def test_submit_llm_votes_reactive_voice_falls_back_when_no_dispatcher( + repo: SqliteRepo, +) -> None: + """Without a dispatcher the reactive_voice branch can't fire, so the + historical gameplay-LLM decider is still used (back-compat for tests + that don't wire the dispatcher).""" + game_rounds, seats = await _seed_vote_game(repo) + async with repo._db.execute( # type: ignore[attr-defined] + "UPDATE games SET discussion_mode=? WHERE id=?", + ("reactive_voice", game_rounds.id), + ): + pass + await repo._db.commit() # type: ignore[attr-defined] + game = (await repo.load_game(game_rounds.id)) + assert game is not None + + gs = _FakeGameService() + decider = _ScriptedDecider( + [ + LLMAction(intent="vote", target_name="H1", reason_summary="", confidence=0.5), + LLMAction(intent="vote", target_name="H1", reason_summary="", confidence=0.5), + ] + ) + adapter = LLMAdapter(repo=repo, decider=decider, rng=random.Random(0)) + # No npc_decision_dispatcher configured. + adapter.set_game_service(gs) # type: ignore[arg-type] + + players = await repo.load_players(game.id) + await adapter.submit_llm_votes(game, players, seats, candidates=None, round_=0) + await asyncio.gather(*list(adapter._background_tasks), return_exceptions=True) + # Fell back to the gameplay decider. + assert decider.call_count == 2 + + async def test_run_votes_aborts_when_phase_stale_at_dispatch(repo: SqliteRepo) -> None: """Per-seat stale check: if the phase is no longer DAY_VOTE when a sub-task reads the game, that sub-task returns without calling the decider. Parallel diff --git a/tests/test_master_decision_dispatcher.py b/tests/test_master_decision_dispatcher.py new file mode 100644 index 0000000..ad618d3 --- /dev/null +++ b/tests/test_master_decision_dispatcher.py @@ -0,0 +1,298 @@ +"""Master-side `NpcDecisionDispatcher` — fan-out + future resolution. + +End-to-end through `dispatch_votes` / `dispatch_night_actions`: + +* Online NPC's reply via `on_vote_decision` resolves the future. +* Offline NPC (no registry entry) → None, no WS send. +* Send failure → None, future is reaped. +* No reply within `request_ttl_ms` → None (timeout). + +The tests use an `InMemoryNpcRegistry` and a captured-`send` list so we +exercise the dispatch path without standing up a real WS. +""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Awaitable, Callable + +from wolfbot.domain.enums import Role +from wolfbot.domain.models import Player, Seat +from wolfbot.domain.ws_messages import ( + DecideVoteRequest, + NightActionDecision, + VoteDecision, +) +from wolfbot.master.decision_dispatcher import ( + DecisionDispatcherConfig, + NpcDecisionDispatcher, +) +from wolfbot.master.npc_registry import InMemoryNpcRegistry + + +def _capture_send(buf: list[str]) -> Callable[[str], Awaitable[None]]: + async def _send(msg: str) -> None: + buf.append(msg) + + return _send + + +def _seats() -> list[Seat]: + return [ + Seat(seat_no=1, display_name="Alice", is_llm=False, persona_key=None, + discord_user_id="u1"), + Seat(seat_no=2, display_name="Bob", is_llm=True, persona_key="setsu", + discord_user_id=None), + Seat(seat_no=3, display_name="Carol", is_llm=True, persona_key="gina", + discord_user_id=None), + ] + + +def _voters() -> list[Player]: + return [ + Player(seat_no=2, role=Role.VILLAGER, alive=True), + Player(seat_no=3, role=Role.WEREWOLF, alive=True), + ] + + +async def test_dispatch_votes_resolves_when_npcs_reply() -> None: + registry = InMemoryNpcRegistry() + seat2_buf: list[str] = [] + seat3_buf: list[str] = [] + registry.register( + npc_id="npc_seat2", discord_bot_user_id="bot2", + supported_voices=(), version="1", + send=_capture_send(seat2_buf), now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_seat2", seat=2, game_id="g1", phase_id="g1::day1::DAY_VOTE::1") + registry.register( + npc_id="npc_seat3", discord_bot_user_id="bot3", + supported_voices=(), version="1", + send=_capture_send(seat3_buf), now_ms=1000, persona_key="gina", + ) + registry.assign("npc_seat3", seat=3, game_id="g1", phase_id="g1::day1::DAY_VOTE::1") + + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig(request_ttl_ms=5_000), + now_ms=lambda: 2_000, + ) + + async def _drive() -> dict[int, int | None]: + return await dispatcher.dispatch_votes( + game_id="g1", day=1, round_=0, + voters=_voters(), seats=_seats(), + candidate_seats=[1, 2, 3], + ) + + task = asyncio.create_task(_drive()) + # Wait until the dispatcher has sent both requests. + for _ in range(50): + if len(seat2_buf) and len(seat3_buf): + break + await asyncio.sleep(0.01) + assert len(seat2_buf) == 1 and len(seat3_buf) == 1 + + # Reply for both seats. + for buf, target_seat, npc_id, seat_no in ( + (seat2_buf, 1, "npc_seat2", 2), + (seat3_buf, 1, "npc_seat3", 3), + ): + sent = json.loads(buf[0]) + await dispatcher.on_vote_decision( + VoteDecision( + ts=3_000, trace_id=sent["trace_id"], request_id=sent["request_id"], + npc_id=npc_id, seat_no=seat_no, target_seat=target_seat, + reason_summary="test", + ) + ) + + results = await task + assert results == {2: 1, 3: 1} + + +async def test_dispatch_votes_offline_seat_resolves_to_none() -> None: + registry = InMemoryNpcRegistry() + # Only seat 2 is online; seat 3 has no NPC. + seat2_buf: list[str] = [] + registry.register( + npc_id="npc_seat2", discord_bot_user_id="bot2", + supported_voices=(), version="1", + send=_capture_send(seat2_buf), now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_seat2", seat=2, game_id="g1", phase_id="g1::day1::DAY_VOTE::1") + + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig(request_ttl_ms=5_000), + now_ms=lambda: 2_000, + ) + + async def _drive() -> dict[int, int | None]: + return await dispatcher.dispatch_votes( + game_id="g1", day=1, round_=0, + voters=_voters(), seats=_seats(), + candidate_seats=[1, 2, 3], + ) + + task = asyncio.create_task(_drive()) + for _ in range(50): + if seat2_buf: + break + await asyncio.sleep(0.01) + assert len(seat2_buf) == 1 + + sent = json.loads(seat2_buf[0]) + await dispatcher.on_vote_decision( + VoteDecision( + ts=3_000, trace_id=sent["trace_id"], request_id=sent["request_id"], + npc_id="npc_seat2", seat_no=2, target_seat=3, + ) + ) + results = await task + assert results[2] == 3 + assert results[3] is None + + +async def test_dispatch_votes_timeout_yields_none() -> None: + registry = InMemoryNpcRegistry() + seat2_buf: list[str] = [] + registry.register( + npc_id="npc_seat2", discord_bot_user_id="bot2", + supported_voices=(), version="1", + send=_capture_send(seat2_buf), now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_seat2", seat=2, game_id="g1", phase_id="g1::day1::DAY_VOTE::1") + + # Very short TTL so the test runs fast. + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig(request_ttl_ms=100), + now_ms=lambda: 0, + ) + voter_only = [Player(seat_no=2, role=Role.VILLAGER, alive=True)] + results = await dispatcher.dispatch_votes( + game_id="g1", day=1, round_=0, + voters=voter_only, seats=_seats(), + candidate_seats=[1, 3], + ) + assert results == {2: None} + + +async def test_dispatch_votes_send_failure_yields_none() -> None: + registry = InMemoryNpcRegistry() + + async def _failing_send(_msg: str) -> None: + raise RuntimeError("ws_closed") + + registry.register( + npc_id="npc_seat2", discord_bot_user_id="bot2", + supported_voices=(), version="1", + send=_failing_send, now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_seat2", seat=2, game_id="g1", phase_id="g1::day1::DAY_VOTE::1") + + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig(request_ttl_ms=200), + now_ms=lambda: 0, + ) + voter_only = [Player(seat_no=2, role=Role.VILLAGER, alive=True)] + results = await dispatcher.dispatch_votes( + game_id="g1", day=1, round_=0, + voters=voter_only, seats=_seats(), + candidate_seats=[1, 3], + ) + assert results == {2: None} + + +async def test_dispatch_night_actions_routes_action_kind() -> None: + registry = InMemoryNpcRegistry() + seat3_buf: list[str] = [] + registry.register( + npc_id="npc_seat3", discord_bot_user_id="bot3", + supported_voices=(), version="1", + send=_capture_send(seat3_buf), now_ms=1000, persona_key="gina", + ) + registry.assign("npc_seat3", seat=3, game_id="g1", phase_id="g1::day1::NIGHT::1") + + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig(request_ttl_ms=5_000), + now_ms=lambda: 0, + ) + actor = Player(seat_no=3, role=Role.WEREWOLF, alive=True) + + async def _drive() -> dict[int, int | None]: + return await dispatcher.dispatch_night_actions( + game_id="g1", day=1, action_kind="wolf_attack", + actors=[actor], seats=_seats(), + candidate_seats=[1], + ) + + task = asyncio.create_task(_drive()) + for _ in range(50): + if seat3_buf: + break + await asyncio.sleep(0.01) + assert len(seat3_buf) == 1 + sent = json.loads(seat3_buf[0]) + assert sent["action_kind"] == "wolf_attack" + assert sent["candidate_seats"] == [[1, "Alice"]] + + await dispatcher.on_night_action_decision( + NightActionDecision( + ts=2_000, trace_id=sent["trace_id"], request_id=sent["request_id"], + npc_id="npc_seat3", seat_no=3, action_kind="wolf_attack", + target_seat=1, + ) + ) + results = await task + assert results == {3: 1} + + +async def test_decide_vote_request_payload_shape() -> None: + """The wire payload carries the seat / round / candidate pairs and a + deadline so the NPC bot can build its prompt without a Master DB hit.""" + registry = InMemoryNpcRegistry() + seat2_buf: list[str] = [] + registry.register( + npc_id="npc_seat2", discord_bot_user_id="bot2", + supported_voices=(), version="1", + send=_capture_send(seat2_buf), now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_seat2", seat=2, game_id="g1", phase_id="g1::day1::DAY_VOTE::1") + + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig(request_ttl_ms=5_000), + now_ms=lambda: 1_000, + ) + + async def _drive() -> dict[int, int | None]: + return await dispatcher.dispatch_votes( + game_id="g1", day=1, round_=0, + voters=[Player(seat_no=2, role=Role.VILLAGER, alive=True)], + seats=_seats(), + candidate_seats=[1, 3], + ) + + task = asyncio.create_task(_drive()) + for _ in range(50): + if seat2_buf: + break + await asyncio.sleep(0.01) + sent = DecideVoteRequest.model_validate_json(seat2_buf[0]) + assert sent.seat_no == 2 + assert sent.round_ == 0 + assert sent.candidate_seats == ((1, "Alice"), (3, "Carol")) + assert sent.expires_at_ms == 1_000 + 5_000 + # Resolve so the test task completes cleanly. + await dispatcher.on_vote_decision( + VoteDecision( + ts=2_000, trace_id=sent.trace_id, request_id=sent.request_id, + npc_id="npc_seat2", seat_no=2, target_seat=3, + ) + ) + await task diff --git a/tests/test_npc_decision_service.py b/tests/test_npc_decision_service.py new file mode 100644 index 0000000..d9d8a16 --- /dev/null +++ b/tests/test_npc_decision_service.py @@ -0,0 +1,130 @@ +"""Phase-D NPC decision LLM helpers — prompt builders + parser. + +The helpers are pure functions of state + persona + request so they're +easy to unit-test without standing up an LLM client. Coverage: + +* `build_vote_prompt` mentions seat, role, candidates, persona name. +* `build_night_prompt` carries the right action label and candidate set. +* `parse_decision` is robust against bad JSON / illegal targets / type + abuses (boolean masquerading as int). +""" + +from __future__ import annotations + +from wolfbot.domain.ws_messages import ( + DecideNightActionRequest, + DecideVoteRequest, +) +from wolfbot.npc.decision_service import ( + build_night_prompt, + build_vote_prompt, + parse_decision, +) +from wolfbot.npc.game_state import NpcGameState +from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY + + +def _state(role: str = "WEREWOLF") -> NpcGameState: + return NpcGameState( + game_id="g1", seat_no=3, persona_key="setsu", role=role, + day_number=1, + alive_seats=[(1, "Alice"), (3, "セツ"), (5, "Bob")], + partner_wolves=[(5, "Bob")] if role == "WEREWOLF" else [], + ) + + +def test_build_vote_prompt_includes_state_and_candidates() -> None: + persona = NPC_PERSONAS_BY_KEY["setsu"] + req = DecideVoteRequest( + ts=1, trace_id="t", request_id="rv1", + npc_id="npc_setsu", seat_no=3, game_id="g1", + phase_id="g1::day1::DAY_VOTE::1", round_=0, + candidate_seats=((1, "Alice"), (5, "Bob")), + public_state_summary="phase=DAY_VOTE day=1 co_claims=[(none)]", + expires_at_ms=10_000, + ) + system, user = build_vote_prompt(state=_state(), persona=persona, request=req) + assert "JSON" in system + assert "あなたの席: 席3" in user + assert "あなたの役職: WEREWOLF" in user + assert "席1 Alice" in user and "席5 Bob" in user + assert "通常投票" in user + # Persona name surfaces verbatim so the LLM stays in character. + assert persona.display_name in user + # Wolf partner block (private) appears for werewolf seats. + assert "仲間の人狼" in user + + +def test_build_vote_prompt_runoff_label() -> None: + persona = NPC_PERSONAS_BY_KEY["jonas"] + req = DecideVoteRequest( + ts=1, trace_id="t", request_id="rv2", + npc_id="npc_jonas", seat_no=4, game_id="g1", + phase_id="g1::day1::DAY_RUNOFF::1", round_=1, + candidate_seats=((1, "Alice"),), + expires_at_ms=10_000, + ) + _system, user = build_vote_prompt(state=_state("SEER"), persona=persona, request=req) + assert "決選投票" in user + + +def test_build_night_prompt_per_action_label() -> None: + persona = NPC_PERSONAS_BY_KEY["setsu"] + base_kwargs: dict = dict( # type: ignore[type-arg] + ts=1, trace_id="t", request_id="rn", + npc_id="npc_setsu", seat_no=3, game_id="g1", + phase_id="g1::day1::NIGHT::1", + candidate_seats=((1, "Alice"),), + expires_at_ms=10_000, + ) + for kind, label in ( + ("wolf_attack", "人狼の襲撃"), + ("seer_divine", "占い師の占い"), + ("knight_guard", "騎士の護衛"), + ): + req = DecideNightActionRequest(**{**base_kwargs, "action_kind": kind}) + _system, user = build_night_prompt(state=_state(), persona=persona, request=req) + assert label in user, f"missing label for {kind}" + + +def test_parse_decision_returns_target_when_legal() -> None: + raw = '{"target_seat": 5, "reason": "怪しい"}' + result = parse_decision(raw, legal_seats=frozenset({1, 5})) + assert result.target_seat == 5 + assert result.reason_summary == "怪しい" + + +def test_parse_decision_falls_back_on_illegal_target() -> None: + raw = '{"target_seat": 9, "reason": "?"}' + result = parse_decision(raw, legal_seats=frozenset({1, 5})) + assert result.target_seat is None + assert result.reason_summary == "illegal_target" + + +def test_parse_decision_handles_null_target_as_abstain() -> None: + raw = '{"target_seat": null, "reason": "迷う"}' + result = parse_decision(raw, legal_seats=frozenset({1, 5})) + assert result.target_seat is None + assert result.reason_summary == "迷う" + + +def test_parse_decision_rejects_boolean_masquerading_as_int() -> None: + # In Python, ``isinstance(True, int)`` is True. The parser must + # treat True / False as non-int so the model can't accidentally + # vote "True". + raw = '{"target_seat": true, "reason": "x"}' + result = parse_decision(raw, legal_seats=frozenset({1, 5})) + assert result.target_seat is None + assert result.reason_summary == "non_int_target" + + +def test_parse_decision_drops_malformed_json() -> None: + result = parse_decision("not json", legal_seats=frozenset({1, 5})) + assert result.target_seat is None + assert result.reason_summary == "parse_failed" + + +def test_parse_decision_drops_top_level_array() -> None: + result = parse_decision("[1, 2]", legal_seats=frozenset({1, 5})) + assert result.target_seat is None + assert result.reason_summary == "not_object" From db32321ae805bc4afadeeced38928124974c56e0 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Tue, 28 Apr 2026 23:51:18 +0900 Subject: [PATCH 046/133] =?UTF-8?q?feat(reactive=5Fvoice):=20Phase-D=203+4?= =?UTF-8?q?+5=20=E2=80=94=20night,=20wolf=20chat,=20digest,=20speech=20sta?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-on phases on top of the Phase-D vote path. All keep the rounds-mode gameplay-LLM path completely intact (no behaviour change for `discussion_mode == "rounds"`). Phase 3 — night actions + wolf chat: - services/llm_service.py: `submit_llm_night_actions` branches on `discussion_mode`; reactive_voice fans out per-action_kind via the NPC dispatcher (wolf_attack / seer_divine / knight_guard buckets), validates returned targets against per-actor legal sets, falls back to None on illegal/timeout/error. - master/wolf_chat_broker.py: `WolfChatBroker.handle_wolf_chat_send` validates the sender is an alive WEREWOLF, persists a private WOLF_CHAT LogEntry, broadcasts a `private_state_update(wolf_chat)` to every other live wolf NPC's bot, and optionally mirrors to the wolves Discord channel via the existing message_poster. - master/ws_server.py: routes the new `wolf_chat_send` inbound message to `MasterHandlers.on_wolf_chat_send`. - main.py: instantiates the broker and wires the WS callback. Phase 4 — public-info digest: - master/public_digest.py: pure function turning the live PublicDiscussionState + recent SpeechEvents into a Japanese block: active CO claims (with names), silent seats, per-seat addressed-counts (a lightweight "pressure" stand-in), and the truncated last-addressed snippet. - services/llm_service.py: LLMAdapter._build_public_digest reduces the live SpeechEvent log via apply_speech_event; the dispatchers forward the digest as `public_state_summary` on each DecideVoteRequest / DecideNightActionRequest. Phase 5 — speech path uses NpcGameState: - npc/speech_service.py: NpcGenerator protocol + NpcSpeechService accept an optional `state: NpcGameState`. FakeNpcGenerator captures it for tests. - npc/client.py: `_lookup_state_for_speech` extracts game_id from the SpeakRequest's phase_id and fetches the in-memory state mirror; `_on_speak_request` forwards it down. - npc/openai_compatible_generator.py + gemini_generator.py + mock_generator.py: generate() takes `state`. `_build_user` prefers state.alive_seats / dead_seats over the SpeakRequest fields and renders private blocks (partner_wolves, seer/medium/guard results, wolf chat history) when state is present. role / role_strategy similarly prefer state.role over request.role. Older Master builds that don't push snapshots fall through to the historical request- field rendering. Tests cover (a) wolf chat broker: 3-wolf seed → broadcast skips sender + non-wolves, persists WOLF_CHAT private log, drops impostor non-wolf, mirrors to channel when set; (b) public_digest: empty CO section, names attribution, silent seats, addressed-count ranking, last-addressed truncation, phase_baseline filtered out; (c) speech state: alive/dead pulled from state over stale request, all private blocks render when present, falls back to SpeakRequest fields when state is None. 1044 passed (+12), ruff/mypy clean. --- src/wolfbot/main.py | 18 ++ src/wolfbot/master/public_digest.py | 112 +++++++++++ src/wolfbot/master/wolf_chat_broker.py | 170 +++++++++++++++++ src/wolfbot/master/ws_server.py | 12 ++ src/wolfbot/npc/client.py | 27 ++- src/wolfbot/npc/gemini_generator.py | 7 +- src/wolfbot/npc/mock_generator.py | 1 + .../npc/openai_compatible_generator.py | 80 +++++++- src/wolfbot/npc/speech_service.py | 10 +- src/wolfbot/services/llm_service.py | 166 ++++++++++++++++- tests/test_master_public_digest.py | 149 +++++++++++++++ tests/test_master_wolf_chat_broker.py | 175 ++++++++++++++++++ tests/test_npc_seat_assignment.py | 83 +++++++++ 13 files changed, 995 insertions(+), 15 deletions(-) create mode 100644 src/wolfbot/master/public_digest.py create mode 100644 src/wolfbot/master/wolf_chat_broker.py create mode 100644 tests/test_master_public_digest.py create mode 100644 tests/test_master_wolf_chat_broker.py diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 2f1719a..030a4fb 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -594,6 +594,7 @@ async def _on_speech_recorded(game_id: str) -> None: # decider and ask the NPC bot for its own seat's vote / night # action via WS. from wolfbot.master.decision_dispatcher import NpcDecisionDispatcher + from wolfbot.master.wolf_chat_broker import WolfChatBroker decision_dispatcher = NpcDecisionDispatcher( registry=npc_registry, @@ -601,6 +602,19 @@ async def _on_speech_recorded(game_id: str) -> None: ) llm_adapter._npc_decision_dispatcher = decision_dispatcher + async def _post_to_wolves_channel(game_id: str, text: str) -> None: + game = await repo.load_game(game_id) + if game is None: + return + await discord_adapter.post_wolves_chat(game, text, kind="WOLF_CHAT") + + wolf_chat_broker = WolfChatBroker( + registry=npc_registry, + repo=repo, + post_to_wolves_channel=_post_to_wolves_channel, + now_ms=lambda: int(time.time() * 1000), + ) + async def _reactive_voice_reenter(game_id: str) -> None: # On Master restart, reactive_voice games still in # DAY_DISCUSSION need their VC joined again before the @@ -927,6 +941,9 @@ async def _on_vote_decision(msg: Any, _ctx: Any) -> None: async def _on_night_action_decision(msg: Any, _ctx: Any) -> None: await decision_dispatcher.on_night_action_decision(msg) + async def _on_wolf_chat_send(msg: Any, _ctx: Any) -> None: + await wolf_chat_broker.handle_wolf_chat_send(msg) + master_handlers = MasterHandlers( registry=npc_registry, on_speak_result=_on_speak_result, @@ -940,6 +957,7 @@ async def _on_night_action_decision(msg: Any, _ctx: Any) -> None: on_stt_failed=_on_stt_failed, on_vote_decision=_on_vote_decision, on_night_action_decision=_on_night_action_decision, + on_wolf_chat_send=_on_wolf_chat_send, ) host, port_str = settings.MASTER_WS_LISTEN.rsplit(":", 1) diff --git a/src/wolfbot/master/public_digest.py b/src/wolfbot/master/public_digest.py new file mode 100644 index 0000000..66017c2 --- /dev/null +++ b/src/wolfbot/master/public_digest.py @@ -0,0 +1,112 @@ +"""Master-side public-info digest builder. + +Phase-D: Master is responsible for structuring the public log into +something each NPC bot can fold into its prompt without re-doing the +NLP. This module turns the live `PublicDiscussionState` + recent +`SpeechEvent` rows into a compact, *role-blind* Japanese block. Each +NPC bot receives the same digest regardless of role; their own private +state is what differentiates the eventual decision. + +What lands in the digest (the "中等" digest the user sealed in the +Phase-D spec): + +* Active CO claims: seat → role, with counter-CO history. +* Silent seats (alive seats who haven't spoken in the active phase). +* Per-seat **addressed-count**: how often each seat has been the + ``addressed_seat_no`` of another's utterance — a lightweight stand-in + for a true "pressure" / "stance" score that doesn't require an extra + LLM analyzer pass. +* Last addressed line: the most recent seat-to-seat callout text so the + NPC can reply on-topic. + +Pure function of the inputs — no I/O, no LLM. Master invokes it once +per outbound `DecideVoteRequest` / `DecideNightActionRequest` / +`SpeakRequest` and stuffs the result into `public_state_summary`. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from wolfbot.domain.discussion import PublicDiscussionState, SpeechEvent, SpeechSource + + +def build_public_digest( + *, + state: PublicDiscussionState, + recent_events: Sequence[SpeechEvent], + seat_names: dict[int, str], +) -> str: + """Compose the Japanese digest block. + + ``recent_events`` is a chronological sequence of the speech events + folded into ``state``. ``phase_baseline`` sentinels are filtered out + automatically — callers can pass the raw `load_phase` result. + """ + lines: list[str] = [] + + co_lines: list[str] = [] + if state.co_claims: + for c in state.co_claims: + name = seat_names.get(c.seat, f"席{c.seat}") + co_lines.append(f" 席{c.seat} {name}: {c.role_claim}") + lines.append("## CO 状況") + lines.extend(co_lines or [" (まだ誰も CO していない)"]) + + if state.silent_seats: + silent_str = "、".join( + f"席{s} {seat_names.get(s, f'席{s}')}" + for s in sorted(state.silent_seats) + ) + lines.append(f"## 未発言の生存席\n {silent_str}") + else: + lines.append("## 未発言の生存席\n (なし)") + + # Per-seat addressed count — counts how many times each seat has + # been the explicit `addressed_seat_no` of another's utterance. + # Higher = more pointed-at. Sorted descending so the prompt header + # surfaces the most-pressured seats first. + addressed_counts: dict[int, int] = {} + for ev in recent_events: + if ev.source == SpeechSource.PHASE_BASELINE: + continue + if ev.addressed_seat_no is None: + continue + addressed_counts[ev.addressed_seat_no] = ( + addressed_counts.get(ev.addressed_seat_no, 0) + 1 + ) + if addressed_counts: + ranked = sorted( + addressed_counts.items(), key=lambda kv: (-kv[1], kv[0]) + ) + rank_lines = [ + f" 席{seat_no} {seat_names.get(seat_no, f'席{seat_no}')}: {count}回" + for seat_no, count in ranked + ] + lines.append("## 名指しされた回数 (多い順)") + lines.extend(rank_lines) + + if state.last_addressed_seat is not None and state.last_addressed_text: + speaker = ( + state.last_addressed_speaker_seat + if state.last_addressed_speaker_seat is not None + else None + ) + speaker_label = ( + f"席{speaker} {seat_names.get(speaker, f'席{speaker}')}" + if speaker is not None + else "人間" + ) + target = state.last_addressed_seat + target_label = f"席{target} {seat_names.get(target, f'席{target}')}" + snippet = state.last_addressed_text.strip().replace("\n", " ") + if len(snippet) > 120: + snippet = snippet[:120] + "…" + lines.append( + f"## 直近の名指し\n {speaker_label} → {target_label}: 「{snippet}」" + ) + + return "\n".join(lines) + + +__all__ = ["build_public_digest"] diff --git a/src/wolfbot/master/wolf_chat_broker.py b/src/wolfbot/master/wolf_chat_broker.py new file mode 100644 index 0000000..beff1a1 --- /dev/null +++ b/src/wolfbot/master/wolf_chat_broker.py @@ -0,0 +1,170 @@ +"""Master-side wolf-chat fan-out for Phase-D. + +In `reactive_voice` mode, when a wolf NPC posts a coordination line via +`WolfChatSend`, Master: + +1. Persists it as a private `WOLF_CHAT` `LogEntry` (visibility=`PRIVATE`, + matching how the rounds-mode wolf-chat path writes the same log row) + so post-game replay still has the canonical history. +2. Pushes a `private_state_update(kind=wolf_chat)` to every other live + wolf seat's NPC bot, so each wolf's `NpcGameState.wolf_chat_history` + stays in sync without the bots ever reading the Master DB. +3. Optionally writes the line into the wolves-only Discord channel via + the existing message_poster, so a human wolf player still sees the + coordination line. + +Pure orchestration — no LLM calls and no decisions. The broker is +constructed with the registry + repo references and a `now_ms` clock; +its single public entry is `handle_wolf_chat_send`. +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Awaitable, Callable + +from wolfbot.domain.enums import Role +from wolfbot.domain.models import LogEntry +from wolfbot.domain.ws_messages import WolfChatSend +from wolfbot.master.npc_registry import NpcEntry, NpcRegistry +from wolfbot.master.private_state import make_wolf_chat_update +from wolfbot.persistence.sqlite_repo import SqliteRepo + +log = logging.getLogger(__name__) + + +class WolfChatBroker: + """Receive-and-fan-out for wolf chat lines from NPC bots. + + The broker is per-Master-process; routing is by ``game_id`` extracted + from the `WolfChatSend` payload. Best-effort end-to-end — a single + failed write/broadcast leg is logged but doesn't block the others. + """ + + def __init__( + self, + *, + registry: NpcRegistry, + repo: SqliteRepo, + post_to_wolves_channel: Callable[[str, str], Awaitable[None]] | None = None, + now_ms: Callable[[], int] = lambda: int(time.time() * 1000), + ) -> None: + self.registry = registry + self.repo = repo + self._post_to_wolves_channel = post_to_wolves_channel + self._now_ms = now_ms + + async def handle_wolf_chat_send(self, msg: WolfChatSend) -> None: + # 1) Validate the sender is actually an alive wolf at this seat. + try: + game = await self.repo.load_game(msg.game_id) + seats = await self.repo.load_seats(msg.game_id) + players = await self.repo.load_players(msg.game_id) + except Exception: + log.exception( + "wolf_chat_load_failed game=%s seat=%d", + msg.game_id, msg.seat_no, + ) + return + if game is None or game.ended_at is not None: + log.info( + "wolf_chat_drop_no_game game=%s seat=%d", + msg.game_id, msg.seat_no, + ) + return + sender = next((p for p in players if p.seat_no == msg.seat_no), None) + if ( + sender is None + or not sender.alive + or sender.role is not Role.WEREWOLF + ): + log.info( + "wolf_chat_drop_not_wolf game=%s seat=%d role=%s", + msg.game_id, msg.seat_no, + sender.role.value if sender and sender.role else "?", + ) + return + + seats_by_no = {s.seat_no: s for s in seats} + sender_name = ( + seats_by_no[msg.seat_no].display_name + if msg.seat_no in seats_by_no + else f"席{msg.seat_no}" + ) + text = msg.text.strip() + if not text: + return + + # 2) Persist the canonical WOLF_CHAT log entry. + try: + await self.repo.insert_log_private( + LogEntry( + game_id=msg.game_id, + day=game.day_number, + phase=game.phase, + kind="WOLF_CHAT", + actor_seat=msg.seat_no, + visibility="PRIVATE", + text=text, + created_at=int(self._now_ms() / 1000), + ) + ) + except Exception: + log.exception( + "wolf_chat_log_insert_failed game=%s seat=%d", + msg.game_id, msg.seat_no, + ) + + # 3) Fan out a PrivateStateUpdate(wolf_chat) to every OTHER live + # wolf seat's NPC bot. The sender's own state is updated by its + # own LLM-side bookkeeping; we don't echo back to avoid + # double-recording the same line. + recipients = [ + p + for p in players + if p.role is Role.WEREWOLF and p.alive and p.seat_no != msg.seat_no + ] + for recipient in recipients: + entry = self._find_npc_for_seat(msg.game_id, recipient.seat_no) + if entry is None or entry.send is None: + continue + update = make_wolf_chat_update( + npc_id=entry.npc_id, + game_id=msg.game_id, + seat_no=recipient.seat_no, + day=game.day_number, + speaker_seat=msg.seat_no, + speaker_name=sender_name, + text=text, + ts=self._now_ms(), + trace_id=f"wolf_chat-{msg.game_id}-{msg.seat_no}", + ) + try: + await entry.send(update.model_dump_json()) + except Exception: + log.exception( + "wolf_chat_broadcast_failed npc=%s seat=%d", + entry.npc_id, recipient.seat_no, + ) + + # 4) Mirror to the wolves-only Discord channel so a human wolf + # (if any) sees the coordination line. Best-effort. + if self._post_to_wolves_channel is not None: + try: + await self._post_to_wolves_channel( + msg.game_id, f"**{sender_name}** (狼チャット): {text}", + ) + except Exception: + log.exception( + "wolf_chat_channel_post_failed game=%s", msg.game_id, + ) + + def _find_npc_for_seat(self, game_id: str, seat_no: int) -> NpcEntry | None: + for entry in self.registry.all_online(): + if entry.assigned_seat == seat_no and entry.game_id == game_id: + return entry + return None + + +__all__ = ["WolfChatBroker"] diff --git a/src/wolfbot/master/ws_server.py b/src/wolfbot/master/ws_server.py index aef1aa5..a9d0e34 100644 --- a/src/wolfbot/master/ws_server.py +++ b/src/wolfbot/master/ws_server.py @@ -54,6 +54,7 @@ VadSpeechEnded, VadSpeechStarted, VoteDecision, + WolfChatSend, ) from wolfbot.master.npc_registry import NpcRegistry @@ -187,6 +188,9 @@ class MasterHandlers: on_night_action_decision: ( Callable[[NightActionDecision, ConnectionContext], Awaitable[None]] | None ) = None + on_wolf_chat_send: ( + Callable[[WolfChatSend, ConnectionContext], Awaitable[None]] | None + ) = None now_ms: Callable[[], int] = field(default=_now_ms_default) def install(self, registry_: HandlerRegistry) -> None: @@ -203,6 +207,7 @@ def install(self, registry_: HandlerRegistry) -> None: registry_.add("stt_failed", self._handle_stt_failed) registry_.add("vote_decision", self._handle_vote_decision) registry_.add("night_action_decision", self._handle_night_action_decision) + registry_.add("wolf_chat_send", self._handle_wolf_chat_send) async def _handle_register(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: msg = NpcRegister.model_validate(payload) @@ -294,6 +299,13 @@ async def _handle_night_action_decision( if self.on_night_action_decision is not None: await self.on_night_action_decision(msg, ctx) + async def _handle_wolf_chat_send( + self, payload: dict[str, Any], ctx: ConnectionContext + ) -> None: + msg = WolfChatSend.model_validate(payload) + if self.on_wolf_chat_send is not None: + await self.on_wolf_chat_send(msg, ctx) + # ---------------------------------------------------------------- real WS server diff --git a/src/wolfbot/npc/client.py b/src/wolfbot/npc/client.py index dd03d48..c6763de 100644 --- a/src/wolfbot/npc/client.py +++ b/src/wolfbot/npc/client.py @@ -278,6 +278,24 @@ async def _on_seat_released(self, msg: SeatReleased) -> None: def _on_logic_packet(self, packet: LogicPacket) -> None: self._logic_cache[packet.packet_id] = packet + def _lookup_state_for_speech( + self, request: SpeakRequest + ) -> NpcGameState | None: + """Find the matching `NpcGameState` for the speak request. + + SpeakRequest carries `phase_id` only; we recover the game id via + the canonical ``{gid}::dayN::PHASE::seq`` format and look the + state up in the in-memory mirror. Returns None when no snapshot + has been received yet — the generator falls back to the + SpeakRequest fields in that case. + """ + from wolfbot.services.llm_trace import parse_game_id_from_phase_id + + gid = parse_game_id_from_phase_id(request.phase_id) + if gid is None: + return None + return self.game_states.get(gid) + async def _on_speak_request(self, request: SpeakRequest) -> None: logic = self._logic_cache.get(request.logic_packet_id) if logic is None: @@ -294,7 +312,14 @@ async def _on_speak_request(self, request: SpeakRequest) -> None: pressure={}, expires_at_ms=request.expires_at_ms, ) - result = await self.speech.respond(logic=logic, request=request, now_ms=self.now_ms()) + # Phase-D: pass the per-game state mirror so the speech generator + # can read its own role + private results + alive/dead lists from + # the same source the vote/night handlers use, rather than from + # the SpeakRequest's now-deprecated role/role_strategy/seat fields. + state = self._lookup_state_for_speech(request) + result = await self.speech.respond( + logic=logic, request=request, now_ms=self.now_ms(), state=state, + ) if result.status == "accepted" and result.text is not None: self._pending_playback[result.request_id] = _PendingForPlayback( text=result.text, voice_id=self.config.voice_id diff --git a/src/wolfbot/npc/gemini_generator.py b/src/wolfbot/npc/gemini_generator.py index d8c9229..4e71ee5 100644 --- a/src/wolfbot/npc/gemini_generator.py +++ b/src/wolfbot/npc/gemini_generator.py @@ -75,6 +75,7 @@ async def generate( *, logic: LogicPacket, request: SpeakRequest, + state: object | None = None, ) -> NpcGeneratedSpeech | None: from google import genai from google.genai import types @@ -94,13 +95,15 @@ async def generate( "each NPC bot must declare its persona at startup." ) persona = NPC_PERSONAS_BY_KEY[self._persona_key] + # Phase-D: prefer state.role; fall back to SpeakRequest.role. + role_value = getattr(state, "role", None) or request.role system = _build_system( persona, max_chars=request.max_chars, - role=request.role, + role=role_value, role_strategy=request.role_strategy, ) - user = _build_user(logic, request) + user = _build_user(logic, request, state) client = genai.Client( vertexai=True, diff --git a/src/wolfbot/npc/mock_generator.py b/src/wolfbot/npc/mock_generator.py index 93b6d4f..f4f0e8d 100644 --- a/src/wolfbot/npc/mock_generator.py +++ b/src/wolfbot/npc/mock_generator.py @@ -134,6 +134,7 @@ async def generate( *, logic: LogicPacket, request: SpeakRequest, + state: object | None = None, ) -> NpcGeneratedSpeech | None: self.call_count += 1 script = self._active_script() diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index 9a745f7..4ed866e 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -131,7 +131,11 @@ def _build_system( ) -def _build_user(logic: LogicPacket, request: SpeakRequest) -> str: +def _build_user( + logic: LogicPacket, + request: SpeakRequest, + state: object | None = None, +) -> str: lines = [ f"フェイズ: {request.phase_id}", f"あなたの席: 席{request.seat_no}", @@ -140,17 +144,74 @@ def _build_user(logic: LogicPacket, request: SpeakRequest) -> str: "## 場の状況", logic.public_state_summary or "(情報なし)", ] - if request.alive_seats: + # Phase-D: prefer the bot's own NpcGameState mirror over the stale + # SpeakRequest fields. The state carries role + alive/dead + private + # results + wolf chat that the speech LLM needs to be in character. + alive_seats = ( + getattr(state, "alive_seats", None) + or list(request.alive_seats) + ) + dead_seats = ( + getattr(state, "dead_seats", None) + or list(request.dead_seats) + ) + if alive_seats: alive_str = "、".join( - f"席{seat_no} {name}" for seat_no, name in request.alive_seats + f"席{seat_no} {name}" for seat_no, name in alive_seats ) lines.append("") lines.append(f"## 生存者\n{alive_str}") - if request.dead_seats: + if dead_seats: dead_str = "、".join( - f"席{seat_no} {name}" for seat_no, name in request.dead_seats + f"席{seat_no} {name}" for seat_no, name in dead_seats ) lines.append(f"## 死亡者\n{dead_str}") + # Private state — only present when Phase-D snapshot was received. + if state is not None: + partner_wolves = getattr(state, "partner_wolves", []) or [] + if partner_wolves: + partners = "、".join(f"席{s} {n}" for s, n in partner_wolves) + lines.append(f"## 仲間の人狼 (非公開)\n{partners}") + seer_results = getattr(state, "seer_results", []) or [] + if seer_results: + lines.append("## 自分の占い結果 (非公開)") + for sr in seer_results: + verdict = "黒 (人狼)" if sr.is_wolf else "白 (人狼ではない)" + lines.append( + f" day{sr.day}: 席{sr.target_seat} {sr.target_name} → {verdict}" + ) + medium_results = getattr(state, "medium_results", []) or [] + if medium_results: + lines.append("## 自分の霊媒結果 (非公開)") + for mr in medium_results: + if mr.is_wolf is None: + verdict = "結果なし (処刑なし)" + elif mr.is_wolf: + verdict = "人狼" + else: + verdict = "人狼ではない" + lines.append( + f" day{mr.day}: 席{mr.target_seat} {mr.target_name} → {verdict}" + ) + guard_history = getattr(state, "guard_history", []) or [] + if guard_history: + lines.append("## 自分の護衛履歴 (非公開)") + for g in guard_history: + outcome = ( + "(平和な朝)" if g.peaceful_morning + else "(襲撃発生)" if g.peaceful_morning is False + else "(結果未確定)" + ) + lines.append( + f" day{g.day}: 席{g.target_seat} {g.target_name} を護衛 {outcome}" + ) + wolf_chat_history = getattr(state, "wolf_chat_history", []) or [] + if wolf_chat_history: + lines.append("## 人狼チャット履歴 (狼/狂人にのみ見える)") + for wc in wolf_chat_history[-15:]: + lines.append( + f" day{wc.day} 席{wc.speaker_seat} {wc.speaker_name}: {wc.text}" + ) if logic.recent_speeches: lines.append("") lines.append("## 直近の発言 (古い順)") @@ -274,6 +335,7 @@ async def generate( *, logic: LogicPacket, request: SpeakRequest, + state: object | None = None, ) -> NpcGeneratedSpeech | None: from openai import AsyncOpenAI @@ -292,15 +354,19 @@ async def generate( "each NPC bot must declare its persona at startup." ) persona = NPC_PERSONAS_BY_KEY[self._persona_key] + # Phase-D: prefer state.role over request.role; SpeakRequest's + # role field is now a fallback for back-compat with older Master + # builds that haven't started sending PrivateStateSnapshot. + role_value = getattr(state, "role", None) or request.role system = _build_system( persona, max_chars=request.max_chars, - role=request.role, + role=role_value, role_strategy=request.role_strategy, ) if self.config.mode == "json_object": system += _DEEPSEEK_JSON_CONTRACT_SUFFIX - user = _build_user(logic, request) + user = _build_user(logic, request, state) client = AsyncOpenAI( api_key=self._api_key, diff --git a/src/wolfbot/npc/speech_service.py b/src/wolfbot/npc/speech_service.py index be50956..2ee28eb 100644 --- a/src/wolfbot/npc/speech_service.py +++ b/src/wolfbot/npc/speech_service.py @@ -16,6 +16,7 @@ from wolfbot.domain.enums import CO_CLAIM_VALUES, CoDeclaration from wolfbot.domain.ws_messages import LogicPacket, SpeakRequest, SpeakResult +from wolfbot.npc.game_state import NpcGameState log = logging.getLogger(__name__) @@ -36,6 +37,7 @@ async def generate( *, logic: LogicPacket, request: SpeakRequest, + state: NpcGameState | None = None, ) -> NpcGeneratedSpeech | None: ... @@ -52,16 +54,19 @@ def __init__( self.call_count = 0 self.received_logic: list[LogicPacket] = [] self.received_requests: list[SpeakRequest] = [] + self.received_state: list[NpcGameState | None] = [] async def generate( self, *, logic: LogicPacket, request: SpeakRequest, + state: NpcGameState | None = None, ) -> NpcGeneratedSpeech | None: self.call_count += 1 self.received_logic.append(logic) self.received_requests.append(request) + self.received_state.append(state) if self._scripted: return self._scripted.pop(0) return self._default @@ -83,9 +88,12 @@ async def respond( logic: LogicPacket, request: SpeakRequest, now_ms: int, + state: NpcGameState | None = None, ) -> SpeakResult: try: - speech = await self.generator.generate(logic=logic, request=request) + speech = await self.generator.generate( + logic=logic, request=request, state=state, + ) except Exception: log.exception( "npc_generate_failed npc_id=%s req=%s", request.npc_id, request.request_id diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index 5d54d43..7d67f8a 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -611,13 +611,130 @@ async def submit_llm_night_actions( llm_players = [p for p in llm_players if p.seat_no in restrict_to_seats] if not llm_players: return - task = asyncio.create_task( - self._run_night_actions(game, llm_players, players, seats, unresolved_seats), - name=f"llm-night-{game.id}-d{game.day_number}", - ) + if ( + game.discussion_mode == "reactive_voice" + and self._npc_decision_dispatcher is not None + ): + task = asyncio.create_task( + self._run_night_actions_via_npc_dispatcher( + game, llm_players, players, seats, unresolved_seats + ), + name=f"npc-night-{game.id}-d{game.day_number}", + ) + else: + task = asyncio.create_task( + self._run_night_actions(game, llm_players, players, seats, unresolved_seats), + name=f"llm-night-{game.id}-d{game.day_number}", + ) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) + async def _run_night_actions_via_npc_dispatcher( + self, + game: Game, + llm_players: Sequence[Player], + all_players: Sequence[Player], + seats: Sequence[Seat], + unresolved_seats: frozenset[int] = frozenset(), + ) -> None: + """Reactive_voice night-action path: each role-actor seat dispatches + to its NPC bot. Wolf chat is intentionally skipped here — the + wolves coordinate through their `wolf_chat_history` mirror that + Master pushes via PrivateStateUpdate when wolves talk. + """ + seats_by_no = {s.seat_no: s for s in seats} + prev = await self.repo.load_previous_guard(game.id) + prev_guard_seat = previous_guard_seat_for_night(prev, game.day_number) + # Stale-phase guard: bail if we already advanced past NIGHT. + fresh = await self.repo.load_game(game.id) + if ( + fresh is None + or fresh.ended_at is not None + or fresh.phase is not Phase.NIGHT + or fresh.day_number != game.day_number + ): + return + # Bucket players by night action kind so each kind dispatches as + # a single fan-out (matches the dispatcher API). Skip seats that + # already submitted unless they are unresolved (split wolves). + existing = await self.repo.load_night_actions(game.id, day=game.day_number) + already_by_seat: dict[int, set[SubmissionType]] = {} + for a in existing: + already_by_seat.setdefault(a.actor_seat, set()).add(a.kind) + buckets: dict[str, list[tuple[Player, SubmissionType, list[int]]]] = { + "wolf_attack": [], + "seer_divine": [], + "knight_guard": [], + } + kind_to_action: dict[SubmissionType, str] = { + SubmissionType.WOLF_ATTACK: "wolf_attack", + SubmissionType.SEER_DIVINE: "seer_divine", + SubmissionType.KNIGHT_GUARD: "knight_guard", + } + for player in llm_players: + kind, legal = self._role_to_kind(player, all_players, prev_guard_seat) + if kind is None or not legal: + continue + already = already_by_seat.get(player.seat_no, set()) + if kind in already and player.seat_no not in unresolved_seats: + continue + action_label = kind_to_action.get(kind) + if action_label is None: + continue + buckets[action_label].append((player, kind, list(legal))) + dispatcher = self._npc_decision_dispatcher + for action_label, items in buckets.items(): + if not items: + continue + actors = [p for p, _kind, _legal in items] + # Use the union of legal targets for the request payload — the + # NPC bot picks any of them. (Per-actor legal sets only differ + # when there are 3+ wolves, which the 9-player ruleset never + # has.) We still validate per-actor on the response side. + union_legal: set[int] = set() + per_actor_legal: dict[int, set[int]] = {} + per_actor_kind: dict[int, SubmissionType] = {} + for player, kind, legal in items: + per_actor_legal[player.seat_no] = set(legal) + per_actor_kind[player.seat_no] = kind + union_legal.update(legal) + digest = await self._build_public_digest(game, seats) + try: + results = await dispatcher.dispatch_night_actions( # type: ignore[union-attr] + game_id=game.id, + day=game.day_number, + action_kind=action_label, + actors=actors, + seats=seats, + candidate_seats=sorted(union_legal), + public_state_summary=digest, + ) + except Exception: + log.exception( + "npc_night_dispatch_failed game=%s day=%d kind=%s", + game.id, game.day_number, action_label, + ) + continue + for actor in actors: + target = results.get(actor.seat_no) + if target is not None and target not in per_actor_legal[actor.seat_no]: + log.info( + "npc_night_decision_illegal_target game=%s seat=%d kind=%s target=%s", + game.id, actor.seat_no, action_label, target, + ) + target = None + kind = per_actor_kind[actor.seat_no] + try: + await self.gs.submit_night_action( + game.id, actor.seat_no, kind, target, game.day_number, + ) + except Exception: + log.exception( + "npc_night_submit_failed game=%s seat=%d kind=%s", + game.id, actor.seat_no, kind.value, + ) + _ = seats_by_no # lint: keep parameter live for log readers + async def _run_night_actions( self, game: Game, @@ -838,6 +955,45 @@ async def submit_llm_votes( self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) + async def _build_public_digest(self, game: Game, seats: Sequence[Seat]) -> str: + """Phase-D: render the Master-side public-info digest for an NPC + dispatch request. Best-effort — a missing DiscussionService or + an empty phase yields an empty string so the NPC sees the + historical no-digest behavior. + """ + if self.discussion_service is None: + return "" + try: + from wolfbot.domain.discussion import ( + make_phase_id as _make_phase_id, + ) + from wolfbot.master.public_digest import build_public_digest + from wolfbot.services.discussion_service import ( + apply_speech_event, + ) + + phase_id = _make_phase_id(game.id, game.day_number, game.phase) + events = list( + await self.discussion_service.load_phase(game.id, phase_id) + ) + if not events: + return "" + state = None + for ev in events: + state = apply_speech_event(state, ev) + if state is None: + return "" + seat_names = {s.seat_no: s.display_name for s in seats} + return build_public_digest( + state=state, recent_events=events, seat_names=seat_names, + ) + except Exception: + log.exception( + "public_digest_build_failed game=%s day=%d", + game.id, game.day_number, + ) + return "" + async def _run_votes_via_npc_dispatcher( self, game: Game, @@ -901,6 +1057,7 @@ async def _run_votes_via_npc_dispatcher( for cs in per_voter_candidates.values(): union_candidates.update(cs) dispatcher = self._npc_decision_dispatcher + digest = await self._build_public_digest(game, seats) try: results = await dispatcher.dispatch_votes( # type: ignore[union-attr] game_id=game.id, @@ -909,6 +1066,7 @@ async def _run_votes_via_npc_dispatcher( voters=voters_to_dispatch, seats=seats, candidate_seats=sorted(union_candidates), + public_state_summary=digest, ) except Exception: log.exception( diff --git a/tests/test_master_public_digest.py b/tests/test_master_public_digest.py new file mode 100644 index 0000000..6f86ce8 --- /dev/null +++ b/tests/test_master_public_digest.py @@ -0,0 +1,149 @@ +"""Phase-D Master public-info digest builder.""" + +from __future__ import annotations + +from wolfbot.domain.discussion import ( + CoClaim, + PublicDiscussionState, + SpeakerKind, + SpeechEvent, + SpeechSource, + make_phase_id, +) +from wolfbot.domain.enums import Phase +from wolfbot.master.public_digest import build_public_digest + + +def _state(**overrides: object) -> PublicDiscussionState: + base: dict[str, object] = { + "game_id": "g1", + "phase_id": make_phase_id("g1", 1, Phase.DAY_DISCUSSION), + "day": 1, + "alive_seat_nos": frozenset({1, 2, 3, 4}), + "co_claims": (), + "silent_seats": frozenset(), + } + base.update(overrides) + return PublicDiscussionState(**base) # type: ignore[arg-type] + + +def _ev( + *, + speaker_seat: int, + text: str, + addressed: int | None = None, + source: SpeechSource = SpeechSource.NPC_GENERATED, +) -> SpeechEvent: + return SpeechEvent( + event_id=f"ev-{speaker_seat}-{text[:5]}", + game_id="g1", + phase_id=make_phase_id("g1", 1, Phase.DAY_DISCUSSION), + day=1, + phase=Phase.DAY_DISCUSSION, + source=source, + speaker_kind=( + SpeakerKind.HUMAN + if source == SpeechSource.VOICE_STT or source == SpeechSource.TEXT + else SpeakerKind.NPC + ), + speaker_seat=speaker_seat, + text=text, + addressed_seat_no=addressed, + created_at_ms=1000, + ) + + +def test_digest_renders_co_section_when_empty() -> None: + state = _state() + out = build_public_digest( + state=state, recent_events=[], seat_names={1: "Alice"}, + ) + assert "## CO 状況" in out + assert "まだ誰も CO していない" in out + + +def test_digest_renders_co_claims_with_names() -> None: + state = _state( + co_claims=( + CoClaim(seat=2, role_claim="seer", declared_at_event_id="ev1"), + CoClaim(seat=4, role_claim="medium", declared_at_event_id="ev2"), + ), + ) + out = build_public_digest( + state=state, recent_events=[], + seat_names={1: "Alice", 2: "Bob", 4: "Dave"}, + ) + assert "席2 Bob: seer" in out + assert "席4 Dave: medium" in out + + +def test_digest_lists_silent_seats() -> None: + state = _state(silent_seats=frozenset({3, 4})) + out = build_public_digest( + state=state, recent_events=[], + seat_names={3: "Carol", 4: "Dave"}, + ) + assert "## 未発言の生存席" in out + assert "席3 Carol" in out and "席4 Dave" in out + + +def test_digest_aggregates_addressed_counts_descending() -> None: + state = _state() + events = [ + _ev(speaker_seat=1, text="say 1", addressed=2), + _ev(speaker_seat=3, text="say 3", addressed=2), + _ev(speaker_seat=4, text="say 4", addressed=2), + _ev(speaker_seat=1, text="more", addressed=4), + ] + out = build_public_digest( + state=state, recent_events=events, + seat_names={2: "Bob", 4: "Dave"}, + ) + assert "## 名指しされた回数 (多い順)" in out + seat2_idx = out.find("席2 Bob: 3回") + seat4_idx = out.find("席4 Dave: 1回") + assert seat2_idx != -1 and seat4_idx != -1 + assert seat2_idx < seat4_idx # higher count first + + +def test_digest_renders_last_addressed_block() -> None: + state = _state( + last_addressed_seat=2, + last_addressed_speaker_seat=1, + last_addressed_text="あなたの白判定が信用できないんです", + ) + out = build_public_digest( + state=state, recent_events=[], + seat_names={1: "Alice", 2: "Bob"}, + ) + assert "## 直近の名指し" in out + assert "席1 Alice → 席2 Bob" in out + assert "信用できない" in out + + +def test_digest_truncates_long_addressed_snippet() -> None: + long_text = "あ" * 200 + state = _state( + last_addressed_seat=2, + last_addressed_speaker_seat=1, + last_addressed_text=long_text, + ) + out = build_public_digest( + state=state, recent_events=[], + seat_names={1: "Alice", 2: "Bob"}, + ) + # Truncated to 120 chars + ellipsis. + assert "あ" * 120 + "…" in out + assert long_text not in out + + +def test_digest_skips_phase_baseline_in_addressed_counts() -> None: + state = _state() + events = [ + _ev(speaker_seat=1, text="", source=SpeechSource.PHASE_BASELINE), + _ev(speaker_seat=1, text="say", addressed=2), + ] + out = build_public_digest( + state=state, recent_events=events, seat_names={2: "Bob"}, + ) + assert "席2 Bob: 1回" in out diff --git a/tests/test_master_wolf_chat_broker.py b/tests/test_master_wolf_chat_broker.py new file mode 100644 index 0000000..2c2c373 --- /dev/null +++ b/tests/test_master_wolf_chat_broker.py @@ -0,0 +1,175 @@ +"""Phase-D wolf chat broker — Master receives WolfChatSend, fans out +PrivateStateUpdate(wolf_chat) to other live wolf NPCs, and persists a +canonical WOLF_CHAT log entry.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable + +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Game, Seat +from wolfbot.domain.ws_messages import ( + PrivateStateUpdate, + WolfChatSend, +) +from wolfbot.master.npc_registry import InMemoryNpcRegistry +from wolfbot.master.wolf_chat_broker import WolfChatBroker +from wolfbot.persistence.sqlite_repo import SqliteRepo + + +def _capture_send(buf: list[str]) -> Callable[[str], Awaitable[None]]: + async def _send(msg: str) -> None: + buf.append(msg) + + return _send + + +async def _seed_3wolf_game(repo: SqliteRepo) -> Game: + game = Game( + id="g_wolf", + guild_id="gu", + host_user_id="h", + phase=Phase.NIGHT, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + wolves_channel_id="cw", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(game) + seats = [ + Seat(seat_no=1, display_name="Alice", is_llm=True, persona_key="setsu", + discord_user_id=None), + Seat(seat_no=2, display_name="Bob", is_llm=True, persona_key="gina", + discord_user_id=None), + Seat(seat_no=3, display_name="Carol", is_llm=True, persona_key="jonas", + discord_user_id=None), + ] + for s in seats: + await repo.insert_seat(game.id, s) + await repo.set_player_role(game.id, 1, Role.WEREWOLF) + await repo.set_player_role(game.id, 2, Role.WEREWOLF) + await repo.set_player_role(game.id, 3, Role.VILLAGER) + return game + + +async def test_wolf_chat_send_broadcasts_to_other_wolves(repo: SqliteRepo) -> None: + game = await _seed_3wolf_game(repo) + registry = InMemoryNpcRegistry() + seat1_buf: list[str] = [] + seat2_buf: list[str] = [] + seat3_buf: list[str] = [] + for npc_id, persona, seat, buf in ( + ("npc_alice", "setsu", 1, seat1_buf), + ("npc_bob", "gina", 2, seat2_buf), + ("npc_carol", "jonas", 3, seat3_buf), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=f"bot{seat}", + supported_voices=(), version="1", + send=_capture_send(buf), now_ms=1000, persona_key=persona, + ) + registry.assign(npc_id, seat=seat, game_id=game.id, phase_id="g_wolf::day1::NIGHT::1") + + broker = WolfChatBroker(registry=registry, repo=repo, now_ms=lambda: 5000) + msg = WolfChatSend( + ts=5000, trace_id="t", + npc_id="npc_alice", seat_no=1, game_id=game.id, + text="席3を狙おう", + ) + await broker.handle_wolf_chat_send(msg) + + # Sender (seat 1) gets nothing. + assert seat1_buf == [] + # Other live wolf (seat 2) receives a wolf_chat update. + assert len(seat2_buf) == 1 + upd = PrivateStateUpdate.model_validate_json(seat2_buf[0]) + assert upd.update_kind == "wolf_chat" + assert upd.payload["text"] == "席3を狙おう" + assert upd.payload["speaker_seat"] == 1 + assert upd.payload["speaker_name"] == "Alice" + # Non-wolf seat (3) receives nothing. + assert seat3_buf == [] + + # WOLF_CHAT log row was persisted. + # Inspect the WOLF_CHAT private log directly via raw SQL — no + # audience-seat filter needed since the broker writes the row with a + # null audience (= visible to every wolf at replay time). + async with repo._db.execute( # type: ignore[attr-defined] + "SELECT kind, text FROM logs_private WHERE game_id=? AND kind='WOLF_CHAT'", + (game.id,), + ) as cur: + wolf_chat_rows = [dict(r) for r in await cur.fetchall()] + assert len(wolf_chat_rows) == 1 + assert "席3を狙おう" in wolf_chat_rows[0]["text"] + + +async def test_wolf_chat_send_drops_non_wolf_sender(repo: SqliteRepo) -> None: + game = await _seed_3wolf_game(repo) + registry = InMemoryNpcRegistry() + seat2_buf: list[str] = [] + registry.register( + npc_id="npc_bob", discord_bot_user_id="bot2", + supported_voices=(), version="1", + send=_capture_send(seat2_buf), now_ms=1000, persona_key="gina", + ) + registry.assign("npc_bob", seat=2, game_id=game.id, phase_id="g_wolf::day1::NIGHT::1") + + broker = WolfChatBroker(registry=registry, repo=repo, now_ms=lambda: 5000) + # Seat 3 is the villager; Master must drop the impersonating message. + msg = WolfChatSend( + ts=5000, trace_id="t", + npc_id="npc_carol", seat_no=3, game_id=game.id, + text="罠を仕込む", + ) + await broker.handle_wolf_chat_send(msg) + + assert seat2_buf == [] # No broadcast. + async with repo._db.execute( # type: ignore[attr-defined] + "SELECT kind FROM logs_private WHERE game_id=? AND kind='WOLF_CHAT'", + (game.id,), + ) as cur: + rows = list(await cur.fetchall()) + assert rows == [] + + +async def test_wolf_chat_send_mirrors_to_wolves_channel_when_set( + repo: SqliteRepo, +) -> None: + game = await _seed_3wolf_game(repo) + registry = InMemoryNpcRegistry() + seat1_buf: list[str] = [] + seat2_buf: list[str] = [] + for npc_id, seat, buf in ( + ("npc_alice", 1, seat1_buf), + ("npc_bob", 2, seat2_buf), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=f"bot{seat}", + supported_voices=(), version="1", + send=_capture_send(buf), now_ms=1000, persona_key="x", + ) + registry.assign(npc_id, seat=seat, game_id=game.id, phase_id="g_wolf::day1::NIGHT::1") + + posted: list[tuple[str, str]] = [] + + async def _post_to_wolves(game_id: str, text: str) -> None: + posted.append((game_id, text)) + + broker = WolfChatBroker( + registry=registry, repo=repo, + post_to_wolves_channel=_post_to_wolves, + now_ms=lambda: 5000, + ) + msg = WolfChatSend( + ts=5000, trace_id="t", + npc_id="npc_alice", seat_no=1, game_id=game.id, + text="様子見しよう", + ) + await broker.handle_wolf_chat_send(msg) + + assert posted == [(game.id, "**Alice** (狼チャット): 様子見しよう")] + # Make sure JSON validity isn't blocked by the channel mirror. + assert "wolf_chat" in json.loads(seat2_buf[0])["update_kind"] diff --git a/tests/test_npc_seat_assignment.py b/tests/test_npc_seat_assignment.py index fed22a8..5d18a56 100644 --- a/tests/test_npc_seat_assignment.py +++ b/tests/test_npc_seat_assignment.py @@ -341,6 +341,89 @@ def test_build_user_prompt_renders_recent_speeches_and_seats() -> None: assert "## 死亡者" in user_msg and "席4 故人" in user_msg +def test_build_user_prompt_uses_npc_state_over_request_fields() -> None: + """Phase-D: when an `NpcGameState` is supplied, the speech user prompt + pulls alive/dead/private info from it rather than the SpeakRequest. + The state's role-specific results, partner wolves, and wolf chat + history all surface in the prompt.""" + from wolfbot.domain.ws_messages import ( + GuardEntry, + MediumResult, + SeerResult, + WolfChatLine, + ) + from wolfbot.npc.game_state import NpcGameState + + logic = LogicPacket( + ts=1, trace_id="t", packet_id="lp", phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="(no digest)", + recent_speeches=(), + expires_at_ms=9999, + ) + request = SpeakRequest( + ts=1, trace_id="t", request_id="sr", npc_id="npc_1", + phase_id="ph", seat_no=2, logic_packet_id="lp", + suggested_intent="speak", max_chars=80, expires_at_ms=5000, + # Stale fields a Master might still be sending — state should + # override these. + alive_seats=((9, "stale_alive"),), + dead_seats=(), + ) + state = NpcGameState( + game_id="g1", seat_no=2, persona_key="setsu", role="WEREWOLF", + day_number=1, + alive_seats=[(1, "Alice"), (2, "セツ"), (5, "Bob")], + dead_seats=[(3, "fallen")], + partner_wolves=[(5, "Bob")], + seer_results=[SeerResult(day=1, target_seat=1, target_name="Alice", is_wolf=False)], + medium_results=[ + MediumResult(day=1, target_seat=3, target_name="fallen", is_wolf=False) + ], + guard_history=[ + GuardEntry(day=1, target_seat=2, target_name="セツ", peaceful_morning=True) + ], + wolf_chat_history=[ + WolfChatLine(day=1, speaker_seat=5, speaker_name="Bob", text="席1を狙う") + ], + ) + out = _build_user(logic, request, state) + # Alive list comes from state, NOT from the stale request fields. + assert "席1 Alice" in out and "席2 セツ" in out and "席5 Bob" in out + assert "stale_alive" not in out + # Dead list comes from state. + assert "席3 fallen" in out + # Private blocks all surface. + assert "## 仲間の人狼" in out and "席5 Bob" in out + assert "## 自分の占い結果" in out + assert "## 自分の霊媒結果" in out + assert "## 自分の護衛履歴" in out and "(平和な朝)" in out + assert "## 人狼チャット履歴" in out and "席1を狙う" in out + + +def test_build_user_prompt_falls_back_to_request_when_state_none() -> None: + """Without state, the prompt still works using SpeakRequest fields + (back-compat with older Master builds that don't push snapshots).""" + logic = LogicPacket( + ts=1, trace_id="t", packet_id="lp", phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="(d)", expires_at_ms=9999, + ) + request = SpeakRequest( + ts=1, trace_id="t", request_id="sr", npc_id="npc_1", + phase_id="ph", seat_no=2, logic_packet_id="lp", + suggested_intent="speak", max_chars=80, expires_at_ms=5000, + alive_seats=((1, "Alice"), (2, "Bob")), + dead_seats=((3, "Dead"),), + ) + out = _build_user(logic, request, None) + assert "席1 Alice" in out and "席2 Bob" in out + assert "席3 Dead" in out + # No private state when state is None. + assert "## 仲間の人狼" not in out + assert "## 自分の占い結果" not in out + + def test_build_user_prompt_no_candidates_no_pressure() -> None: logic = LogicPacket( ts=1, From 2ae90b259afbdb6300f31ef71a83ae45c3c901a9 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 00:23:11 +0900 Subject: [PATCH 047/133] feat(reactive_voice): Phase-D night-result push + wolf-chat coordination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two remaining gaps that blocked an end-to-end reactive_voice playthrough: 1) PrivateStateUpdate auto-firing after each transition. master/phase_d_state_pusher.py: PhaseDStatePusher reads transition.private_logs / public_logs and translates them into the right NPC state mutation: - SEER_RESULT / SEER_RESULT_NIGHT0 → seer_result update on the seer's NPC (NIGHT_0 forced white per spec, regular result via is_detected_as_wolf so madman stays white). - MEDIUM_RESULT → medium_result update on the medium's NPC, carrying is_wolf=null when the day had no execution. - NIGHT entry with a KNIGHT_GUARD action → guard_entry on the knight's NPC. - MORNING in public_logs → guard_resolved on the knight's NPC, day=current_day-1, peaceful_morning parsed from the morning text. - Every transition also fans alive_changed + day_advanced to every alive LLM seat's NPC so prompts always see the current game state without a re-snapshot. GameService late-binds the pusher via set_phase_d_state_pusher; main.py constructs it inside the reactive_voice wiring block. No-op for rounds-mode (the pusher short-circuits on discussion_mode != reactive_voice). 2) Wolf chat coordination round-trip. - domain/ws_messages.py: new WolfChatRequest (Master → NPC); the existing WolfChatSend gains an optional request_id field so prompted lines can resolve a pending future. Spontaneous lines stay request_id=None and the broker still persists+broadcasts. - master/decision_dispatcher.py: dispatch_wolf_chat_lines elicits one line per alive wolf NPC sequentially (sorted by seat) so wolf B's request fires only after the broker has pushed wolf A's line into wolf B's wolf_chat_history mirror. Timeouts/offline/ send-failure yield None and the gather() proceeds. - main.py: vote/night decision callback runs broker first, then the dispatcher's on_wolf_chat_send so every other wolf's mirror is updated before the dispatcher resolves its future. - services/llm_service.py: _run_night_actions_via_npc_dispatcher calls dispatch_wolf_chat_lines BEFORE the wolf_attack bucket fan- out (only when ≥2 wolves are alive), passing the public digest and legal attack candidates. - npc/decision_service.py: build_wolf_chat_prompt + _WOLF_CHAT_SCHEMA + parse_wolf_chat_text. Prompt block surfaces persona, role, private state, candidates; output is just `{ "text": "…" }`. - npc/client.py: _on_wolf_chat_request handler. Drops on missing state / non-wolf / no-LLM / parse-failure with empty-text reply so the dispatcher resolves cleanly. Tests cover: pusher per-trigger (rounds-mode skip, seer_result with is_wolf, NIGHT_0 force-white, medium with no execution, guard_entry from night_actions, guard_resolved on peaceful morning, alive + day_advanced fan-out), wolf-chat dispatcher (sequential resolution across two wolves, timeout → None). 1053 passed (+9), ruff/mypy clean. --- src/wolfbot/domain/ws_messages.py | 39 +- src/wolfbot/main.py | 24 ++ src/wolfbot/master/decision_dispatcher.py | 137 +++++++ src/wolfbot/master/phase_d_state_pusher.py | 402 +++++++++++++++++++++ src/wolfbot/npc/client.py | 70 ++++ src/wolfbot/npc/decision_service.py | 81 ++++- src/wolfbot/services/game_service.py | 32 ++ src/wolfbot/services/llm_service.py | 26 ++ tests/test_master_decision_dispatcher.py | 95 +++++ tests/test_master_phase_d_state_pusher.py | 252 +++++++++++++ 10 files changed, 1156 insertions(+), 2 deletions(-) create mode 100644 src/wolfbot/master/phase_d_state_pusher.py create mode 100644 tests/test_master_phase_d_state_pusher.py diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index 0e0e34b..390b65e 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -390,16 +390,53 @@ class NightActionDecision(BaseEnvelope): reason_summary: str = "" +class WolfChatRequest(BaseEnvelope): + """Master → NPC (wolf seat only): "post a coordination line now". + + Sent sequentially to each alive wolf NPC at the start of the night + phase before attack-decision dispatch. Each wolf reads the others' + `wolf_chat_history` (already updated via `private_state_update`) so + the chain converges on a target. NPC replies via `WolfChatSend` + carrying the same `request_id` so Master can drain its pending + futures. + """ + + type: Literal["wolf_chat_request"] = "wolf_chat_request" + request_id: str + npc_id: str + seat_no: int + game_id: str + phase_id: str + candidate_seats: tuple[tuple[int, str], ...] = Field( + default_factory=tuple, + description=( + "(seat_no, name) pairs of legal attack targets — passed so the " + "wolf NPC can ground its proposal in real candidates rather " + "than improvising a name." + ), + ) + public_state_summary: str = "" + expires_at_ms: int + + class WolfChatSend(BaseEnvelope): """NPC (wolf seat only) → Master: post a line to the wolves' private chat. Master persists it as a `WOLF_CHAT` private LogEntry and pushes - a `wolf_chat` PrivateStateUpdate to every other live wolf seat's NPC.""" + a `wolf_chat` PrivateStateUpdate to every other live wolf seat's NPC. + + `request_id` is non-null when the line was prompted by a + `WolfChatRequest` from Master; the dispatcher uses it to resolve the + pending future. Spontaneous wolf chat (a wolf NPC volunteers a line + without a request) leaves it null — Master broker still persists + + fans out, just without resolving any future. + """ type: Literal["wolf_chat_send"] = "wolf_chat_send" npc_id: str seat_no: int game_id: str text: str + request_id: str | None = None class PlaybackAuthorized(BaseEnvelope): diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 030a4fb..1f77990 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -615,6 +615,20 @@ async def _post_to_wolves_channel(game_id: str, text: str) -> None: now_ms=lambda: int(time.time() * 1000), ) + # Phase-D: state pusher fans out seer/medium/guard results, + # alive_changed, and day_advanced PrivateStateUpdates to NPC + # bots after each transition. Wired into GameService via the + # late-binding setter (the pusher needs npc_registry which is + # only available here, but GameService was constructed earlier). + from wolfbot.master.phase_d_state_pusher import PhaseDStatePusher + + phase_d_pusher = PhaseDStatePusher( + repo=repo, + registry=npc_registry, + now_ms=lambda: int(time.time() * 1000), + ) + game_service.set_phase_d_state_pusher(phase_d_pusher) + async def _reactive_voice_reenter(game_id: str) -> None: # On Master restart, reactive_voice games still in # DAY_DISCUSSION need their VC joined again before the @@ -942,7 +956,17 @@ async def _on_night_action_decision(msg: Any, _ctx: Any) -> None: await decision_dispatcher.on_night_action_decision(msg) async def _on_wolf_chat_send(msg: Any, _ctx: Any) -> None: + # Two consumers run for every wolf_chat_send: + # 1) Broker: persist as WOLF_CHAT private log + broadcast a + # `wolf_chat` PrivateStateUpdate to other live wolves. + # 2) Dispatcher: resolve the matching pending future when + # the line was prompted by a Master-issued `WolfChatRequest`. + # Order matters — let the broker run first so by the time + # the dispatcher resolves the future and the wolf-chat + # gather() returns, every other wolf NPC's mirror is + # already updated. await wolf_chat_broker.handle_wolf_chat_send(msg) + await decision_dispatcher.on_wolf_chat_send(msg) master_handlers = MasterHandlers( registry=npc_registry, diff --git a/src/wolfbot/master/decision_dispatcher.py b/src/wolfbot/master/decision_dispatcher.py index 64763d0..51e12d0 100644 --- a/src/wolfbot/master/decision_dispatcher.py +++ b/src/wolfbot/master/decision_dispatcher.py @@ -36,6 +36,8 @@ DecideVoteRequest, NightActionDecision, VoteDecision, + WolfChatRequest, + WolfChatSend, ) from wolfbot.master.npc_registry import NpcEntry, NpcRegistry @@ -65,6 +67,16 @@ class _PendingDecision: request_id: str +@dataclass +class _PendingWolfChat: + """Pending wolf-chat round-trip — resolves to the spoken text.""" + + future: asyncio.Future[str | None] + seat_no: int + npc_id: str + request_id: str + + class NpcDecisionDispatcher: """Send DecideVoteRequest / DecideNightActionRequest to NPC bots and collect their decisions. @@ -86,6 +98,10 @@ def __init__( self._now_ms = now_ms # request_id → pending future. Cleaned up on resolution / timeout. self._pending: dict[str, _PendingDecision] = {} + # request_id → pending wolf-chat future (kept separate from + # `_pending` so a stray vote_decision can't accidentally resolve + # a wolf chat or vice versa). + self._pending_wolf_chat: dict[str, _PendingWolfChat] = {} # ------------------------------------------------- public dispatch entry @@ -134,6 +150,50 @@ async def _one(voter: Player) -> tuple[int, int | None]: ) return dict(results) + async def dispatch_wolf_chat_lines( + self, + *, + game_id: str, + day: int, + wolves: Sequence[Player], + seats: Sequence[Seat], + candidate_seats: Sequence[int], + public_state_summary: str = "", + ) -> dict[int, str | None]: + """Sequentially elicit one wolf-chat line per alive wolf seat. + + Sequential by design: each WolfChatRequest is awaited so the + broker's broadcast lands before the next wolf's request fires, + which is what lets the wolves' state mirrors converge before the + attack-decision dispatch. + + Returns ``{wolf_seat: line_or_None}`` — None when the NPC was + offline / send failed / timed out / declined. + """ + from wolfbot.domain.discussion import make_phase_id + + seats_by_no = {s.seat_no: s for s in seats} + candidate_pairs = tuple( + (no, seats_by_no[no].display_name) + for no in candidate_seats + if no in seats_by_no + ) + phase_id = make_phase_id( + game_id, day, Phase.NIGHT_0 if day == 0 else Phase.NIGHT + ) + out: dict[int, str | None] = {} + for wolf in sorted(wolves, key=lambda p: p.seat_no): + text = await self._dispatch_one_wolf_chat( + wolf=wolf, + seats_by_no=seats_by_no, + candidate_pairs=candidate_pairs, + game_id=game_id, + phase_id=phase_id, + public_state_summary=public_state_summary, + ) + out[wolf.seat_no] = text + return out + async def dispatch_night_actions( self, *, @@ -201,6 +261,22 @@ async def on_vote_decision(self, msg: VoteDecision) -> None: msg.reason_summary or "(none)", ) + async def on_wolf_chat_send(self, msg: WolfChatSend) -> None: + """Resolve any pending wolf-chat future for ``msg.request_id``. + + Idempotent: if no future is parked (e.g. spontaneous wolf line + sent without a request, or duplicate from retransmit), this is a + no-op. Persistence + broadcast still happens via the broker + regardless. + """ + if msg.request_id is None: + return + pending = self._pending_wolf_chat.pop(msg.request_id, None) + if pending is None: + return + if not pending.future.done(): + pending.future.set_result(msg.text) + async def on_night_action_decision(self, msg: NightActionDecision) -> None: pending = self._pending.pop(msg.request_id, None) if pending is None: @@ -264,6 +340,67 @@ async def _dispatch_one_vote( label="vote", ) + async def _dispatch_one_wolf_chat( + self, + *, + wolf: Player, + seats_by_no: dict[int, Seat], + candidate_pairs: tuple[tuple[int, str], ...], + game_id: str, + phase_id: str, + public_state_summary: str, + ) -> str | None: + seat = seats_by_no.get(wolf.seat_no) + if seat is None or not seat.is_llm: + return None + entry = self._find_npc_for_seat(game_id, wolf.seat_no) + if entry is None or entry.send is None: + log.info( + "wolf_chat_dispatch_skip_no_npc game=%s seat=%d", + game_id, wolf.seat_no, + ) + return None + request_id = f"rwc_{uuid.uuid4().hex[:12]}" + deadline = self._now_ms() + self.config.request_ttl_ms + req = WolfChatRequest( + ts=self._now_ms(), + trace_id=f"wolf_chat-{game_id}-{wolf.seat_no}", + request_id=request_id, + npc_id=entry.npc_id, + seat_no=wolf.seat_no, + game_id=game_id, + phase_id=phase_id, + candidate_seats=candidate_pairs, + public_state_summary=public_state_summary, + expires_at_ms=deadline, + ) + loop = asyncio.get_running_loop() + future: asyncio.Future[str | None] = loop.create_future() + self._pending_wolf_chat[request_id] = _PendingWolfChat( + future=future, seat_no=wolf.seat_no, + npc_id=entry.npc_id, request_id=request_id, + ) + try: + await entry.send(req.model_dump_json()) + except Exception: + log.exception( + "wolf_chat_dispatch_send_failed npc=%s seat=%d", + entry.npc_id, wolf.seat_no, + ) + self._pending_wolf_chat.pop(request_id, None) + return None + timeout_s = self.config.request_ttl_ms / 1000.0 + try: + return await asyncio.wait_for(future, timeout=timeout_s) + except TimeoutError: + log.info( + "wolf_chat_dispatch_timeout npc=%s seat=%d request=%s", + entry.npc_id, wolf.seat_no, request_id, + ) + return None + finally: + self._pending_wolf_chat.pop(request_id, None) + async def _dispatch_one_night( self, *, diff --git a/src/wolfbot/master/phase_d_state_pusher.py b/src/wolfbot/master/phase_d_state_pusher.py new file mode 100644 index 0000000..b0016ed --- /dev/null +++ b/src/wolfbot/master/phase_d_state_pusher.py @@ -0,0 +1,402 @@ +"""Phase-D state-update pusher — wired into ``GameService.advance``. + +After each transition is applied to the DB, this pusher diff's the new +state against the transition's emitted logs and ships incremental +``PrivateStateUpdate`` messages to each affected seat's NPC bot. Every +update kind the NPC bot recognises is covered: + +* ``seer_result`` / ``seer_result`` (NIGHT_0 random white) — when a + ``SEER_RESULT_NIGHT0`` / ``SEER_RESULT`` private log is emitted. +* ``medium_result`` — on every ``MEDIUM_RESULT`` private log. +* ``guard_entry`` — when a ``KNIGHT_GUARD`` night-action lands. +* ``guard_resolved`` — when MORNING resolves (peaceful or attack). +* ``alive_changed`` / ``day_advanced`` — broadcast to all live LLM + seats whenever a player dies or the day counter ticks. + +This module is the only place that needs to know which Master events +turn into which NPC state mutations, so the NPC bot stays a pure +consumer of the snapshot + update stream. + +Pure orchestration — no LLM calls, all data sourced from the repo, +seats, and the transition's logs. Best-effort end to end: a single +failed send is logged but does not block the others. +""" + +from __future__ import annotations + +import logging +import re +import time +from collections.abc import Callable, Sequence + +from wolfbot.domain.enums import Phase, Role, SubmissionType +from wolfbot.domain.models import Game, NightAction, Player, Seat +from wolfbot.domain.models import LogEntry as DomainLogEntry +from wolfbot.master.npc_registry import NpcEntry, NpcRegistry +from wolfbot.master.private_state import ( + make_alive_changed_update, + make_day_advanced_update, + make_guard_entry_update, + make_guard_resolved_update, + make_medium_result_update, + make_seer_result_update, +) +from wolfbot.persistence.sqlite_repo import SqliteRepo + +log = logging.getLogger(__name__) + + +# Regex to pull a seat number out of the canonical ``席N 名前 は ...`` +# rendering used by every state-machine private log. The state_machine +# always builds these via ``_name(seats_by_no, ...)`` which produces +# ``席N 名前`` deterministically. +_SEAT_RE = re.compile(r"席(\d+)") + + +class PhaseDStatePusher: + """Side-effect coordinator: transitions → PrivateStateUpdate fan-out. + + Hold a reference to the registry + repo and a wall-clock helper. + The single public entry point is :meth:`push_after_advance`, called + from ``GameService`` after each successful ``apply_transition``. + """ + + def __init__( + self, + *, + repo: SqliteRepo, + registry: NpcRegistry, + now_ms: Callable[[], int] = lambda: int(time.time() * 1000), + ) -> None: + self.repo = repo + self.registry = registry + self._now_ms = now_ms + + async def push_after_advance( + self, + *, + game: Game, + prev_phase: Phase, + private_logs: Sequence[DomainLogEntry], + public_logs: Sequence[DomainLogEntry], + ) -> None: + """Run all phase-D side-effects for one applied transition. + + ``game`` is the *new* game state (after ``apply_transition``). + ``private_logs`` / ``public_logs`` are the entries emitted by + the transition planner; the pusher uses them as triggers and + re-loads any structured data it needs from the repo. + """ + if game.discussion_mode != "reactive_voice": + return + try: + seats = await self.repo.load_seats(game.id) + players = await self.repo.load_players(game.id) + except Exception: + log.exception( + "phase_d_state_pusher_load_failed game=%s", game.id, + ) + return + + seats_by_no = {s.seat_no: s for s in seats} + + # 1) seer / medium private results — derive structured data and + # push the matching updates. + for entry in private_logs: + if entry.audience_seat is None: + continue + if entry.kind in ("SEER_RESULT", "SEER_RESULT_NIGHT0"): + await self._push_seer_result( + game=game, + entry=entry, + seats_by_no=seats_by_no, + players=players, + ) + elif entry.kind == "MEDIUM_RESULT": + await self._push_medium_result( + game=game, + entry=entry, + seats_by_no=seats_by_no, + players=players, + ) + + # 2) Knight guard ENTRY — pushed when night actions land. Master's + # state_machine doesn't emit a private log for the knight's own + # guard choice, so we look directly at the night_actions row. + await self._push_guard_entries( + game=game, seats_by_no=seats_by_no, players=players, + ) + + # 3) Knight guard RESOLVED — pushed when MORNING fires. + if any(e.kind == "MORNING" for e in public_logs): + await self._push_guard_resolved( + game=game, + public_logs=public_logs, + seats_by_no=seats_by_no, + players=players, + ) + + # 4) Broad updates: alive_changed (whenever someone dies) + + # day_advanced (whenever day counter ticks). We always fan these + # out because the cost is small and the NPC's prompt depends on + # them. + await self._push_alive_and_day( + game=game, prev_phase=prev_phase, players=players, seats=seats, + ) + + # --------------------------------------------------- seer + + async def _push_seer_result( + self, + *, + game: Game, + entry: DomainLogEntry, + seats_by_no: dict[int, Seat], + players: Sequence[Player], + ) -> None: + target_seat = _parse_seat_from_text(entry.text) + if target_seat is None or target_seat not in seats_by_no: + log.info( + "phase_d_seer_no_target_in_text game=%s text=%r", + game.id, entry.text[:80], + ) + return + target = next((p for p in players if p.seat_no == target_seat), None) + if target is None or target.role is None: + return + # NIGHT_0 random white is always non-wolf by definition; the + # regular SEER_RESULT respects the role detection rule (madman is + # NOT detected as wolf). + from wolfbot.domain.rules import is_detected_as_wolf + + is_wolf = ( + False + if entry.kind == "SEER_RESULT_NIGHT0" + else bool(is_detected_as_wolf(target.role)) + ) + update = make_seer_result_update( + npc_id="", # filled per-recipient below + game_id=game.id, + seat_no=entry.audience_seat or 0, + day=entry.day, + target_seat=target_seat, + target_name=seats_by_no[target_seat].display_name, + is_wolf=is_wolf, + ts=self._now_ms(), + trace_id=f"seer_result-{game.id}-d{entry.day}", + ) + await self._send_to_seat(game.id, entry.audience_seat or 0, update) + + # --------------------------------------------------- medium + + async def _push_medium_result( + self, + *, + game: Game, + entry: DomainLogEntry, + seats_by_no: dict[int, Seat], + players: Sequence[Player], + ) -> None: + target_seat = _parse_seat_from_text(entry.text) + is_wolf: bool | None = None + target_name = "(処刑なし)" + if target_seat is not None and target_seat in seats_by_no: + target = next((p for p in players if p.seat_no == target_seat), None) + target_name = seats_by_no[target_seat].display_name + if target is not None and target.role is not None: + from wolfbot.domain.rules import is_detected_as_wolf + + is_wolf = bool(is_detected_as_wolf(target.role)) + else: + # No execution → medium gets a "no result" update so its + # state mirror still has a row for that day. target_seat=0 + # is a sentinel meaning "no target"; the NPC prompt-builder + # already special-cases is_wolf=None. + target_seat = 0 + update = make_medium_result_update( + npc_id="", + game_id=game.id, + seat_no=entry.audience_seat or 0, + day=entry.day, + target_seat=target_seat, + target_name=target_name, + is_wolf=is_wolf, + ts=self._now_ms(), + trace_id=f"medium_result-{game.id}-d{entry.day}", + ) + await self._send_to_seat(game.id, entry.audience_seat or 0, update) + + # --------------------------------------------------- knight guard + + async def _push_guard_entries( + self, + *, + game: Game, + seats_by_no: dict[int, Seat], + players: Sequence[Player], + ) -> None: + knight = next( + (p for p in players if p.role is Role.KNIGHT and p.alive), None, + ) + if knight is None: + return + try: + night_actions: list[NightAction] = list( + await self.repo.load_night_actions(game.id, day=game.day_number) + ) + except Exception: + log.exception("phase_d_load_night_actions_failed game=%s", game.id) + return + guard_action = next( + (a for a in night_actions if a.kind is SubmissionType.KNIGHT_GUARD), + None, + ) + if guard_action is None or guard_action.target_seat is None: + return + target_seat = guard_action.target_seat + if target_seat not in seats_by_no: + return + update = make_guard_entry_update( + npc_id="", + game_id=game.id, + seat_no=knight.seat_no, + day=game.day_number, + target_seat=target_seat, + target_name=seats_by_no[target_seat].display_name, + ts=self._now_ms(), + trace_id=f"guard_entry-{game.id}-d{game.day_number}", + ) + await self._send_to_seat(game.id, knight.seat_no, update) + + async def _push_guard_resolved( + self, + *, + game: Game, + public_logs: Sequence[DomainLogEntry], + seats_by_no: dict[int, Seat], + players: Sequence[Player], + ) -> None: + knight = next( + (p for p in players if p.role is Role.KNIGHT and p.alive), None, + ) + if knight is None: + return + morning = next((e for e in public_logs if e.kind == "MORNING"), None) + if morning is None: + return + peaceful = "平和な朝" in morning.text + # The morning entry's day is the new day; the guard we're + # resolving was submitted under the previous day_number. + update = make_guard_resolved_update( + npc_id="", + game_id=game.id, + seat_no=knight.seat_no, + day=max(0, game.day_number - 1), + peaceful_morning=peaceful, + ts=self._now_ms(), + trace_id=f"guard_resolved-{game.id}-d{game.day_number}", + ) + await self._send_to_seat(game.id, knight.seat_no, update) + + # --------------------------------------------------- alive / day + + async def _push_alive_and_day( + self, + *, + game: Game, + prev_phase: Phase, + players: Sequence[Player], + seats: Sequence[Seat], + ) -> None: + # Fan out to every alive LLM seat that has an NPC bot online. + for player in players: + if not player.alive: + continue + seat = next((s for s in seats if s.seat_no == player.seat_no), None) + if seat is None or not seat.is_llm: + continue + entry = self._find_npc_for_seat(game.id, player.seat_no) + if entry is None or entry.send is None: + continue + alive_update = make_alive_changed_update( + npc_id=entry.npc_id, + game_id=game.id, + seat_no=player.seat_no, + players=players, + seats=seats, + ts=self._now_ms(), + trace_id=f"alive-{game.id}-d{game.day_number}-s{player.seat_no}", + ) + try: + await entry.send(alive_update.model_dump_json()) + except Exception: + log.exception( + "phase_d_alive_update_failed npc=%s seat=%d", + entry.npc_id, player.seat_no, + ) + day_update = make_day_advanced_update( + npc_id=entry.npc_id, + game_id=game.id, + seat_no=player.seat_no, + day_number=game.day_number, + ts=self._now_ms(), + trace_id=f"day-{game.id}-d{game.day_number}-s{player.seat_no}", + ) + try: + await entry.send(day_update.model_dump_json()) + except Exception: + log.exception( + "phase_d_day_update_failed npc=%s seat=%d", + entry.npc_id, player.seat_no, + ) + # `prev_phase` is currently unused but kept on the signature so + # future hooks can branch on phase transitions (e.g. emit + # `day_advanced` only on NIGHT → DAY transitions). + _ = prev_phase + + # --------------------------------------------------- send helpers + + async def _send_to_seat( + self, + game_id: str, + seat_no: int, + update_template: object, + ) -> None: + entry = self._find_npc_for_seat(game_id, seat_no) + if entry is None or entry.send is None: + return + # The factory returns a PrivateStateUpdate with npc_id="" (we + # don't know it yet at construction time); patch the field via + # model_copy so the routed message addresses this NPC. + try: + model_copy = getattr(update_template, "model_copy", None) + if model_copy is None: + return + patched = model_copy(update={"npc_id": entry.npc_id}) + payload = patched.model_dump_json() + await entry.send(payload) + except Exception: + log.exception( + "phase_d_state_update_send_failed game=%s seat=%d", + game_id, seat_no, + ) + + def _find_npc_for_seat(self, game_id: str, seat_no: int) -> NpcEntry | None: + for entry in self.registry.all_online(): + if entry.assigned_seat == seat_no and entry.game_id == game_id: + return entry + return None + + +def _parse_seat_from_text(text: str) -> int | None: + """Pull the first ``席N`` token out of a Japanese private-log text.""" + m = _SEAT_RE.search(text) + if m is None: + return None + try: + return int(m.group(1)) + except ValueError: + return None + + +__all__ = ["PhaseDStatePusher"] diff --git a/src/wolfbot/npc/client.py b/src/wolfbot/npc/client.py index c6763de..d751953 100644 --- a/src/wolfbot/npc/client.py +++ b/src/wolfbot/npc/client.py @@ -43,14 +43,19 @@ TtsFailed, TtsFinished, VoteDecision, + WolfChatRequest, + WolfChatSend, ) from wolfbot.npc.decision_service import ( _NIGHT_SCHEMA, _VOTE_SCHEMA, + _WOLF_CHAT_SCHEMA, DecisionLLM, build_night_prompt, build_vote_prompt, + build_wolf_chat_prompt, parse_decision, + parse_wolf_chat_text, ) from wolfbot.npc.game_state import NpcGameState, apply_update, state_from_snapshot from wolfbot.npc.playback import ( @@ -197,6 +202,10 @@ async def process_message(self, raw_json: str) -> None: await self._on_decide_night_action_request( DecideNightActionRequest.model_validate(payload) ) + elif t == "wolf_chat_request": + await self._on_wolf_chat_request( + WolfChatRequest.model_validate(payload) + ) else: log.info("npc_client_unhandled_type type=%s", t) @@ -565,6 +574,67 @@ async def _on_decide_night_action_request( ) await self.send(decision.model_dump_json()) + async def _on_wolf_chat_request(self, req: WolfChatRequest) -> None: + """Phase-D: wolf NPC posts a coordination line. + + Drops with empty text on missing state / non-wolf role / no + decision LLM / LLM error / parse failure. Master's broker still + runs on every WolfChatSend, so an empty text is silently + absorbed rather than persisted. + """ + if req.npc_id != self.config.npc_id: + return + text = await self._build_wolf_chat_line(req) + decision = WolfChatSend( + ts=self.now_ms(), + trace_id=req.trace_id, + npc_id=self.config.npc_id, + seat_no=req.seat_no, + game_id=req.game_id, + text=text or "", + request_id=req.request_id, + ) + await self.send(decision.model_dump_json()) + + async def _build_wolf_chat_line(self, req: WolfChatRequest) -> str | None: + from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY + + state = self.game_states.get(req.game_id) + if state is None: + log.warning( + "npc_wolf_chat_no_state game=%s seat=%d", + req.game_id, req.seat_no, + ) + return None + if state.role != "WEREWOLF": + log.info( + "npc_wolf_chat_drop_non_wolf seat=%d role=%s", + req.seat_no, state.role, + ) + return None + if self.decision_llm is None: + return None + persona = NPC_PERSONAS_BY_KEY.get(state.persona_key) + if persona is None: + return None + system, user = build_wolf_chat_prompt( + state=state, + persona=persona, + candidates=req.candidate_seats, + public_state_summary=req.public_state_summary, + ) + try: + raw = await self.decision_llm.decide_json( + system_prompt=system, user_prompt=user, schema=_WOLF_CHAT_SCHEMA, + ) + except Exception: + log.exception( + "npc_wolf_chat_llm_failed game=%s seat=%d", + req.game_id, req.seat_no, + ) + return None + return parse_wolf_chat_text(raw) + async def _decide_night_target( self, req: DecideNightActionRequest ) -> tuple[int | None, str]: diff --git a/src/wolfbot/npc/decision_service.py b/src/wolfbot/npc/decision_service.py index ff4c8ea..fe91d76 100644 --- a/src/wolfbot/npc/decision_service.py +++ b/src/wolfbot/npc/decision_service.py @@ -21,7 +21,7 @@ import json import logging -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from dataclasses import dataclass from typing import Protocol, runtime_checkable @@ -230,6 +230,82 @@ def build_vote_prompt( } +_WOLF_CHAT_SCHEMA: dict[str, object] = { + "type": "object", + "additionalProperties": False, + "required": ["text"], + "properties": { + "text": { + "type": "string", + "description": "短い coordinator メッセージ (狼仲間にだけ届く)。最大 80 文字。", + }, + }, +} + + +def build_wolf_chat_prompt( + *, + state: NpcGameState, + persona: Persona, + candidates: Sequence[tuple[int, str]], + public_state_summary: str, +) -> tuple[str, str]: + """Compose system + user prompts for a wolf-chat coordination line. + + Wolves talk to each other privately. The line must: + - propose / agree / counter on a target, + - stay under 80 chars, + - speak in the persona's voice (this is still character). + """ + persona_block = _build_persona_block(persona) + state_block = _build_state_block(state) + candidates_str = ( + "、".join(f"席{seat_no} {name}" for seat_no, name in candidates) + or "(なし)" + ) + digest = public_state_summary or "(情報なし)" + system = ( + "あなたは人狼ゲームの 1 プレイヤーです。" + "あなたは人狼で、仲間の人狼にだけ届く秘密チャットでこのターンの " + "襲撃方針を簡潔に伝えてください。村人に届く発話ではないので、" + "ペルソナの口調を保ちつつ素直に作戦を提示してよい (ただし" + "メタ用語は避ける)。返答は JSON のみ。" + ) + user_parts = [ + f"## 現在: 人狼チャット (day {state.day_number})", + "", + persona_block, + "", + "## 自分の状況 (非公開)", + state_block, + "", + "## 場の状況 (Master ダイジェスト)", + digest, + "", + f"## 襲撃候補席\n{candidates_str}", + "", + "上記を踏まえ、仲間の狼に向けて 80 文字以内で 1 行だけ書いてください。" + "JSON は {\"text\": \"...\"} の形。", + ] + return system, "\n".join(p for p in user_parts if p is not None) + + +def parse_wolf_chat_text(raw_json: str) -> str | None: + """Pull the ``text`` field out of the JSON response, with empty / non-string + payloads dropped to None so the dispatcher records a no-line outcome.""" + try: + data = json.loads(raw_json) + except json.JSONDecodeError: + return None + if not isinstance(data, dict): + return None + raw = data.get("text") + if not isinstance(raw, str): + return None + cleaned = raw.strip() + return cleaned or None + + def build_night_prompt( *, state: NpcGameState, @@ -303,9 +379,12 @@ def parse_decision( __all__ = [ "_NIGHT_SCHEMA", "_VOTE_SCHEMA", + "_WOLF_CHAT_SCHEMA", "DecisionLLM", "DecisionResult", "build_night_prompt", "build_vote_prompt", + "build_wolf_chat_prompt", "parse_decision", + "parse_wolf_chat_text", ] diff --git a/src/wolfbot/services/game_service.py b/src/wolfbot/services/game_service.py index 6a8ffcd..05ecadf 100644 --- a/src/wolfbot/services/game_service.py +++ b/src/wolfbot/services/game_service.py @@ -146,6 +146,7 @@ def __init__( on_reactive_phase_enter: Callable[[str], Awaitable[None]] | None = None, on_reactive_game_end: Callable[[str], Awaitable[None]] | None = None, on_game_end_finalize: Callable[[str], Awaitable[None]] | None = None, + phase_d_state_pusher: object | None = None, ) -> None: self.repo = repo self.discord = discord @@ -164,6 +165,18 @@ def __init__( # final ended_at state. Used to trigger the viewer JSON export # without blocking gameplay — exceptions are logged + swallowed. self._on_game_end_finalize = on_game_end_finalize + # Phase-D: optional state pusher that fans out PrivateStateUpdate + # messages to NPC bots after each apply_transition. Constructed + # in main.py only when reactive_voice is enabled; rounds-mode + # never sets it so the existing path is byte-for-byte unchanged. + self._phase_d_state_pusher = phase_d_state_pusher + + def set_phase_d_state_pusher(self, pusher: object) -> None: + """Late-bind the Phase-D pusher. main.py constructs the pusher + only after the NPC registry exists (inside the reactive_voice + wiring block), which lands after GameService itself is built; + this setter breaks the order dependency.""" + self._phase_d_state_pusher = pusher def _lock_for(self, game_id: str) -> asyncio.Lock: lock = self._advance_locks.get(game_id) @@ -251,6 +264,25 @@ async def _advance_once(self, game_id: str) -> None: else: await self._safe_post_public(new_game, entry.text, entry.kind) + # Phase-D: fan out PrivateStateUpdate messages to NPC bots so each + # seat's in-memory mirror absorbs the seer/medium/guard results, + # alive-set changes, and day_advanced ticks before the next + # decision request fires. No-op for rounds-mode games (the pusher + # short-circuits on `discussion_mode != "reactive_voice"`). + pusher = self._phase_d_state_pusher + if pusher is not None: + push_after_advance = getattr(pusher, "push_after_advance", None) + if push_after_advance is not None: + try: + await push_after_advance( + game=new_game, + prev_phase=previous_phase, + private_logs=transition.private_logs, + public_logs=transition.public_logs, + ) + except Exception: + log.exception("phase_d_state_push_failed game=%s", game_id) + # 4. Announce WAITING status if we paused. if transition.requires_host_decision and transition.pending is not None: try: diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index 7d67f8a..92e56f0 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -683,6 +683,32 @@ async def _run_night_actions_via_npc_dispatcher( continue buckets[action_label].append((player, kind, list(legal))) dispatcher = self._npc_decision_dispatcher + # Phase-D pre-night wolf-chat coordination. Mirrors rounds-mode + # `_run_wolf_chat`: each alive wolf NPC gets a turn (sequential) + # to post a coordination line BEFORE the wolf_attack bucket + # fans out, so wolf B sees wolf A's proposed target via the + # wolf_chat_history the broker already pushed. + wolf_attack_items = buckets.get("wolf_attack", []) + if len(wolf_attack_items) >= 2: + wolves = [p for p, _kind, _legal in wolf_attack_items] + attack_legal: set[int] = set() + for _p, _kind, legal in wolf_attack_items: + attack_legal.update(legal) + try: + await dispatcher.dispatch_wolf_chat_lines( # type: ignore[union-attr] + game_id=game.id, + day=game.day_number, + wolves=wolves, + seats=seats, + candidate_seats=sorted(attack_legal), + public_state_summary=await self._build_public_digest(game, seats), + ) + except Exception: + log.exception( + "phase_d_wolf_chat_dispatch_failed game=%s day=%d", + game.id, game.day_number, + ) + for action_label, items in buckets.items(): if not items: continue diff --git a/tests/test_master_decision_dispatcher.py b/tests/test_master_decision_dispatcher.py index ad618d3..d920603 100644 --- a/tests/test_master_decision_dispatcher.py +++ b/tests/test_master_decision_dispatcher.py @@ -252,6 +252,101 @@ async def _drive() -> dict[int, int | None]: assert results == {3: 1} +async def test_dispatch_wolf_chat_lines_resolves_sequentially() -> None: + """`dispatch_wolf_chat_lines` runs wolves sequentially so each + WolfChatRequest awaits the previous wolf's broker fan-out before the + next wolf is asked.""" + from wolfbot.domain.ws_messages import WolfChatRequest, WolfChatSend + + registry = InMemoryNpcRegistry() + seat2_buf: list[str] = [] + seat3_buf: list[str] = [] + registry.register( + npc_id="npc_w2", discord_bot_user_id="bw2", + supported_voices=(), version="1", + send=_capture_send(seat2_buf), now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_w2", seat=2, game_id="g1", phase_id="g1::day1::NIGHT::1") + registry.register( + npc_id="npc_w3", discord_bot_user_id="bw3", + supported_voices=(), version="1", + send=_capture_send(seat3_buf), now_ms=1000, persona_key="gina", + ) + registry.assign("npc_w3", seat=3, game_id="g1", phase_id="g1::day1::NIGHT::1") + + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig(request_ttl_ms=5_000), + now_ms=lambda: 1_000, + ) + wolves = [ + Player(seat_no=2, role=Role.WEREWOLF, alive=True), + Player(seat_no=3, role=Role.WEREWOLF, alive=True), + ] + + async def _drive() -> dict[int, str | None]: + return await dispatcher.dispatch_wolf_chat_lines( + game_id="g1", day=1, wolves=wolves, seats=_seats(), + candidate_seats=[1], + ) + + task = asyncio.create_task(_drive()) + # Wait for wolf 2's request (lower seat goes first because of sort). + for _ in range(50): + if seat2_buf: + break + await asyncio.sleep(0.01) + assert len(seat2_buf) == 1 + assert seat3_buf == [] # wolf 3 hasn't been asked yet + + sent2 = WolfChatRequest.model_validate_json(seat2_buf[0]) + await dispatcher.on_wolf_chat_send( + WolfChatSend( + ts=2_000, trace_id=sent2.trace_id, request_id=sent2.request_id, + npc_id="npc_w2", seat_no=2, game_id="g1", + text="席1を狙う", + ) + ) + # Now wolf 3 should be asked. + for _ in range(50): + if seat3_buf: + break + await asyncio.sleep(0.01) + assert len(seat3_buf) == 1 + sent3 = WolfChatRequest.model_validate_json(seat3_buf[0]) + await dispatcher.on_wolf_chat_send( + WolfChatSend( + ts=3_000, trace_id=sent3.trace_id, request_id=sent3.request_id, + npc_id="npc_w3", seat_no=3, game_id="g1", + text="同意", + ) + ) + results = await task + assert results == {2: "席1を狙う", 3: "同意"} + + +async def test_dispatch_wolf_chat_lines_timeout_yields_none() -> None: + registry = InMemoryNpcRegistry() + seat2_buf: list[str] = [] + registry.register( + npc_id="npc_w2", discord_bot_user_id="bw2", + supported_voices=(), version="1", + send=_capture_send(seat2_buf), now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_w2", seat=2, game_id="g1", phase_id="g1::day1::NIGHT::1") + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig(request_ttl_ms=100), + now_ms=lambda: 0, + ) + wolves = [Player(seat_no=2, role=Role.WEREWOLF, alive=True)] + results = await dispatcher.dispatch_wolf_chat_lines( + game_id="g1", day=1, wolves=wolves, seats=_seats(), + candidate_seats=[1], + ) + assert results == {2: None} + + async def test_decide_vote_request_payload_shape() -> None: """The wire payload carries the seat / round / candidate pairs and a deadline so the NPC bot can build its prompt without a Master DB hit.""" diff --git a/tests/test_master_phase_d_state_pusher.py b/tests/test_master_phase_d_state_pusher.py new file mode 100644 index 0000000..57325ad --- /dev/null +++ b/tests/test_master_phase_d_state_pusher.py @@ -0,0 +1,252 @@ +"""Phase-D state pusher — derives PrivateStateUpdate fan-outs from the +state-machine's transition logs and sends them to NPC bots.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable + +import pytest + +from wolfbot.domain.enums import Phase, Role, SubmissionType +from wolfbot.domain.models import ( + Game, + LogEntry, + NightAction, + Seat, +) +from wolfbot.master.npc_registry import InMemoryNpcRegistry +from wolfbot.master.phase_d_state_pusher import PhaseDStatePusher +from wolfbot.persistence.sqlite_repo import SqliteRepo + + +def _capture_send(buf: list[str]) -> Callable[[str], Awaitable[None]]: + async def _send(msg: str) -> None: + buf.append(msg) + + return _send + + +@pytest.fixture +async def fxt(repo: SqliteRepo) -> tuple[Game, list[Seat], InMemoryNpcRegistry, dict[int, list[str]]]: + game = Game( + id="g_pusher", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + wolves_channel_id="cw", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(game) + seats = [ + Seat(seat_no=1, display_name="Alice", is_llm=True, persona_key="setsu", + discord_user_id=None), + Seat(seat_no=2, display_name="Bob", is_llm=True, persona_key="gina", + discord_user_id=None), + Seat(seat_no=3, display_name="Carol", is_llm=True, persona_key="jonas", + discord_user_id=None), + ] + for s in seats: + await repo.insert_seat(game.id, s) + await repo.set_player_role(game.id, 1, Role.SEER) + await repo.set_player_role(game.id, 2, Role.WEREWOLF) + await repo.set_player_role(game.id, 3, Role.KNIGHT) + + registry = InMemoryNpcRegistry() + bufs: dict[int, list[str]] = {1: [], 2: [], 3: []} + for npc_id, persona, seat in ( + ("npc_alice", "setsu", 1), + ("npc_bob", "gina", 2), + ("npc_carol", "jonas", 3), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=f"bot{seat}", + supported_voices=(), version="1", + send=_capture_send(bufs[seat]), now_ms=1000, persona_key=persona, + ) + registry.assign( + npc_id, seat=seat, game_id=game.id, + phase_id="g_pusher::day1::DAY_DISCUSSION::1", + ) + return game, seats, registry, bufs + + +def _kinds_in(buf: list[str]) -> list[str]: + out: list[str] = [] + for raw in buf: + try: + d = json.loads(raw) + except json.JSONDecodeError: + continue + if d.get("type") == "private_state_update": + out.append(d["update_kind"]) + return out + + +def _payload_of(buf: list[str], kind: str) -> dict[str, object]: + for raw in buf: + d = json.loads(raw) + if d.get("type") == "private_state_update" and d["update_kind"] == kind: + return d["payload"] # type: ignore[no-any-return] + raise AssertionError(f"no {kind} update found in buf") + + +async def test_pusher_skips_for_rounds_mode( + repo: SqliteRepo, + fxt: tuple[Game, list[Seat], InMemoryNpcRegistry, dict[int, list[str]]], +) -> None: + """Rounds-mode games never see the Phase-D fan-out.""" + game, _seats, registry, bufs = fxt + # Force this game out of reactive_voice. + async with repo._db.execute( # type: ignore[attr-defined] + "UPDATE games SET discussion_mode='rounds' WHERE id=?", (game.id,), + ): + pass + await repo._db.commit() # type: ignore[attr-defined] + new_game = await repo.load_game(game.id) + assert new_game is not None and new_game.discussion_mode == "rounds" + + pusher = PhaseDStatePusher(repo=repo, registry=registry, now_ms=lambda: 5000) + await pusher.push_after_advance( + game=new_game, prev_phase=Phase.NIGHT, + private_logs=[], public_logs=[], + ) + for buf in bufs.values(): + assert buf == [] # nothing pushed + + +async def test_pusher_emits_seer_result_with_is_wolf( + repo: SqliteRepo, + fxt: tuple[Game, list[Seat], InMemoryNpcRegistry, dict[int, list[str]]], +) -> None: + game, _seats, registry, bufs = fxt + pusher = PhaseDStatePusher(repo=repo, registry=registry, now_ms=lambda: 5000) + seer_log = LogEntry( + game_id=game.id, day=1, phase=Phase.NIGHT, kind="SEER_RESULT", + actor_seat=None, audience_seat=1, visibility="PRIVATE", + text="占い結果: 席2 Bob は 人狼 です。", created_at=1, + ) + await pusher.push_after_advance( + game=game, prev_phase=Phase.NIGHT, + private_logs=[seer_log], public_logs=[], + ) + payload = _payload_of(bufs[1], "seer_result") + assert payload["target_seat"] == 2 + assert payload["target_name"] == "Bob" + assert payload["is_wolf"] is True + # Other wolves don't see the seer's private result. + assert "seer_result" not in _kinds_in(bufs[2]) + + +async def test_pusher_emits_seer_result_night0_marks_white( + repo: SqliteRepo, + fxt: tuple[Game, list[Seat], InMemoryNpcRegistry, dict[int, list[str]]], +) -> None: + """SEER_RESULT_NIGHT0 is always white by spec; the pusher overrides + the role lookup so a future ruleset change can't accidentally flip it.""" + game, _seats, registry, bufs = fxt + pusher = PhaseDStatePusher(repo=repo, registry=registry, now_ms=lambda: 5000) + night0_log = LogEntry( + game_id=game.id, day=0, phase=Phase.NIGHT_0, kind="SEER_RESULT_NIGHT0", + actor_seat=None, audience_seat=1, visibility="PRIVATE", + text="初日ランダム白: 席2 Bob は 人狼ではありません。", created_at=1, + ) + await pusher.push_after_advance( + game=game, prev_phase=Phase.NIGHT_0, + private_logs=[night0_log], public_logs=[], + ) + payload = _payload_of(bufs[1], "seer_result") + assert payload["target_seat"] == 2 + assert payload["is_wolf"] is False # NIGHT_0 result is forced white + + +async def test_pusher_emits_medium_result_with_no_execution( + repo: SqliteRepo, + fxt: tuple[Game, list[Seat], InMemoryNpcRegistry, dict[int, list[str]]], +) -> None: + game, _seats, registry, bufs = fxt + pusher = PhaseDStatePusher(repo=repo, registry=registry, now_ms=lambda: 5000) + medium_log = LogEntry( + game_id=game.id, day=1, phase=Phase.NIGHT, kind="MEDIUM_RESULT", + actor_seat=None, audience_seat=2, visibility="PRIVATE", + text="本日の霊媒結果はありません(処刑なし)。", created_at=1, + ) + await pusher.push_after_advance( + game=game, prev_phase=Phase.NIGHT, + private_logs=[medium_log], public_logs=[], + ) + payload = _payload_of(bufs[2], "medium_result") + assert payload["is_wolf"] is None + assert payload["target_seat"] == 0 + + +async def test_pusher_emits_guard_entry_when_knight_submitted( + repo: SqliteRepo, + fxt: tuple[Game, list[Seat], InMemoryNpcRegistry, dict[int, list[str]]], +) -> None: + game, _seats, registry, bufs = fxt + # Knight (seat 3) submitted a guard for seat 2 this night. + await repo.insert_night_action( + NightAction( + game_id=game.id, day=1, actor_seat=3, + kind=SubmissionType.KNIGHT_GUARD, target_seat=2, submitted_at=1, + ) + ) + pusher = PhaseDStatePusher(repo=repo, registry=registry, now_ms=lambda: 5000) + await pusher.push_after_advance( + game=game, prev_phase=Phase.NIGHT, + private_logs=[], public_logs=[], + ) + payload = _payload_of(bufs[3], "guard_entry") + assert payload["target_seat"] == 2 + assert payload["target_name"] == "Bob" + # Only the knight gets the guard entry. + assert "guard_entry" not in _kinds_in(bufs[1]) + assert "guard_entry" not in _kinds_in(bufs[2]) + + +async def test_pusher_emits_guard_resolved_on_morning( + repo: SqliteRepo, + fxt: tuple[Game, list[Seat], InMemoryNpcRegistry, dict[int, list[str]]], +) -> None: + game, _seats, registry, bufs = fxt + pusher = PhaseDStatePusher(repo=repo, registry=registry, now_ms=lambda: 5000) + # Day 2 morning, peaceful → guard worked. + morning_log = LogEntry( + game_id=game.id, day=2, phase=Phase.DAY_DISCUSSION, kind="MORNING", + actor_seat=None, visibility="PUBLIC", + text="平和な朝です。昨晩の犠牲者はいません。", created_at=1, + ) + new_game = game.model_copy(update={"day_number": 2}) + await pusher.push_after_advance( + game=new_game, prev_phase=Phase.NIGHT, + private_logs=[], public_logs=[morning_log], + ) + payload = _payload_of(bufs[3], "guard_resolved") + assert payload["peaceful_morning"] is True + assert payload["day"] == 1 # the guard was submitted on day 1 + + +async def test_pusher_fans_alive_changed_and_day_advanced_to_all_alive_llms( + repo: SqliteRepo, + fxt: tuple[Game, list[Seat], InMemoryNpcRegistry, dict[int, list[str]]], +) -> None: + game, _seats, registry, bufs = fxt + pusher = PhaseDStatePusher(repo=repo, registry=registry, now_ms=lambda: 5000) + new_game = game.model_copy(update={"day_number": 2}) + await pusher.push_after_advance( + game=new_game, prev_phase=Phase.NIGHT, + private_logs=[], public_logs=[], + ) + # Every alive LLM seat got both updates. + for seat in (1, 2, 3): + kinds = _kinds_in(bufs[seat]) + assert "alive_changed" in kinds + assert "day_advanced" in kinds + # day_advanced payload carries the new day number. + p = _payload_of(bufs[1], "day_advanced") + assert p["day_number"] == 2 From 70e60613b0a5b182d29757268109fafb9f199601 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 01:58:58 +0900 Subject: [PATCH 048/133] feat(voice): DAVE E2EE inner decrypt + voicetest harness + pre-STT silence gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recording on this guild's VCs went silent / hallucinatory after Discord rolled out mandatory voice E2EE (DAVE). discord-ext-voice-recv 0.5.2a179 only decrypts the outer AEAD layer; the MLS-encrypted opus inside hits the decoder as garbage and the resilience patch only stops the RX thread from dying — every packet still drops. Symptom dump: ``logs/audio_debug/e5660c02f79a/sakura/seg_*.wav`` was mostly silence with the canonical Whisper hallucination "ご視聴ありがとうございました". Disabling DAVE on the client side is not viable: Discord rejects ``max_dave_protocol_version=0`` with voice WS close 4017 on this channel. Layer DAVE inner decrypt on top of voice_recv ourselves. discord.py 2.7.1 already drives the MLS state machine via opcodes 25-31 and exposes ``voice_client._connection.dave_session`` plus ``_ssrc_to_id``; ``davey.DaveSession.decrypt(user_id, audio, packet)`` is the missing receive-side primitive. ``AudioReader.__init__`` is monkey-patched to wrap ``decryptor.decrypt_rtp`` so AEAD-decrypted bytes pass through ``DaveSession.decrypt`` before reaching the opus decoder, with passthrough-mode and missing-ssrc fallbacks. Same approach receive-side recorders in other ecosystems (Craig on Eris) take. Add a standalone ``wolfbot-voicetest`` entry point so the same recording path can be exercised in isolation: joins one VC, runs the production ``WolfbotAudioSink`` → ``VoiceIngestService`` pipeline, dumps WAV+txt sidecars with a wall-clock-prefixed filename, and toggles between a no-op STT (audio-only validation) and the real production STT stack (auto-loads ``.env.master`` so ``VOICE_STT_PROVIDER``/``VOICE_LLM_API_KEY``/``GROQ_STT_API_KEY`` are reused without duplication). A separate dump-level RMS+duration gate filters out segments whose buffer is silence/noise so the debug tree only contains real utterances. Promote the silence gate idea to production as a pre-STT filter in ``VoiceIngestService``: Discord's speaking-start fires on breathing/keyboard/hum and ``SilenceGeneratorSink`` keeps each such burst open, so without this gate every non-speech segment burns one Groq Whisper + one xAI analyzer call. Adds ``VOICE_PRE_STT_MIN_RMS=200`` and ``VOICE_PRE_STT_MIN_DURATION_MS=300`` to ``MasterSettings``, defaults disabled in ``VoiceIngestConfig`` to preserve existing test behavior, opted in via ``main.py``. Emits ``stt_failed reason=pre_stt_silence_gate`` so the arbiter still finalises the segment. --- .gitignore | 2 + envs/voicetest/.env.voicetest.example | 40 ++ pyproject.toml | 1 + src/wolfbot/config.py | 14 + src/wolfbot/main.py | 19 +- src/wolfbot/master/voice_ingest_service.py | 92 ++++ src/wolfbot/master/voice_recv_dave_patch.py | 152 ++++++ src/wolfbot/voicetest/__init__.py | 8 + src/wolfbot/voicetest/main.py | 496 ++++++++++++++++++++ tests/test_voice_ingest_service.py | 91 ++++ 10 files changed, 914 insertions(+), 1 deletion(-) create mode 100644 envs/voicetest/.env.voicetest.example create mode 100644 src/wolfbot/master/voice_recv_dave_patch.py create mode 100644 src/wolfbot/voicetest/__init__.py create mode 100644 src/wolfbot/voicetest/main.py diff --git a/.gitignore b/.gitignore index 5bb034c..c7b70f6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ build/ # (generated by scripts/generate_npc_envs.py) stay out of git. envs/npc/.env.* !envs/npc/.env.npc.example +envs/voicetest/.env.* +!envs/voicetest/.env.voicetest.example # Local secrets used by the generator tokens.txt *.db diff --git a/envs/voicetest/.env.voicetest.example b/envs/voicetest/.env.voicetest.example new file mode 100644 index 0000000..c53e799 --- /dev/null +++ b/envs/voicetest/.env.voicetest.example @@ -0,0 +1,40 @@ +# Voice-test bot configuration. Copy to envs/voicetest/.env.voicetest +# and run with: +# WOLFBOT_VOICETEST_ENV=envs/voicetest/.env.voicetest uv run wolfbot-voicetest + +# Discord bot token (separate Discord application from Master / NPCs). +VOICETEST_DISCORD_TOKEN= + +# Guild + voice channel to join. Use the same VC the real game runs in +# so the recorded path matches production network conditions. +VOICETEST_GUILD_ID= +VOICETEST_VOICE_CHANNEL_ID= + +# Where to drop seg_*.wav + seg_*.txt sidecars. Same env var the +# production master reads, so an existing audio_debug/ tree continues +# to work — files end up under {dir}/voicetest/{speaker_name}/. +WOLFBOT_VOICE_DEBUG_DIR=./logs/audio_debug + +# Toggle the SilenceGeneratorSink wrap. Set to "true" to reproduce the +# current production behaviour, "false" to record raw real-frames-only +# audio for an A/B comparison. +VOICETEST_USE_SILENCE_GENERATOR=true + +# Drop segments that look like silence/background rather than real +# speech. Tuned from observed RMS samples — pure noise sits at 0-100, +# faint/distant speech around 200-500, normal talking 1000+. Adjust +# based on the voicetest_dump_skip / voicetest_dump_keep log lines +# for your mic + room. +VOICETEST_MIN_RMS=200 +VOICETEST_MIN_DURATION_MS=300 + +# Toggle real STT. When ``false`` (default), the recording path is +# tested in isolation - the .txt sidecar shows ``transcript: `` +# but the WAV is still dumped. When ``true``, kept segments are sent +# through the same STT stack the production master uses (selected by +# VOICE_STT_PROVIDER=gemini|groq in .env.master) - no API keys need +# to be set here, voicetest auto-loads them from .env.master at +# startup with the voicetest env file overriding on top. +VOICETEST_USE_STT=false + +LOG_LEVEL=INFO diff --git a/pyproject.toml b/pyproject.toml index c412ddd..bfe640d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ [project.scripts] wolfbot = "wolfbot.main:cli" wolfbot-npc = "wolfbot.npc.main:main" +wolfbot-voicetest = "wolfbot.voicetest.main:run" [build-system] requires = ["hatchling"] diff --git a/src/wolfbot/config.py b/src/wolfbot/config.py index 4912013..02e86d6 100644 --- a/src/wolfbot/config.py +++ b/src/wolfbot/config.py @@ -107,6 +107,20 @@ class MasterSettings(BaseSettings): GROQ_STT_MODEL: str = "whisper-large-v3-turbo" GROQ_STT_BASE_URL: str = "https://api.groq.com/openai/v1" + # ── Pre-STT silence gate ────────────────────────────────────────── + # Discord's speaking-start fires on any audio above a low threshold + # (breathing, keyboard, room hum). With ``SilenceGeneratorSink`` + # padding, this would burn one Groq + one xAI analyzer call for + # every such non-speech burst. The gate suppresses the STT call + # (and emits ``stt_failed reason=pre_stt_silence_gate`` so the + # arbiter still finalises the segment) when the buffer is too + # short or too quiet to plausibly contain speech. Tuned from + # voicetest measurements: pure noise sits at RMS 0-100, faint / + # distant speech 200-500, normal talking 1000+. + # Set ``VOICE_PRE_STT_MIN_RMS=0`` to disable the gate entirely. + VOICE_PRE_STT_MIN_RMS: int = 200 + VOICE_PRE_STT_MIN_DURATION_MS: int = 300 + # ── Master TTS narration (reactive_voice only) ──────────────────── # When `LLM_DISCUSSION_MODE=reactive_voice` and Master is in VC, # phase-transition announcements (PHASE_CHANGE / MORNING / VICTORY diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 1f77990..4882a08 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -150,6 +150,9 @@ async def _master_join_vc_for_game(game: Any) -> None: from discord.ext import voice_recv from wolfbot.master.audio_sink import WolfbotAudioSink + from wolfbot.master.voice_recv_dave_patch import ( + apply_dave_decrypt_patch, + ) from wolfbot.master.voice_recv_resilience import ( apply_packet_router_resilience, ) @@ -159,6 +162,13 @@ async def _master_join_vc_for_game(game: Any) -> None: # silences the entire reactive_voice pipeline for the rest of # the game (no STT, no NPC dispatch). apply_packet_router_resilience() + # Layer DAVE (E2EE voice) inner decrypt on top of voice_recv's + # outer AEAD. Without this, channels with E2EE enabled deliver + # MLS-encrypted opus that the decoder can't read, manifesting + # as a flood of `corrupted stream` warnings and zero usable + # audio (the ``e5660c02f79a`` debug dumps before this patch + # were the canonical symptom). See voice_recv_dave_patch.py. + apply_dave_decrypt_patch() try: channel_id = int(game.main_vc_channel_id) @@ -1017,7 +1027,10 @@ async def _on_wolf_chat_send(msg: Any, _ctx: Any) -> None: GroqWhisperAudioAnalyzer, ) from wolfbot.master.voice_ingest_client import DirectMasterIngestionClient - from wolfbot.master.voice_ingest_service import VoiceIngestService + from wolfbot.master.voice_ingest_service import ( + VoiceIngestConfig, + VoiceIngestService, + ) # Direct callbacks (no WS ctx needed) async def _direct_vad_started(msg: Any) -> None: @@ -1096,6 +1109,10 @@ def _phase_lookup() -> tuple[str, str] | None: stt=voice_llm, seat_lookup=_seat_lookup, phase_lookup=_phase_lookup, + config=VoiceIngestConfig( + pre_stt_min_rms=settings.VOICE_PRE_STT_MIN_RMS, + pre_stt_min_duration_ms=settings.VOICE_PRE_STT_MIN_DURATION_MS, + ), ) if settings.VOICE_STT_PROVIDER == "groq": log.info( diff --git a/src/wolfbot/master/voice_ingest_service.py b/src/wolfbot/master/voice_ingest_service.py index e8ce1e1..db97240 100644 --- a/src/wolfbot/master/voice_ingest_service.py +++ b/src/wolfbot/master/voice_ingest_service.py @@ -65,6 +65,20 @@ class VoiceIngestConfig: pcm_sample_rate: int = 48_000 pcm_channels: int = 2 pcm_sample_width: int = 2 + # Pre-STT silence gate. Discord's speaking-start fires on any audio + # above a low threshold (breathing, keyboard, room hum) and the + # SilenceGeneratorSink keeps the segment open; without this gate + # every such non-speech burst hits the STT API. Both thresholds + # default to 0 (disabled) so existing tests / callers see the same + # behavior; production wiring opts in via MasterSettings. + # ``pre_stt_min_rms`` is a 16-bit signed RMS computed across the + # full segment buffer - speech is typically 1000-5000, breathing + # / typing 100-500, true silence 0-100. + pre_stt_min_rms: int = 0 + # ``pre_stt_min_duration_ms`` rejects sub-syllable bursts triggered + # by the silence-padding playback or single mouse clicks. Real + # utterances are ≥300ms. + pre_stt_min_duration_ms: int = 0 @dataclass @@ -95,6 +109,25 @@ def _now_ms() -> int: return int(time.time() * 1000) +def _pcm_rms_s16le(pcm: bytes) -> int: + """Root-mean-square magnitude across all 16-bit signed LE samples. + + Returns 0 for empty / odd-length buffers. ~20ms for a 2-second + 48kHz stereo segment in pure Python — negligible vs the STT + network round-trip we're potentially saving. + """ + n = len(pcm) // 2 + if n == 0: + return 0 + import struct + + samples = struct.unpack(f"<{n}h", pcm[: n * 2]) + sq = 0 + for s in samples: + sq += s * s + return int((sq / n) ** 0.5) + + class VoiceIngestService: """Single-process voice-ingest orchestrator. @@ -127,6 +160,10 @@ def __init__( self.dropped_npc_packets = 0 self.stt_low_confidence_count = 0 self.stt_provider_error_count = 0 + # Pre-STT silence gate — counts segments suppressed before + # the STT call so an operator can spot a misconfigured + # threshold (e.g. silenced everything → no Whisper traffic). + self.pre_stt_silence_gated_count = 0 # ---------------------------------------------------------- packet boundary @@ -294,6 +331,61 @@ def _build_dump( failure_reason=failure_reason, ) + # Pre-STT silence gate. Skip the network round-trip when the + # buffer is too short or too quiet to plausibly contain + # speech — Discord's speaking-start fires on breathing / + # keyboard / hum, and without this every such non-speech + # burst would burn one Groq + one xAI analyzer call. + if ( + self.config.pre_stt_min_rms > 0 + or self.config.pre_stt_min_duration_ms > 0 + ): + bytes_per_ms = ( + self.config.pcm_sample_rate + * self.config.pcm_channels + * self.config.pcm_sample_width + // 1000 + ) + duration_ms = ( + len(pcm_snapshot) // bytes_per_ms if bytes_per_ms else 0 + ) + need_rms_check = self.config.pre_stt_min_rms > 0 + rms = _pcm_rms_s16le(pcm_snapshot) if need_rms_check else 0 + too_short = duration_ms < self.config.pre_stt_min_duration_ms + too_quiet = need_rms_check and rms < self.config.pre_stt_min_rms + if too_short or too_quiet: + self.pre_stt_silence_gated_count += 1 + log.info( + "stt_pre_silence_gated game=%s segment=%s " + "duration_ms=%d rms=%d reason=%s", + game_id, + seg.segment_id, + duration_ms, + rms, + "too_short" if too_short else "below_rms", + ) + if dump_enabled: + await dump_segment( + _build_dump( + result=None, + failure_reason="pre_stt_silence_gate", + ), + pcm_snapshot, + ) + await self.master_client.send_stt_failed( + SttFailed( + ts=self._now_ms(), + trace_id=f"vi-{seg.segment_id}", + game_id=game_id, + phase_id=phase_id, + speaker_discord_user_id=seg.speaker_user_id, + seat_no=seg.seat_no, + segment_id=seg.segment_id, + failure_reason="pre_stt_silence_gate", + ) + ) + return + try: result: SttResult = await self.stt.transcribe( audio=pcm_snapshot, diff --git a/src/wolfbot/master/voice_recv_dave_patch.py b/src/wolfbot/master/voice_recv_dave_patch.py new file mode 100644 index 0000000..30d7ffc --- /dev/null +++ b/src/wolfbot/master/voice_recv_dave_patch.py @@ -0,0 +1,152 @@ +"""Add DAVE (E2EE voice) inner-decrypt to ``discord-ext-voice-recv``. + +discord.py 2.7.1 negotiates DAVE per voice connection and drives the +MLS state machine (``DaveSession``) via the voice gateway opcodes +25-31. discord-ext-voice-recv 0.5.2a179 only handles the *outer* AEAD +layer - when DAVE is active, the AEAD-decrypted bytes are still the +MLS-encrypted opus payload. The opus decoder then sees ciphertext and +fails with ``OpusError("corrupted stream")`` for every packet, which +shows up as a flood of ``voice_recv_opus_decode_skip`` warnings (the +:func:`apply_packet_router_resilience` patch keeps the RX thread +alive but cannot recover the audio). + +Discord rolled DAVE out as **mandatory** on many channels, so the +client cannot opt out by advertising ``max_dave_protocol_version=0`` +(the voice gateway responds with close code 4017 and won't accept +the connection). The only way to keep recording is to layer the +DAVE decrypt step on top of voice_recv's pipeline ourselves. + +The implementation mirrors what receive-side recorders in other +ecosystems do (e.g. Craig on Eris): keep the upstream AEAD outer +decrypt as-is, then call into the active ``DaveSession`` to peel +off the MLS inner layer when the speaker is not in passthrough. + +This module monkey-patches :class:`discord.ext.voice_recv.reader.AudioReader` +so that, immediately after the upstream AEAD decrypt returns, an +extra :meth:`davey.DaveSession.decrypt` call replaces the buffer with +the inner-decrypted opus. We use the ``davey`` Python bindings that +discord.py already depends on, and the per-voice-client ``DaveSession`` +that discord.py keeps in ``voice_client._connection.dave_session`` — +no extra protocol implementation needed on our side. + +Apply via :func:`apply_dave_decrypt_patch` once at startup. Idempotent. +""" + +from __future__ import annotations + +import logging + +log = logging.getLogger(__name__) + +_PATCH_MARKER = "_wolfbot_dave_decrypt_patched" + + +def apply_dave_decrypt_patch() -> None: + """Wrap ``AudioReader.decryptor.decrypt_rtp`` to add DAVE inner decrypt. + + Idempotent. Logs a single info line on first apply so an operator + can confirm the patch fired before the bot connects to voice. + """ + try: + import davey + except ImportError: + log.info( + "voice_recv_dave_patch_skipped reason=davey_not_installed" + ) + return + + from discord.ext.voice_recv.reader import AudioReader + + if getattr(AudioReader, _PATCH_MARKER, False): + return + + original_init = AudioReader.__init__ + + def patched_init(self, sink, voice_client, *args, **kwargs): # type: ignore[no-untyped-def] + original_init(self, sink, voice_client, *args, **kwargs) + _attach_dave_wrapper(self, voice_client) + + original_update_secret_key = AudioReader.update_secret_key + + def patched_update_secret_key(self, secret_key): # type: ignore[no-untyped-def] + # update_secret_key rebuilds the AEAD ``box`` on the upstream + # decryptor but doesn't rebind ``decrypt_rtp``. Our wrapper + # closes over the original bound method, so it survives the + # key rotation - but we still re-attach defensively in case + # upstream ever rebuilds the bound method too. + original_update_secret_key(self, secret_key) + _attach_dave_wrapper(self, self.voice_client) + + AudioReader.__init__ = patched_init # type: ignore[method-assign] + AudioReader.update_secret_key = patched_update_secret_key # type: ignore[method-assign] + setattr(AudioReader, _PATCH_MARKER, True) + log.info( + "voice_recv_dave_decrypt_patch_applied protocol_version=%s", + davey.DAVE_PROTOCOL_VERSION, + ) + + +def _attach_dave_wrapper(reader, voice_client) -> None: # type: ignore[no-untyped-def] + """Install the DAVE-aware ``decrypt_rtp`` wrapper on the decryptor.""" + import davey + + decryptor = reader.decryptor + # Don't double-wrap if update_secret_key fires repeatedly during + # an MLS rotation while a previous wrapper is still in place. + if getattr(decryptor, _PATCH_MARKER, False): + return + + original_decrypt_rtp = decryptor.decrypt_rtp + + def dave_aware_decrypt_rtp(packet): # type: ignore[no-untyped-def] + data = original_decrypt_rtp(packet) + state = getattr(voice_client, "_connection", None) + session = getattr(state, "dave_session", None) + if session is None or not getattr(session, "ready", False): + return data + + ssrc = getattr(packet, "ssrc", None) + if ssrc is None: + return data + ssrc_map = getattr(voice_client, "_ssrc_to_id", None) or {} + user_id = ssrc_map.get(ssrc) + if user_id is None: + # First packet for this SSRC — we don't yet know which + # user it belongs to. Skip DAVE decrypt; the reader will + # drop unknown-SSRC packets anyway, and once the voice + # gateway sends the SSRC→user binding, subsequent packets + # decode normally. + return data + + try: + if session.can_passthrough(user_id): + return data + except Exception: + log.debug( + "dave_can_passthrough_check_failed ssrc=%s user=%s", + ssrc, + user_id, + exc_info=True, + ) + return data + + try: + return session.decrypt(user_id, davey.MediaType.audio, data) + except Exception: + # Emit at debug level — a flood of decrypt failures during + # an MLS transition is expected and would otherwise drown + # the log. The packet_router_resilience patch will + # downgrade the resulting OpusError to a single line. + log.debug( + "dave_decrypt_failed ssrc=%s user=%s", + ssrc, + user_id, + exc_info=True, + ) + return data + + decryptor.decrypt_rtp = dave_aware_decrypt_rtp + setattr(decryptor, _PATCH_MARKER, True) + + +__all__ = ["apply_dave_decrypt_patch"] diff --git a/src/wolfbot/voicetest/__init__.py b/src/wolfbot/voicetest/__init__.py new file mode 100644 index 0000000..42499fa --- /dev/null +++ b/src/wolfbot/voicetest/__init__.py @@ -0,0 +1,8 @@ +"""Standalone voice-capture test bot. + +Joins one VC, runs the production audio path (``WolfbotAudioSink`` → +``VoiceIngestService``) with STT stubbed out, and writes the same +``audio_debug/`` WAV+txt sidecars Master would. Use it to validate the +recording / PCM-assembly path in isolation, in particular to A/B +``SilenceGeneratorSink`` on/off without running a full game. +""" diff --git a/src/wolfbot/voicetest/main.py b/src/wolfbot/voicetest/main.py new file mode 100644 index 0000000..55c6ebd --- /dev/null +++ b/src/wolfbot/voicetest/main.py @@ -0,0 +1,496 @@ +"""Voice-capture test bot — entrypoint. + +Reuses the same ``WolfbotAudioSink`` → ``VoiceIngestService`` path that +Master runs in production, but with: + +* STT stubbed (``_NoOpSttService`` returns confidence=1.0 / text="" so + the ingest service reaches the success branch and the per-segment + audio dump fires unchanged). +* Master WS client stubbed to in-process logs. +* ``seat_lookup`` / ``phase_lookup`` returning fixed values so the + ingest service treats every speaker as seat 1 in a synthetic + ``voicetest::day1::DAY_DISCUSSION::1`` phase. + +Segment boundaries come from the same Discord +``on_voice_member_speaking_start`` / ``on_voice_member_speaking_stop`` +listeners the production sink uses. + +The ``SilenceGeneratorSink`` wrap is toggled by +``VOICETEST_USE_SILENCE_GENERATOR`` so an operator can record the +same speech twice (on / off) and diff the resulting WAVs. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +import signal + +import discord +from discord.ext import voice_recv +from dotenv import load_dotenv +from pydantic import SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + +from wolfbot.domain.ws_messages import ( + Heartbeat, + SpeechEventPayload, + SttFailed, + VadSpeechEnded, + VadSpeechStarted, +) +from wolfbot.master.audio_sink import WolfbotAudioSink +from wolfbot.master.stt_service import SttResult +from wolfbot.master.voice_ingest_service import ( + VoiceIngestConfig, + VoiceIngestService, +) + +log = logging.getLogger("wolfbot.voicetest") + +_FAKE_GAME_ID = "voicetest" +_FAKE_PHASE_ID = "voicetest::day1::DAY_DISCUSSION::1" + + +class VoicetestSettings(BaseSettings): + """Voice-test bot configuration. + + Env file path is selected by ``WOLFBOT_VOICETEST_ENV``; default is + ``.env.voicetest`` in the working directory. + """ + + model_config = SettingsConfigDict(env_file=None, extra="ignore") + + VOICETEST_DISCORD_TOKEN: SecretStr + VOICETEST_GUILD_ID: int + VOICETEST_VOICE_CHANNEL_ID: int + WOLFBOT_VOICE_DEBUG_DIR: str + VOICETEST_USE_SILENCE_GENERATOR: bool = True + # Drop segments whose buffered audio sounds like silence rather than + # speech. Discord's speaking-start fires on any audio above a low + # threshold (breathing, keyboard noise, room hum), so without this + # gate the dump dir fills with noise-only WAVs. + # ``MIN_RMS`` is a 16-bit signed RMS threshold computed across the + # full segment; speech is typically 1000-5000, breathing/typing + # 100-500, true silence 0-100. + VOICETEST_MIN_RMS: int = 600 + # ``MIN_DURATION_MS`` rejects micro-bursts (mouse clicks, single + # syllables triggered by the silence-padding playback). Real + # utterances are almost always ≥300ms. + VOICETEST_MIN_DURATION_MS: int = 300 + # When true, run kept segments through the same STT stack the + # production master uses (selected by ``VOICE_STT_PROVIDER`` - + # gemini multimodal *or* Groq Whisper + xAI analyzer). When false + # (default), STT is stubbed and only the WAV is dumped. + VOICETEST_USE_STT: bool = False + LOG_LEVEL: str = "INFO" + + # ----- production-equivalent STT credentials ----- + # These are the same keys the master process reads from + # ``.env.master``. ``_run`` loads ``.env.master`` first and the + # voicetest env file second (with override), so voicetest + # transparently reuses whatever STT provider production is set up + # for - no duplication of API keys in the voicetest env. + VOICE_STT_PROVIDER: str = "gemini" + VOICE_LLM_API_KEY: SecretStr | None = None + VOICE_LLM_MODEL: str = "gemini-2.0-flash-lite" + GROQ_STT_API_KEY: SecretStr | None = None + GROQ_STT_MODEL: str = "whisper-large-v3-turbo" + GROQ_STT_BASE_URL: str = "https://api.groq.com/openai/v1" + GAMEPLAY_LLM_API_KEY: SecretStr | None = None + GAMEPLAY_LLM_MODEL: str = "grok-4-1-fast" + GAMEPLAY_LLM_BASE_URL: str | None = None + # Pre-STT silence gate, mirrored from MasterSettings so the voice + # path uses the same threshold as production. ``0`` disables. + VOICE_PRE_STT_MIN_RMS: int = 0 + VOICE_PRE_STT_MIN_DURATION_MS: int = 0 + + +class _NoNpcRegistryView: + """Always returns False — the voice-test session has no NPC bots.""" + + def is_npc(self, discord_user_id: str) -> bool: + return False + + def npc_user_ids(self) -> set[str]: + return set() + + +class _NoOpMasterClient: + """Logs each outbound event instead of sending over WS.""" + + async def send_vad_started(self, msg: VadSpeechStarted) -> None: + log.debug( + "vad_started seat=%s segment=%s ts=%s", + msg.seat_no, msg.segment_id, msg.ts, + ) + + async def send_vad_ended(self, msg: VadSpeechEnded) -> None: + log.debug( + "vad_ended seat=%s segment=%s ts=%s", + msg.seat_no, msg.segment_id, msg.ts, + ) + + async def send_speech_event_payload(self, msg: SpeechEventPayload) -> None: + log.info( + "segment_dumped seat=%s segment=%s window=%s→%sms text=%r", + msg.seat_no, + msg.segment_id, + msg.audio_start_ms, + msg.audio_end_ms, + msg.text, + ) + + async def send_stt_failed(self, msg: SttFailed) -> None: + log.info( + "stt_failed seat=%s segment=%s reason=%s", + msg.seat_no, msg.segment_id, msg.failure_reason, + ) + + async def send_heartbeat(self, msg: Heartbeat) -> None: + return None + + +class _NoOpSttService: + """High-confidence empty STT result. + + ``VoiceIngestService._run_stt_inner`` reaches the success branch + (confidence ≥ 0.6) and triggers the per-segment dump. ``text`` + stays empty so the ``.txt`` sidecar shows ``transcript: `` — + the WAV is what matters here, not the transcript. + """ + + async def transcribe( + self, + *, + audio: bytes, + language: str, + timeout_s: float, + ) -> SttResult: + bytes_per_sec = 48_000 * 2 * 2 # matches VoiceIngestConfig defaults + duration_ms = int(len(audio) / bytes_per_sec * 1000) if bytes_per_sec else 0 + return SttResult( + text="", + confidence=1.0, + duration_ms=duration_ms, + ) + + +def _seat_lookup(_uid: str) -> int | None: + return 1 + + +def _phase_lookup() -> tuple[str, str] | None: + return (_FAKE_GAME_ID, _FAKE_PHASE_ID) + + +def _build_production_stt(settings: VoicetestSettings): # type: ignore[no-untyped-def] + """Construct the same STT instance the production master uses. + + Selected by ``VOICE_STT_PROVIDER`` (gemini / groq) loaded from + ``.env.master``. Raises ``SystemExit`` with a precise hint when + the chosen provider's credentials are missing - the operator + should fix the production env, not duplicate keys here. + """ + provider = (settings.VOICE_STT_PROVIDER or "gemini").lower() + if provider == "groq": + if settings.GROQ_STT_API_KEY is None: + raise SystemExit( + "VOICETEST_USE_STT=true with VOICE_STT_PROVIDER=groq " + "requires GROQ_STT_API_KEY (set in .env.master)." + ) + if settings.GAMEPLAY_LLM_API_KEY is None: + raise SystemExit( + "VOICETEST_USE_STT=true with VOICE_STT_PROVIDER=groq " + "requires GAMEPLAY_LLM_API_KEY for the analyzer step " + "(set in .env.master)." + ) + from wolfbot.master.stt_service import GroqWhisperAudioAnalyzer + + analyzer_base_url = ( + settings.GAMEPLAY_LLM_BASE_URL or "https://api.x.ai/v1" + ) + log.info( + "voicetest_stt=ON provider=groq whisper=%s analyzer=%s @ %s", + settings.GROQ_STT_MODEL, + settings.GAMEPLAY_LLM_MODEL, + analyzer_base_url, + ) + return GroqWhisperAudioAnalyzer( + groq_api_key=settings.GROQ_STT_API_KEY.get_secret_value(), + groq_model=settings.GROQ_STT_MODEL, + groq_base_url=settings.GROQ_STT_BASE_URL, + analyzer_api_key=settings.GAMEPLAY_LLM_API_KEY.get_secret_value(), + analyzer_model=settings.GAMEPLAY_LLM_MODEL, + analyzer_base_url=analyzer_base_url, + ) + + if provider == "gemini": + if settings.VOICE_LLM_API_KEY is None: + raise SystemExit( + "VOICETEST_USE_STT=true with VOICE_STT_PROVIDER=gemini " + "requires VOICE_LLM_API_KEY (set in .env.master)." + ) + from wolfbot.master.stt_service import GeminiAudioAnalyzer + + log.info( + "voicetest_stt=ON provider=gemini model=%s", + settings.VOICE_LLM_MODEL, + ) + return GeminiAudioAnalyzer( + api_key=settings.VOICE_LLM_API_KEY.get_secret_value(), + model=settings.VOICE_LLM_MODEL, + ) + + raise SystemExit( + f"Unknown VOICE_STT_PROVIDER={provider!r} - expected 'gemini' or 'groq'" + ) + + +def _pcm_rms_s16le(pcm: bytes) -> int: + """Root-mean-square magnitude across all 16-bit signed LE samples. + + Returns 0 for empty / odd-length buffers (treat as silence). + Uses pure Python so we don't pick up an audioop deprecation + surface and don't pull numpy into the voicetest dependency + closure. + """ + n = len(pcm) // 2 + if n == 0: + return 0 + import struct + + samples = struct.unpack(f"<{n}h", pcm[: n * 2]) + sq = 0 + for s in samples: + sq += s * s + return int((sq / n) ** 0.5) + + +def _install_silence_gate( + *, + min_rms: int, + min_duration_ms: int, + pcm_bytes_per_ms: int, +) -> None: + """Wrap ``voice_debug_dump.dump_segment`` with an RMS+duration gate. + + Discord's speaking-start indicator fires on any audio above a low + threshold (breathing, typing, room noise), and the + SilenceGeneratorSink keeps the segment "open" by injecting zero + frames. The result is a steady stream of dumps whose buffer is + mostly silence with sub-syllable spikes - useless for inspecting + actual speech recordings. + + ``VoiceIngestService._run_stt_inner`` does + ``from wolfbot.master.voice_debug_dump import dump_segment`` + *inside the function body*, so a module-level monkey-patch + applied before any segment fires will be picked up by every + subsequent call without touching production code. + """ + import dataclasses + from datetime import datetime + + import wolfbot.master.voice_debug_dump as _dump_mod + + original = _dump_mod.dump_segment + + def _stamp(audio_start_ms: int) -> str: + # Local-time wall clock so the operator scanning ``ls`` in the + # dump dir can see at a glance which utterance came when. ``ms`` + # disambiguates back-to-back segments that fire in the same + # second, which is common during silence-padded VAD bursts. + dt = datetime.fromtimestamp(audio_start_ms / 1000.0) + return dt.strftime("%Y%m%d-%H%M%S-") + f"{audio_start_ms % 1000:03d}" + + async def gated_dump_segment(record, pcm): # type: ignore[no-untyped-def] + duration_ms = len(pcm) // pcm_bytes_per_ms if pcm_bytes_per_ms else 0 + rms = _pcm_rms_s16le(pcm) + if duration_ms < min_duration_ms: + log.info( + "voicetest_dump_skip reason=too_short segment=%s duration_ms=%d rms=%d", + record.segment_id, + duration_ms, + rms, + ) + return + if rms < min_rms: + log.info( + "voicetest_dump_skip reason=below_rms segment=%s rms=%d duration_ms=%d", + record.segment_id, + rms, + duration_ms, + ) + return + log.info( + "voicetest_dump_keep segment=%s rms=%d duration_ms=%d", + record.segment_id, + rms, + duration_ms, + ) + # Prepend a wall-clock timestamp to ``segment_id`` so the + # underlying ``dump_segment`` writes ``_.wav``. + # The sanitizer keeps ``-`` and ``_`` intact, so this passes + # through unchanged. + stamped = dataclasses.replace( + record, + segment_id=f"{_stamp(record.audio_start_ms)}_{record.segment_id}", + ) + await original(stamped, pcm) + + _dump_mod.dump_segment = gated_dump_segment + + +async def _run() -> None: + # Load production credentials first so STT keys (VOICE_LLM_API_KEY, + # GROQ_STT_API_KEY, GAMEPLAY_LLM_API_KEY, etc.) come through without + # duplication. The voicetest-specific file is loaded second with + # ``override=True`` so any voicetest-only setting wins - including + # the discord token, which is intentionally a different bot from + # the master. + load_dotenv(".env.master") + env_path = os.environ.get("WOLFBOT_VOICETEST_ENV", ".env.voicetest") + load_dotenv(env_path, override=True) + settings = VoicetestSettings() # type: ignore[call-arg] + + # The dump module reads WOLFBOT_VOICE_DEBUG_DIR off os.environ — make + # sure it's set even if the operator put it in the .env file rather + # than the parent shell environment. + os.environ["WOLFBOT_VOICE_DEBUG_DIR"] = settings.WOLFBOT_VOICE_DEBUG_DIR + + logging.basicConfig( + level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + + # Default intents already include ``voice_states``, which is all we + # need for ``on_voice_member_speaking_*`` to fire. We deliberately do + # NOT request the privileged ``members`` intent so this bot can be + # run without enabling Server Members Intent in the developer + # portal — the trade-off is that ``member.display_name`` may fall + # back to the global username (User.name) when the member isn't in + # the cache, which only affects the dump subdirectory name. + intents = discord.Intents.default() + bot = discord.Client(intents=intents) + + ingest_config = VoiceIngestConfig( + pre_stt_min_rms=settings.VOICE_PRE_STT_MIN_RMS, + pre_stt_min_duration_ms=settings.VOICE_PRE_STT_MIN_DURATION_MS, + ) + pcm_bytes_per_ms = ( + ingest_config.pcm_sample_rate + * ingest_config.pcm_channels + * ingest_config.pcm_sample_width + // 1000 + ) + _install_silence_gate( + min_rms=settings.VOICETEST_MIN_RMS, + min_duration_ms=settings.VOICETEST_MIN_DURATION_MS, + pcm_bytes_per_ms=pcm_bytes_per_ms, + ) + log.info( + "voicetest_silence_gate min_rms=%d min_duration_ms=%d", + settings.VOICETEST_MIN_RMS, + settings.VOICETEST_MIN_DURATION_MS, + ) + + from wolfbot.master.stt_service import SttService + + stt_service: SttService + if settings.VOICETEST_USE_STT: + stt_service = _build_production_stt(settings) + else: + stt_service = _NoOpSttService() + log.info("voicetest_stt=OFF (transcript will be empty)") + + voice_ingest = VoiceIngestService( + registry_view=_NoNpcRegistryView(), + master_client=_NoOpMasterClient(), + stt=stt_service, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup, + config=ingest_config, + ) + + stop = asyncio.Event() + vc_ref: list[voice_recv.VoiceRecvClient | None] = [None] + + async def _join_vc() -> None: + from wolfbot.master.voice_recv_dave_patch import ( + apply_dave_decrypt_patch, + ) + from wolfbot.master.voice_recv_resilience import ( + apply_packet_router_resilience, + ) + + apply_packet_router_resilience() + apply_dave_decrypt_patch() + + ch = bot.get_channel(settings.VOICETEST_VOICE_CHANNEL_ID) + if not isinstance(ch, discord.VoiceChannel): + log.error( + "voicetest_channel_not_found id=%s", + settings.VOICETEST_VOICE_CHANNEL_ID, + ) + stop.set() + return + try: + vc = await ch.connect(cls=voice_recv.VoiceRecvClient) + except Exception: + log.exception("voicetest_join_failed") + stop.set() + return + + sink: voice_recv.AudioSink = WolfbotAudioSink( + voice_ingest, loop=asyncio.get_running_loop() + ) + if settings.VOICETEST_USE_SILENCE_GENERATOR: + sink = voice_recv.SilenceGeneratorSink(sink) + log.info("voicetest_silence_generator=ON (matches production)") + else: + log.info("voicetest_silence_generator=OFF (raw real-frames only)") + vc.listen(sink) + vc_ref[0] = vc + log.info( + "voicetest_joined channel=%s dump_dir=%s", + settings.VOICETEST_VOICE_CHANNEL_ID, + settings.WOLFBOT_VOICE_DEBUG_DIR, + ) + + @bot.event + async def on_ready() -> None: + log.info("voicetest_ready user=%s", bot.user) + await _join_vc() + + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + with contextlib.suppress(NotImplementedError): + loop.add_signal_handler(sig, stop.set) + + bot_task = asyncio.create_task( + bot.start(settings.VOICETEST_DISCORD_TOKEN.get_secret_value()) + ) + + try: + await stop.wait() + finally: + log.info("voicetest_shutting_down") + vc = vc_ref[0] + if vc is not None: + with contextlib.suppress(Exception): + if vc.is_connected(): + await vc.disconnect() + with contextlib.suppress(Exception): + await bot.close() + with contextlib.suppress(Exception): + await bot_task + + +def run() -> None: + asyncio.run(_run()) + + +if __name__ == "__main__": + run() diff --git a/tests/test_voice_ingest_service.py b/tests/test_voice_ingest_service.py index 93d146f..0c99045 100644 --- a/tests/test_voice_ingest_service.py +++ b/tests/test_voice_ingest_service.py @@ -187,3 +187,94 @@ async def test_restart_abandons_open_segments() -> None: await svc.begin_segment(speaker_user_id="u4") abandoned = await svc.abandon_open_segments() assert abandoned == 2 + + +async def test_pre_stt_silence_gate_skips_short_or_quiet_segments() -> None: + """Pre-STT silence gate must suppress the STT call for buffers + too short or too quiet to plausibly contain speech, and emit a + canonical ``stt_failed reason=pre_stt_silence_gate`` instead.""" + + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + # Scripted result is never reached when the gate fires - if it + # were, the assertion on stt_failures would still catch the + # mis-routing because confidence=0.99 would land in + # speech_payloads, not stt_failures. + stt = FakeSttService(default=SttResult(text="x", confidence=0.99, duration_ms=1)) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + config=VoiceIngestConfig( + pre_stt_min_rms=200, + pre_stt_min_duration_ms=300, + ), + now_ms=lambda: 1, + ) + # Buffer = 100 frames * 4 bytes (stereo s16) = 400 bytes ≈ 2ms + # well under both thresholds and contains all-zero PCM. + await svc.begin_segment(speaker_user_id="u3") + await svc.handle_voice_packet(speaker_user_id="u3", pcm=b"\x00" * 400) + await svc.end_segment(speaker_user_id="u3") + assert client.speech_payloads == [] + assert len(client.stt_failures) == 1 + assert client.stt_failures[0].failure_reason == "pre_stt_silence_gate" + assert svc.pre_stt_silence_gated_count == 1 + # Sanity: the STT service was never called. + assert stt.call_count == 0 + + +async def test_pre_stt_silence_gate_passes_loud_speech() -> None: + """A long, loud buffer should pass the gate and reach STT.""" + + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(default=SttResult(text="ok", confidence=0.9, duration_ms=500)) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + config=VoiceIngestConfig( + pre_stt_min_rms=200, + pre_stt_min_duration_ms=300, + ), + now_ms=lambda: 1, + ) + # Build ~1s of loud square-wave PCM: 48000 Hz * 2ch * 2B = 192000 + # bytes/sec. Use a +/- 8000 amplitude so RMS clears the 200 gate. + import struct + + samples = [8000 if (i // 480) % 2 == 0 else -8000 for i in range(48_000 * 2)] + pcm = struct.pack(f"<{len(samples)}h", *samples) + await svc.begin_segment(speaker_user_id="u3") + await svc.handle_voice_packet(speaker_user_id="u3", pcm=pcm) + await svc.end_segment(speaker_user_id="u3") + assert len(client.speech_payloads) == 1 + assert client.stt_failures == [] + assert svc.pre_stt_silence_gated_count == 0 + + +async def test_pre_stt_silence_gate_disabled_by_default() -> None: + """Default ``VoiceIngestConfig`` keeps the gate off so existing + tests / callers that pass tiny buffers still reach STT.""" + + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(default=SttResult(text="x", confidence=0.9, duration_ms=1)) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + now_ms=lambda: 1, + ) + await svc.begin_segment(speaker_user_id="u3") + await svc.handle_voice_packet(speaker_user_id="u3", pcm=b"\x00" * 16) + await svc.end_segment(speaker_user_id="u3") + assert len(client.speech_payloads) == 1 + assert svc.pre_stt_silence_gated_count == 0 From cf2f238be88b2d3838ca30c09baa886e01ac8d82 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 02:31:00 +0900 Subject: [PATCH 049/133] feat(voice): ground STT analyzer prompt on the live VC roster + emit addressed_seat_no MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/domain/ws_messages.py | 12 ++ src/wolfbot/main.py | 59 +++++++- src/wolfbot/master/ingest_service.py | 23 ++- src/wolfbot/master/stt_service.py | 156 +++++++++++++++++++-- src/wolfbot/master/voice_ingest_service.py | 25 ++++ src/wolfbot/voicetest/main.py | 56 +++++++- tests/test_addressed_npc_routing.py | 83 +++++++++++ tests/test_stt_roster_prompt.py | 74 ++++++++++ tests/test_voice_ingest_service.py | 52 +++++++ 9 files changed, 521 insertions(+), 19 deletions(-) create mode 100644 tests/test_stt_roster_prompt.py diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index 390b65e..b7e9c40 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -538,6 +538,18 @@ class SpeechEventPayload(BaseEnvelope): "the analyzer didn't detect a named address." ), ) + addressed_seat_no: int | None = Field( + default=None, + description=( + "Seat number the analyzer pre-resolved ``addressed_name`` to " + "when its prompt was grounded with a roster. Master prefers " + "this over running ``resolve_seat_by_name(addressed_name, ...)`` " + "which only matches against the persona-canonical " + "``Seat.display_name`` and fails when the bot's live VC " + "nickname diverges from the persona handle. None when the " + "analyzer wasn't grounded or couldn't pick a seat." + ), + ) class SttFailed(BaseEnvelope): diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 4882a08..8ad2760 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -103,9 +103,23 @@ async def post_public(self, game_id: str, text: str, kind: str) -> None: # voice-ingest seat/phase caches (populated when integrated ingest is active) _vc_seat_map: dict[str, int] = {} _vc_phase_cache: list[tuple[str, str] | None] = [None] + # Alive seats as ``(seat_no, display_name)`` so the analyzer LLM + # can resolve mistranscribed names to a canonical participant. + _vc_roster: list[tuple[int, str]] = [] async def _refresh_voice_ingest_cache(game_id: str) -> None: - """Update seat map and phase cache for the integrated voice-ingest.""" + """Update seat map and phase cache for the integrated voice-ingest. + + ``_vc_roster`` holds the alive seats in ``(seat_no, display_name)`` + form for the analyzer LLM's name-resolution prompt. The + ``display_name`` here is the **live VC nickname** as Discord + renders it for that participant - i.e. what the human speaker + actually sees on their VC overlay - not the stored + ``Seat.display_name`` (which for NPC seats is the persona's + canonical handle and may differ from the Discord bot's + guild-side nickname). Falls back to the stored value when the + member is uncached or the bot user_id isn't known yet. + """ game = await repo.load_game(game_id) if game is None or game.ended_at is not None: _vc_phase_cache[0] = None @@ -115,11 +129,42 @@ async def _refresh_voice_ingest_cache(game_id: str) -> None: _vc_phase_cache[0] = (game.id, phase_id) seats = await repo.load_seats(game_id) players = await repo.load_players(game_id) + alive_seats = {p.seat_no for p in players if p.alive} _vc_seat_map.clear() for s in seats: - if s.discord_user_id and any(p.seat_no == s.seat_no and p.alive for p in players): + if s.discord_user_id and s.seat_no in alive_seats: _vc_seat_map[s.discord_user_id] = s.seat_no + # Resolve each seat's live VC display name. NPC seats have + # ``discord_user_id=NULL`` in the seats table, so cross- + # reference NpcRegistry to pick up the bot's actual user id + # (each NPC bot logs in as its own Discord user). + try: + guild = bot.get_guild(int(game.guild_id)) if game.guild_id else None + except (TypeError, ValueError): + guild = None + npc_user_by_seat: dict[int, str] = {} + if _npc_registry_ref: + registry = _npc_registry_ref[0] + for entry in registry.assigned_to_game(game.id): + if entry.assigned_seat is not None: + npc_user_by_seat[entry.assigned_seat] = entry.discord_bot_user_id + + _vc_roster.clear() + for s in sorted(seats, key=lambda x: x.seat_no): + if s.seat_no not in alive_seats: + continue + user_id_str = s.discord_user_id or npc_user_by_seat.get(s.seat_no) + live_name: str | None = None + if guild is not None and user_id_str: + try: + member = guild.get_member(int(user_id_str)) + except (TypeError, ValueError): + member = None + if member is not None: + live_name = member.display_name + _vc_roster.append((s.seat_no, live_name or s.display_name)) + # ---- Master VC join lifecycle --------------------------------------- # Single VC connection; one Master = one guild = at most one active # 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: def _phase_lookup() -> tuple[str, str] | None: return _vc_phase_cache[0] + def _roster_lookup() -> list[tuple[int, str]]: + """Snapshot of alive seats for grounding the STT analyzer. + + Read out of the ``_vc_roster`` cache populated by + ``_refresh_voice_ingest_cache`` so we don't hit the DB + on every speech segment. + """ + return list(_vc_roster) + voice_ingest = VoiceIngestService( registry_view=_RegistryViewAdapter(), master_client=direct_client, @@ -1113,6 +1167,7 @@ def _phase_lookup() -> tuple[str, str] | None: pre_stt_min_rms=settings.VOICE_PRE_STT_MIN_RMS, pre_stt_min_duration_ms=settings.VOICE_PRE_STT_MIN_DURATION_MS, ), + roster_lookup=_roster_lookup, ) if settings.VOICE_STT_PROVIDER == "groq": log.info( diff --git a/src/wolfbot/master/ingest_service.py b/src/wolfbot/master/ingest_service.py index d783293..7af7e0e 100644 --- a/src/wolfbot/master/ingest_service.py +++ b/src/wolfbot/master/ingest_service.py @@ -232,8 +232,23 @@ async def ingest_voice( alive_seat_nos=alive_seat_nos, ) + # Prefer the analyzer's pre-resolved seat number when its + # prompt was grounded with a roster - that path bypasses the + # legacy ``resolve_seat_by_name`` string match, which only + # compares against ``Seat.display_name`` (=persona handle) + # and silently drops the address when the live VC nickname + # diverges. Validate the seat is actually alive in this + # phase before trusting it; fall back to the name-resolution + # path otherwise so a buggy / hallucinated seat number + # doesn't poison the routing. addressed_seat_no: int | None = None - if payload.addressed_name: + alive_set = set(alive_seat_nos) + if ( + payload.addressed_seat_no is not None + and payload.addressed_seat_no in alive_set + ): + addressed_seat_no = payload.addressed_seat_no + elif payload.addressed_name: try: addressed_seat_no = await self.phase_lookup.resolve_addressed_seat( payload.game_id, payload.addressed_name @@ -245,9 +260,9 @@ async def ingest_voice( payload.addressed_name, ) addressed_seat_no = None - # Self-address never needs a routed reply. - if addressed_seat_no is not None and addressed_seat_no == payload.seat_no: - addressed_seat_no = None + # Self-address never needs a routed reply. + if addressed_seat_no is not None and addressed_seat_no == payload.seat_no: + addressed_seat_no = None event = SpeechEvent( event_id=new_event_id(), diff --git a/src/wolfbot/master/stt_service.py b/src/wolfbot/master/stt_service.py index 72dfa6d..ddb50b5 100644 --- a/src/wolfbot/master/stt_service.py +++ b/src/wolfbot/master/stt_service.py @@ -17,7 +17,7 @@ from __future__ import annotations import logging -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass from typing import Any, Protocol, runtime_checkable @@ -27,6 +27,14 @@ format_co_claim_options, ) +# (seat_no, display_name) pair used to ground the analyzer's +# ``addressed_name`` extraction in the actual VC roster. Passing this +# at transcribe time lets the LLM resolve STT misspellings (e.g. +# Whisper's "ラッキーオ" for the spoken "ラキオ") to one of the real +# seats instead of returning the literal mistranscription that the +# downstream resolver can't match. +RosterEntry = tuple[int, str] + log = logging.getLogger(__name__) @@ -35,6 +43,70 @@ def _seat_range_label() -> str: return f"1〜{VILLAGE_SIZE}" +def _coerce_seat_no(value: Any) -> int | None: + """Coerce an analyzer-emitted seat number to ``int`` in [1, VILLAGE_SIZE]. + + Accepts both ``int`` and string-of-digits because some models hand + back numeric strings even when the JSON schema asks for an int. + Returns ``None`` for any out-of-range / non-numeric input rather + than raising — the analyzer is best-effort and the downstream + name-resolver still has the literal ``addressed_name`` to fall + back on. + """ + if value is None: + return None + if isinstance(value, bool): + return None + if isinstance(value, int): + seat = value + elif isinstance(value, str) and value.strip().isdigit(): + seat = int(value.strip()) + else: + return None + return seat if 1 <= seat <= VILLAGE_SIZE else None + + +_ADDRESSED_SEAT_FIELD_INSTRUCTION = ( + "**addressed_seat_no**: addressed_name と同じ人物の席番号(整数 1〜9)、" + "または null。**downstream は addressed_name の文字一致より先に " + "addressed_seat_no を採用する**ので、roster 上の人物に確信があるなら " + "必ず席番号を埋める。確信が無いとき / 全体宛 / 該当無しは null。\n" +) + + +def _format_roster_block(roster: Sequence[RosterEntry] | None) -> str: + """Render the participant roster as a prompt block. + + Empty when ``roster`` is None or empty so the static prompt + sections look identical for callers that don't supply a roster + (tests, voicetest in no-op mode). When non-empty, lists every + seat with its ``display_name`` and instructs the analyzer LLM + to resolve ``addressed_name`` / ``vote_target_seat`` to one of + these canonical entries even when the upstream STT mangles the + spelling - the typical "Whisper rendered 'ラキオ' as 'ラッキーオ'" + failure mode this guard exists to fix. + """ + if not roster: + return "" + lines = "\n".join(f" - 席{seat}: {name}" for seat, name in roster) + return ( + "\n現在の参加者(席番号 → display_name):\n" + f"{lines}\n" + "**addressed_name の表記揺れ正規化**: addressed_name は必ず上の " + "display_name のいずれか(emoji含む完全一致)を返す、または null。" + "STT の書き起こしは表記揺れすることがあるので以下を吸収して最も近い人物に対応付ける:\n" + "- 音韻的に近い表記(例: 「ラッキーオ」「ラキ」「らきお」 → 「🦋ラキオ」)、" + "濁音/半濁音/促音/長音/ヤ行イ行差/カタカナ⇔ひらがなを許容\n" + "- さん/くん/ちゃん/様/氏 等の敬称は剝がして照合\n" + "- 「3番」「席3」のような席番号呼びかけは、その席の display_name に解決\n" + "- 「みんな」「全員」「みなさん」「お前ら」など全体宛は null\n" + "- 候補が決められない場合も null(無理に当てない)\n" + "**vote_target_seat も同じ参加者リストに従い、上の席番号のいずれか**を返す、" + "または null。\n" + + _ADDRESSED_SEAT_FIELD_INSTRUCTION + ) + + def pcm_to_wav( pcm: bytes, *, @@ -96,6 +168,15 @@ class SttResult: summary: str | None = None co_declaration: str | None = None addressed_name: str | None = None + # Seat number the analyzer resolved ``addressed_name`` to, when + # the prompt was grounded with a roster. Bypasses the downstream + # ``resolve_seat_by_name`` string match - critical when the live + # VC display name (which the speaker hears and the analyzer is + # told about) differs from ``Seat.display_name`` in the DB + # (which is the persona's canonical handle and the only thing + # the legacy resolver knows). ``None`` when no roster was given + # or the analyzer couldn't pick a seat. + addressed_seat_no: int | None = None raw_analysis: dict[str, Any] | None = None @@ -117,6 +198,13 @@ class SttService(Protocol): Implementations MUST be cancellable and MUST NOT block the asyncio loop on the network call (use `asyncio.to_thread` or an async HTTP client). + + ``roster`` is an optional list of ``(seat_no, display_name)`` pairs + grounding the analyzer LLM's ``addressed_name`` / + ``vote_target_seat`` extraction. When supplied, the analyzer maps + misspelled / phonetically-near transcriptions back to a canonical + seat. Implementations should fall back to their static prompt + when ``roster`` is None or empty. """ async def transcribe( @@ -125,6 +213,7 @@ async def transcribe( audio: bytes, language: str, timeout_s: float, + roster: Sequence[RosterEntry] | None = None, ) -> SttResult: ... @@ -132,6 +221,9 @@ class FakeSttService: """In-memory STT for tests. Either return a scripted sequence of results or raise scripted errors. + Records the most-recently-passed ``roster`` so tests asserting on + prompt-conditioning behavior can verify it without intercepting + HTTP traffic. """ def __init__( @@ -142,6 +234,7 @@ def __init__( self._scripted: list[SttResult | Exception] = list(scripted or []) self._default: SttResult | None = default self.call_count = 0 + self.last_roster: Sequence[RosterEntry] | None = None async def transcribe( self, @@ -149,8 +242,10 @@ async def transcribe( audio: bytes, language: str, timeout_s: float, + roster: Sequence[RosterEntry] | None = None, ) -> SttResult: self.call_count += 1 + self.last_roster = roster if self._scripted: head = self._scripted.pop(0) if isinstance(head, Exception): @@ -189,9 +284,15 @@ async def transcribe( audio: bytes, language: str, timeout_s: float, + roster: Sequence[RosterEntry] | None = None, ) -> SttResult: if self._transcribe_fn is None: raise SttProviderError("stt_provider_not_configured") + # ``GeminiSttService`` is a thin transcribe-only delegate; it + # has no analyzer prompt to inject the roster into. Tests that + # care about roster propagation use ``GeminiAudioAnalyzer`` + # instead. Accept the param for Protocol conformance. + del roster return await self._transcribe_fn(audio, language, self.model, timeout_s) @@ -215,7 +316,7 @@ class GeminiAudioAnalyzer: input tokens). Does NOT import ``httpx`` at module level. """ - _SYSTEM_PROMPT: str = ( + _SYSTEM_PROMPT_BASE: str = ( "あなたは人狼ゲームの音声ログ分析エンジンです。\n" "渡された音声(日本語)を書き起こし、以下のJSON形式で返してください。\n" "JSONのみ返答し、他のテキストは含めないでください。\n\n" @@ -227,7 +328,8 @@ class GeminiAudioAnalyzer: ' "co_claim": null,\n' ' "vote_target_seat": null,\n' ' "stance": {},\n' - ' "addressed_name": null\n' + ' "addressed_name": null,\n' + ' "addressed_seat_no": null\n' "}\n" "```\n\n" "フィールド説明:\n" @@ -239,9 +341,17 @@ class GeminiAudioAnalyzer: "- stance: 言及した席への態度 {\"席番号\": \"positive\"/\"negative\"/\"neutral\"}\n" "- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 \"セツ\"、\"ジーナさん\"、\"席3\"、\"3番\")、なければ null。" "「みんな」「全員」など全体への呼びかけは null。さん/くん/ちゃん 等の敬称は付けたままでも構わない。\n" + "- addressed_seat_no: 上の addressed_name と同じ人物の席番号(整数)、または null。roster が与えられているときは必ず埋める。\n" "\n音声が不明瞭な場合は confidence を低くし、transcript は聞き取れた範囲で。" ) + @classmethod + def _build_system_prompt( + cls, roster: Sequence[RosterEntry] | None + ) -> str: + """Static base + (optional) roster grounding block.""" + return cls._SYSTEM_PROMPT_BASE + _format_roster_block(roster) + def __init__( self, *, @@ -261,6 +371,7 @@ async def transcribe( audio: bytes, language: str, timeout_s: float, + roster: Sequence[RosterEntry] | None = None, ) -> SttResult: import base64 import json @@ -275,6 +386,7 @@ async def transcribe( audio_b64 = base64.b64encode(audio).decode("ascii") effective_timeout = min(timeout_s, self.timeout_s) + system_prompt = self._build_system_prompt(roster) body = { "contents": [ @@ -287,7 +399,7 @@ async def transcribe( } }, { - "text": self._SYSTEM_PROMPT, + "text": system_prompt, }, ] } @@ -346,6 +458,7 @@ async def transcribe( if isinstance(addressed_raw, str): stripped = addressed_raw.strip() addressed_name = stripped or None + addressed_seat_no = _coerce_seat_no(parsed.get("addressed_seat_no")) # Estimate duration from audio size (assume 16kHz 16-bit mono WAV) data_bytes = max(0, len(audio) - 44) @@ -358,6 +471,7 @@ async def transcribe( summary=summary_str, co_declaration=co_declaration, addressed_name=addressed_name, + addressed_seat_no=addressed_seat_no, raw_analysis=parsed or None, ) @@ -377,7 +491,7 @@ async def transcribe( role="voice_stt", provider="gemini", model=self.model, - system_prompt=self._SYSTEM_PROMPT, + system_prompt=system_prompt, user_prompt=f"[audio bytes={len(audio)} mime=audio/wav]", response=raw_text or None, latency_ms=timer.elapsed_ms, @@ -433,7 +547,7 @@ class GroqWhisperAudioAnalyzer: flowing even when the analyzer LLM is briefly down. """ - _ANALYZER_PROMPT: str = ( + _ANALYZER_PROMPT_BASE: str = ( "あなたは人狼ゲームの発話内容を分析するエンジンです。\n" "以下の書き起こし(日本語)を読んで、以下のJSONのみを返してください。\n" "他の文字は含めないでください。\n\n" @@ -442,7 +556,8 @@ class GroqWhisperAudioAnalyzer: ' "co_claim": null,\n' ' "vote_target_seat": null,\n' ' "stance": {},\n' - ' "addressed_name": null\n' + ' "addressed_name": null,\n' + ' "addressed_seat_no": null\n' "}\n\n" "フィールド説明:\n" "- summary: 発言内容の1文要約\n" @@ -450,9 +565,16 @@ class GroqWhisperAudioAnalyzer: f"- vote_target_seat: 処刑対象として名指しした席番号({_seat_range_label()})、なければ null\n" "- stance: 言及した席への態度 {\"席番号\": \"positive\"/\"negative\"/\"neutral\"}\n" "- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 \"セツ\"、\"ジーナさん\"、\"席3\"、\"3番\")、なければ null。" - "「みんな」「全員」など全体への呼びかけは null。" + "「みんな」「全員」など全体への呼びかけは null。\n" + "- addressed_seat_no: 上の addressed_name と同じ人物の席番号(整数)、または null。roster が与えられているときは必ず埋める。" ) + @classmethod + def _build_analyzer_prompt( + cls, roster: Sequence[RosterEntry] | None + ) -> str: + return cls._ANALYZER_PROMPT_BASE + _format_roster_block(roster) + def __init__( self, *, @@ -488,6 +610,7 @@ async def transcribe( audio: bytes, language: str, timeout_s: float, + roster: Sequence[RosterEntry] | None = None, ) -> SttResult: effective_timeout = min(timeout_s, self.timeout_s) bytes_per_sec = ( @@ -519,7 +642,7 @@ async def transcribe( addressed_name=None, ) - analysis = await self._analyze(transcript, effective_timeout) + analysis = await self._analyze(transcript, effective_timeout, roster=roster) co_raw = analysis.get("co_claim") co_decl = co_raw if co_raw in CO_CLAIM_VALUES else None addressed = analysis.get("addressed_name") @@ -558,6 +681,7 @@ async def transcribe( summary=summary_str, co_declaration=co_decl, addressed_name=addressed_name, + addressed_seat_no=_coerce_seat_no(analysis.get("addressed_seat_no")), raw_analysis=analysis or None, ) @@ -661,7 +785,13 @@ async def _whisper( ) return transcript, confidence - async def _analyze(self, transcript: str, timeout: float) -> dict: # type: ignore[type-arg] + async def _analyze( + self, + transcript: str, + timeout: float, + *, + roster: Sequence[RosterEntry] | None = None, + ) -> dict: # type: ignore[type-arg] """Step 2: ask the analyzer LLM to extract structured fields. Soft-fail: any error returns ``{}`` so the discussion path still @@ -680,10 +810,11 @@ async def _analyze(self, transcript: str, timeout: float) -> dict: # type: igno ) url = f"{self.analyzer_base_url}/chat/completions" + analyzer_prompt = self._build_analyzer_prompt(roster) body = { "model": self.analyzer_model, "messages": [ - {"role": "system", "content": self._ANALYZER_PROMPT}, + {"role": "system", "content": analyzer_prompt}, {"role": "user", "content": transcript}, ], "response_format": {"type": "json_object"}, @@ -741,7 +872,7 @@ async def _analyze(self, transcript: str, timeout: float) -> dict: # type: igno role="voice_stt", provider="xai", model=self.analyzer_model, - system_prompt=self._ANALYZER_PROMPT, + system_prompt=analyzer_prompt, user_prompt=transcript, response=raw or None, latency_ms=timer.elapsed_ms, @@ -756,6 +887,7 @@ async def _analyze(self, transcript: str, timeout: float) -> dict: # type: igno "GeminiAudioAnalyzer", "GeminiSttService", "GroqWhisperAudioAnalyzer", + "RosterEntry", "SttProviderError", "SttResult", "SttService", diff --git a/src/wolfbot/master/voice_ingest_service.py b/src/wolfbot/master/voice_ingest_service.py index db97240..0ecdc65 100644 --- a/src/wolfbot/master/voice_ingest_service.py +++ b/src/wolfbot/master/voice_ingest_service.py @@ -39,6 +39,7 @@ VadSpeechStarted, ) from wolfbot.master.stt_service import ( + RosterEntry, SttProviderError, SttResult, SttService, @@ -147,6 +148,7 @@ def __init__( phase_lookup: Callable[[], tuple[str, str] | None], config: VoiceIngestConfig | None = None, now_ms: Callable[[], int] = _now_ms, + roster_lookup: Callable[[], list[RosterEntry]] | None = None, ) -> None: self.registry_view = registry_view self.master_client = master_client @@ -154,6 +156,12 @@ def __init__( self.seat_lookup = seat_lookup # phase_lookup returns (game_id, phase_id) for the current discussion phase, or None. self.phase_lookup = phase_lookup + # roster_lookup returns the current alive seat list as + # (seat_no, display_name) so the analyzer LLM can resolve + # mistranscribed names to a canonical participant. Optional + # because tests / voicetest in no-op mode have nothing useful + # to ground against. + self.roster_lookup = roster_lookup self.config = config or VoiceIngestConfig() self._now_ms = now_ms self._open_segments: dict[str, _OpenSegment] = {} @@ -386,11 +394,27 @@ def _build_dump( ) return + roster: list[RosterEntry] | None = None + if self.roster_lookup is not None: + try: + roster = list(self.roster_lookup()) + except Exception: + # Roster fetch is a soft dependency on game state; + # never let it abort the STT call. Log and proceed + # with the legacy un-grounded prompt. + log.warning( + "voice_roster_lookup_failed game=%s segment=%s", + game_id, + seg.segment_id, + exc_info=True, + ) + try: result: SttResult = await self.stt.transcribe( audio=pcm_snapshot, language=self.config.stt_language, timeout_s=self.config.stt_timeout_s, + roster=roster, ) except SttProviderError as exc: self.stt_provider_error_count += 1 @@ -497,6 +521,7 @@ def _build_dump( summary=result.summary, co_declaration=co_decl, # type: ignore[arg-type] addressed_name=result.addressed_name, + addressed_seat_no=result.addressed_seat_no, ) ) diff --git a/src/wolfbot/voicetest/main.py b/src/wolfbot/voicetest/main.py index 55c6ebd..1e63a4f 100644 --- a/src/wolfbot/voicetest/main.py +++ b/src/wolfbot/voicetest/main.py @@ -27,6 +27,7 @@ import logging import os import signal +from collections.abc import Callable, Sequence import discord from discord.ext import voice_recv @@ -42,7 +43,7 @@ VadSpeechStarted, ) from wolfbot.master.audio_sink import WolfbotAudioSink -from wolfbot.master.stt_service import SttResult +from wolfbot.master.stt_service import RosterEntry, SttResult from wolfbot.master.voice_ingest_service import ( VoiceIngestConfig, VoiceIngestService, @@ -168,7 +169,9 @@ async def transcribe( audio: bytes, language: str, timeout_s: float, + roster: Sequence[RosterEntry] | None = None, ) -> SttResult: + del roster # Voicetest no-op STT has no analyzer to ground. bytes_per_sec = 48_000 * 2 * 2 # matches VoiceIngestConfig defaults duration_ms = int(len(audio) / bytes_per_sec * 1000) if bytes_per_sec else 0 return SttResult( @@ -186,6 +189,39 @@ def _phase_lookup() -> tuple[str, str] | None: return (_FAKE_GAME_ID, _FAKE_PHASE_ID) +def _make_live_vc_roster_lookup( + bot: discord.Client, voice_channel_id: int +) -> Callable[[], list[tuple[int, str]]]: + """Build a roster lookup that pulls live VC display names. + + The voicetest bot is in the same voice channel as the human + speaker plus any NPC bots, so the channel's member list is the + ground truth for "what names does the speaker actually see / + hear in this room". Mirrors the production resolver in + ``main.py`` which falls back to ``Seat.display_name`` only when + the live member is uncached - operators reported NPC bots + sometimes appear in VC under a different nickname than the + persona's stored ``display_name`` (e.g. operator renamed the + bot in server settings), and the analyzer needs to ground on + the *spoken* name, not the internal canonical handle. + """ + + def lookup() -> list[tuple[int, str]]: + ch = bot.get_channel(voice_channel_id) + if not isinstance(ch, discord.VoiceChannel): + return [] + # ``ch.members`` is the cached list of guild members + # currently connected to this voice channel. Sort by id so + # the seat numbering stays stable across calls. + members = sorted(ch.members, key=lambda m: m.id) + roster: list[tuple[int, str]] = [] + for i, member in enumerate(members, start=1): + roster.append((i, member.display_name)) + return roster + + return lookup + + def _build_production_stt(settings: VoicetestSettings): # type: ignore[no-untyped-def] """Construct the same STT instance the production master uses. @@ -412,6 +448,9 @@ async def _run() -> None: seat_lookup=_seat_lookup, phase_lookup=_phase_lookup, config=ingest_config, + roster_lookup=_make_live_vc_roster_lookup( + bot, settings.VOICETEST_VOICE_CHANNEL_ID + ), ) stop = asyncio.Event() @@ -459,6 +498,21 @@ async def _join_vc() -> None: settings.WOLFBOT_VOICE_DEBUG_DIR, ) + # Snapshot the roster the analyzer will see and log it once + # so the operator can confirm at a glance which display + # names will end up in the system prompt for this session. + roster_now = voice_ingest.roster_lookup() if voice_ingest.roster_lookup else [] + if roster_now: + log.info( + "voicetest_roster_snapshot %s", + ", ".join(f"席{seat}: {name}" for seat, name in roster_now), + ) + else: + log.warning( + "voicetest_roster_empty - analyzer will fall back to " + "the un-grounded prompt (no VC members visible yet)" + ) + @bot.event async def on_ready() -> None: log.info("voicetest_ready user=%s", bot.user) diff --git a/tests/test_addressed_npc_routing.py b/tests/test_addressed_npc_routing.py index 9930696..5645a0e 100644 --- a/tests/test_addressed_npc_routing.py +++ b/tests/test_addressed_npc_routing.py @@ -204,6 +204,89 @@ async def test_ingest_voice_sets_addressed_seat_from_payload( assert any(r.event_id == event.event_id and r.addressed_seat_no == 2 for r in rows) +async def test_ingest_voice_prefers_payload_seat_no_over_name_resolution( + repo: SqliteRepo, +) -> None: + """When the analyzer's prompt was grounded with a roster, the + ``addressed_seat_no`` it pre-resolves must override the legacy + string-based ``resolve_seat_by_name`` lookup. This is the only + code path that handles a renamed-bot scenario where the live VC + nickname doesn't match the persona's stored ``Seat.display_name``. + + Test setup: seat 2 has display_name "🌙セツ" in the DB. The payload + carries ``addressed_name="Lucky"`` (a name that would never resolve + via the legacy path) but ``addressed_seat_no=2``. The resulting + ``SpeechEvent.addressed_seat_no`` must be 2. + """ + g, seats = await _make_seated_game(repo) + registry = InMemoryNpcRegistry() + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + lookup = _SeatedPhaseLookup(seats=seats, alive=[1, 2, 3]) + svc = MasterIngestService( + registry=registry, discussion=discussion, phase_lookup=lookup + ) + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id=g.id, + phase_id=make_phase_id(g.id, 1, Phase.DAY_DISCUSSION), + seat_no=1, + speaker_discord_user_id="u1", + segment_id="s1", + text="Luckyさん、どう思う?", + confidence=0.92, + duration_ms=600, + audio_start_ms=0, + audio_end_ms=600, + addressed_name="Lucky", + addressed_seat_no=2, + ) + event, reason = await svc.ingest_voice(payload) + assert reason is None + assert event is not None + assert event.addressed_seat_no == 2 + + +async def test_ingest_voice_falls_back_to_name_when_seat_no_dead( + repo: SqliteRepo, +) -> None: + """A hallucinated / stale ``addressed_seat_no`` pointing at a dead + seat must be rejected and the resolver must fall back to the + name-based path.""" + g, seats = await _make_seated_game(repo) + registry = InMemoryNpcRegistry() + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + # Seat 2 is dead in this scenario; analyzer hallucinates + # ``addressed_seat_no=2`` anyway. + lookup = _SeatedPhaseLookup(seats=seats, alive=[1, 3]) + svc = MasterIngestService( + registry=registry, discussion=discussion, phase_lookup=lookup + ) + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id=g.id, + phase_id=make_phase_id(g.id, 1, Phase.DAY_DISCUSSION), + seat_no=1, + speaker_discord_user_id="u1", + segment_id="s1", + text="ジナさんどう?", + confidence=0.92, + duration_ms=600, + audio_start_ms=0, + audio_end_ms=600, + addressed_name="ジナ", + addressed_seat_no=2, # dead — should be ignored + ) + event, reason = await svc.ingest_voice(payload) + assert reason is None + assert event is not None + # Falls back to name resolution → resolves "ジナ" to seat 3. + assert event.addressed_seat_no == 3 + + async def test_ingest_voice_self_address_is_dropped(repo: SqliteRepo) -> None: """A speaker calling their own name must not produce a routed address.""" g, seats = await _make_seated_game(repo) diff --git a/tests/test_stt_roster_prompt.py b/tests/test_stt_roster_prompt.py new file mode 100644 index 0000000..2867e15 --- /dev/null +++ b/tests/test_stt_roster_prompt.py @@ -0,0 +1,74 @@ +"""Roster grounding for the STT analyzer prompt. + +The analyzer LLM receives a list of ``(seat_no, display_name)`` pairs +so it can resolve mistranscribed names (Whisper's "ラッキーオ" for +spoken "ラキオ" is the canonical failure case) to the canonical +participant. These tests pin the prompt-construction contract: + +1. ``_format_roster_block`` returns an empty string for missing / + empty roster so callers without seat info get the legacy prompt. +2. A non-empty roster is rendered as numbered lines with the + variant-tolerance instructions the analyzer needs. +3. Both ``GeminiAudioAnalyzer._build_system_prompt`` and + ``GroqWhisperAudioAnalyzer._build_analyzer_prompt`` thread the + roster through correctly. +""" + +from __future__ import annotations + +from wolfbot.master.stt_service import ( + GeminiAudioAnalyzer, + GroqWhisperAudioAnalyzer, + _format_roster_block, +) + + +def test_format_roster_block_empty_when_roster_missing() -> None: + assert _format_roster_block(None) == "" + assert _format_roster_block([]) == "" + + +def test_format_roster_block_lists_seats_and_variant_rules() -> None: + block = _format_roster_block([(1, "🦋ラキオ"), (2, "🌙セツ")]) + assert "席1: 🦋ラキオ" in block + assert "席2: 🌙セツ" in block + # The crucial instruction: the LLM must collapse phonetic / + # mistranscribed variants onto a roster entry, never invent a + # new one. The example call-out is the canonical failure case + # from the voicetest dump that motivated this feature. + assert "ラッキーオ" in block + assert "敬称" in block + # Catch-all behaviors: out-of-roster / ambiguous → null. + assert "null" in block + + +def test_gemini_build_system_prompt_appends_roster_block() -> None: + base = GeminiAudioAnalyzer._SYSTEM_PROMPT_BASE + with_roster = GeminiAudioAnalyzer._build_system_prompt( + [(3, "🦋ラキオ")] + ) + assert with_roster.startswith(base) + assert "席3: 🦋ラキオ" in with_roster + + +def test_gemini_build_system_prompt_no_roster_equals_base() -> None: + assert ( + GeminiAudioAnalyzer._build_system_prompt(None) + == GeminiAudioAnalyzer._SYSTEM_PROMPT_BASE + ) + + +def test_groq_build_analyzer_prompt_appends_roster_block() -> None: + base = GroqWhisperAudioAnalyzer._ANALYZER_PROMPT_BASE + with_roster = GroqWhisperAudioAnalyzer._build_analyzer_prompt( + [(5, "🌙セツ")] + ) + assert with_roster.startswith(base) + assert "席5: 🌙セツ" in with_roster + + +def test_groq_build_analyzer_prompt_no_roster_equals_base() -> None: + assert ( + GroqWhisperAudioAnalyzer._build_analyzer_prompt(None) + == GroqWhisperAudioAnalyzer._ANALYZER_PROMPT_BASE + ) diff --git a/tests/test_voice_ingest_service.py b/tests/test_voice_ingest_service.py index 0c99045..49f7766 100644 --- a/tests/test_voice_ingest_service.py +++ b/tests/test_voice_ingest_service.py @@ -258,6 +258,58 @@ async def test_pre_stt_silence_gate_passes_loud_speech() -> None: assert svc.pre_stt_silence_gated_count == 0 +async def test_roster_lookup_is_forwarded_to_stt_transcribe() -> None: + """When ``roster_lookup`` is supplied, its output must reach the + ``SttService.transcribe`` call so the analyzer LLM can ground + ``addressed_name`` on real seat names. ``FakeSttService`` records + the most recent ``roster`` for the assertion.""" + + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(default=SttResult(text="hi", confidence=0.9, duration_ms=1)) + captured_roster: list[tuple[int, str]] = [(3, "🦋ラキオ"), (4, "🌙セツ")] + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + roster_lookup=lambda: captured_roster, + now_ms=lambda: 1, + ) + await svc.begin_segment(speaker_user_id="u3") + await svc.handle_voice_packet(speaker_user_id="u3", pcm=b"\x00" * 16) + await svc.end_segment(speaker_user_id="u3") + assert stt.last_roster == captured_roster + + +async def test_roster_lookup_failure_does_not_break_stt_call() -> None: + """A roster_lookup that raises must not abort STT — the call + should fall back to a None roster (legacy un-grounded prompt).""" + + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(default=SttResult(text="hi", confidence=0.9, duration_ms=1)) + + def boom() -> list[tuple[int, str]]: + raise RuntimeError("game state not loaded") + + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + roster_lookup=boom, + now_ms=lambda: 1, + ) + await svc.begin_segment(speaker_user_id="u3") + await svc.end_segment(speaker_user_id="u3") + assert stt.call_count == 1 + assert stt.last_roster is None + assert len(client.speech_payloads) == 1 + + async def test_pre_stt_silence_gate_disabled_by_default() -> None: """Default ``VoiceIngestConfig`` keeps the gate off so existing tests / callers that pass tiny buffers still reach STT.""" From 1b783e6a4096731a15176dfb75ea08af07bd5445 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 09:16:42 +0900 Subject: [PATCH 050/133] feat(viewer): expose arbiter selection_reason on NPC speech rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist the SpeakArbiter's pick decision (addressed / silent_rotation / seat_tiebreak) plus a public-state snapshot on each npc_speak_requests row, join the three NPC orchestration tables in the GameExport, and render a "発話選定" chip + context line under every NPC speech in the viewer so reviewers can answer "why did this NPC speak now" without spelunking into Master logs. --- src/wolfbot/master/speak_arbiter.py | 32 +++- src/wolfbot/persistence/schema.py | 15 +- src/wolfbot/persistence/sqlite_repo.py | 11 +- src/wolfbot/services/game_export.py | 79 +++++++- src/wolfbot/services/game_export_types.py | 45 +++++ tests/test_game_export.py | 124 ++++++++++++ tests/test_reactive_voice_master.py | 222 ++++++++++++++++++++++ viewer/sample-data/export-schema.json | 203 ++++++++++++++++++++ viewer/src/components/GameView.tsx | 1 + viewer/src/components/PhaseSection.tsx | 156 ++++++++++++++- viewer/src/lib/types.ts | 37 ++++ 11 files changed, 917 insertions(+), 8 deletions(-) diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index 2b9ecb7..da1aead 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -28,7 +28,7 @@ import uuid from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass -from typing import Literal, cast +from typing import Any, Literal, cast from wolfbot.domain.discussion import ( PublicDiscussionState, @@ -184,6 +184,8 @@ async def dispatch_request( seat_no: int, game_id: str, suggested_intent: str = "speak", + selection_reason: str | None = None, + public_state_snapshot: dict[str, Any] | None = None, ) -> tuple[SpeakRequest | None, str | None]: """Try to send a SpeakRequest to `candidate_npc_id`. @@ -273,6 +275,8 @@ async def dispatch_request( priority=0, expires_at_ms=request.expires_at_ms, created_at_ms=now, + selection_reason=selection_reason, + public_state_snapshot=public_state_snapshot, ) try: await entry.send(request.model_dump_json()) @@ -639,16 +643,40 @@ def _pick_key(e: object) -> tuple[int, int, int]: in_silent = 0 if seat in state.silent_seats else 1 return (is_addressed, in_silent, seat) + online_npc_seats = sorted( + e.assigned_seat + for e in online + if e.assigned_seat is not None and e.game_id == game_id + ) + snapshot: dict[str, Any] = { + "phase_id": state.phase_id, + "day": state.day, + "phase": game.phase.value, + "last_addressed_seat": addressed, + "silent_seats": sorted(state.silent_seats), + "alive_seat_nos": sorted(state.alive_seat_nos), + "online_npc_seats": online_npc_seats, + } + for entry in sorted(online, key=_pick_key): if entry.assigned_seat is None or entry.game_id != game_id: continue if entry.assigned_seat not in state.alive_seat_nos: continue + seat = entry.assigned_seat + if addressed is not None and seat == addressed: + reason = "addressed" + elif seat in state.silent_seats: + reason = "silent_rotation" + else: + reason = "seat_tiebreak" await self.dispatch_request( state=state, candidate_npc_id=entry.npc_id, - seat_no=entry.assigned_seat, + seat_no=seat, game_id=game_id, + selection_reason=reason, + public_state_snapshot=snapshot, ) return diff --git a/src/wolfbot/persistence/schema.py b/src/wolfbot/persistence/schema.py index 011ccb4..61fa1d0 100644 --- a/src/wolfbot/persistence/schema.py +++ b/src/wolfbot/persistence/schema.py @@ -193,7 +193,9 @@ max_duration_ms INTEGER NOT NULL, priority INTEGER NOT NULL DEFAULT 0, expires_at_ms INTEGER NOT NULL, - created_at_ms INTEGER NOT NULL + created_at_ms INTEGER NOT NULL, + selection_reason TEXT, + public_state_snapshot_json TEXT ) """, """ @@ -300,4 +302,15 @@ async def migrate(db_path: str | Path) -> None: await db.execute( "ALTER TABLE speech_events ADD COLUMN addressed_seat_no INTEGER" ) + async with db.execute("PRAGMA table_info(npc_speak_requests)") as cur: + cols = {row[1] async for row in cur} + if "selection_reason" not in cols: + await db.execute( + "ALTER TABLE npc_speak_requests ADD COLUMN selection_reason TEXT" + ) + if "public_state_snapshot_json" not in cols: + await db.execute( + "ALTER TABLE npc_speak_requests " + "ADD COLUMN public_state_snapshot_json TEXT" + ) await db.commit() diff --git a/src/wolfbot/persistence/sqlite_repo.py b/src/wolfbot/persistence/sqlite_repo.py index a392440..8f3c70f 100644 --- a/src/wolfbot/persistence/sqlite_repo.py +++ b/src/wolfbot/persistence/sqlite_repo.py @@ -965,14 +965,17 @@ async def insert_npc_speak_request( priority: int, expires_at_ms: int, created_at_ms: int, + selection_reason: str | None = None, + public_state_snapshot: dict[str, Any] | None = None, ) -> None: await self._db.execute( """ INSERT INTO npc_speak_requests ( request_id, game_id, phase_id, npc_id, seat_no, logic_packet_id, suggested_intent, max_chars, max_duration_ms, - priority, expires_at_ms, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + priority, expires_at_ms, created_at_ms, + selection_reason, public_state_snapshot_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( request_id, @@ -987,6 +990,10 @@ async def insert_npc_speak_request( priority, expires_at_ms, created_at_ms, + selection_reason, + json.dumps(public_state_snapshot, ensure_ascii=False) + if public_state_snapshot is not None + else None, ), ) await self._db.commit() diff --git a/src/wolfbot/services/game_export.py b/src/wolfbot/services/game_export.py index 056918b..5cae16d 100644 --- a/src/wolfbot/services/game_export.py +++ b/src/wolfbot/services/game_export.py @@ -33,6 +33,7 @@ from pydantic import ValidationError from wolfbot.services.game_export_types import ( + ArbiterDecisionEntry, DeathCause, DiscussionMode, GameExport, @@ -83,11 +84,12 @@ async def export_game( encoding="utf-8", ) log.info( - "game_exported game=%s path=%s phases=%d trace_lines=%d", + "game_exported game=%s path=%s phases=%d trace_lines=%d arbiter_decisions=%d", game_id, out_path, len(payload.phases), len(payload.trace), + len(payload.arbiter_decisions), ) return out_path.resolve() @@ -155,6 +157,35 @@ async def _build_payload( "ORDER BY created_at_ms ASC", (game_id,), )] + # Arbiter decision timeline — joined LEFT-OUTER from requests so + # in-flight or rejected dispatches still appear (results / + # playback may legitimately be missing). + arbiter_rows = [dict(r) for r in await _fetch_all( + db, + """ + SELECT + req.request_id, req.phase_id, req.npc_id, req.seat_no, + req.suggested_intent, req.selection_reason, + req.public_state_snapshot_json, req.logic_packet_id, + req.created_at_ms, req.expires_at_ms, + res.status AS result_status, + res.text AS result_text, + res.intent AS result_intent, + res.failure_reason AS result_failure_reason, + res.received_at_ms AS result_received_at_ms, + pb.outcome AS playback_outcome, + pb.failure_reason AS playback_failure_reason, + pb.finished_at_ms AS playback_finished_at_ms, + pb.tts_outcome AS tts_outcome, + pb.tts_duration_ms AS tts_duration_ms + FROM npc_speak_requests req + LEFT JOIN npc_speak_results res ON res.request_id = req.request_id + LEFT JOIN npc_playback_events pb ON pb.request_id = req.request_id + WHERE req.game_id = ? + ORDER BY req.created_at_ms ASC + """, + (game_id,), + )] return GameExport( game=_build_game_meta(game_row, public_log_rows), @@ -163,6 +194,7 @@ async def _build_payload( public_log_rows, speech_event_rows, vote_rows, night_action_rows ), trace=_load_trace(trace_root, game_id), + arbiter_decisions=[_build_arbiter_decision(r) for r in arbiter_rows], ) @@ -199,6 +231,51 @@ def _build_game_meta( ) +def _build_arbiter_decision(row: dict[str, Any]) -> ArbiterDecisionEntry: + """Build one ArbiterDecisionEntry from the joined query row. + + Older games (pre-selection_reason migration) have NULL in both + ``selection_reason`` and ``public_state_snapshot_json``; we surface + them as ``None`` rather than back-fill heuristics so the viewer can + distinguish "we don't know why" from "we know it was X". + """ + raw_snapshot = row.get("public_state_snapshot_json") + snapshot: dict[str, Any] | None = None + if raw_snapshot: + try: + parsed = json.loads(raw_snapshot) + except json.JSONDecodeError: + log.warning( + "skipping malformed public_state_snapshot_json for request %s", + row.get("request_id"), + ) + parsed = None + if isinstance(parsed, dict): + snapshot = parsed + return ArbiterDecisionEntry( + request_id=row["request_id"], + phase_id=row["phase_id"], + npc_id=row["npc_id"], + seat_no=row["seat_no"], + suggested_intent=row["suggested_intent"], + selection_reason=row.get("selection_reason"), + public_state_snapshot=snapshot, + logic_packet_id=row["logic_packet_id"], + created_at_ms=row["created_at_ms"], + expires_at_ms=row["expires_at_ms"], + result_status=row.get("result_status"), + result_text=row.get("result_text"), + result_intent=row.get("result_intent"), + result_failure_reason=row.get("result_failure_reason"), + result_received_at_ms=row.get("result_received_at_ms"), + playback_outcome=row.get("playback_outcome"), + playback_failure_reason=row.get("playback_failure_reason"), + playback_finished_at_ms=row.get("playback_finished_at_ms"), + tts_outcome=row.get("tts_outcome"), + tts_duration_ms=row.get("tts_duration_ms"), + ) + + def _build_seat(row: dict[str, Any]) -> SeatExport: role = cast(RoleKey, row["role"] or "VILLAGER") death_cause: DeathCause | None = ( diff --git a/src/wolfbot/services/game_export_types.py b/src/wolfbot/services/game_export_types.py index a29684f..570556d 100644 --- a/src/wolfbot/services/game_export_types.py +++ b/src/wolfbot/services/game_export_types.py @@ -169,6 +169,49 @@ class TraceEntry(BaseModel): file_stem: str | None = None +class ArbiterDecisionEntry(BaseModel): + """One Master-side `SpeakRequest` dispatch — the "why this NPC, why now" + breadcrumb the viewer surfaces alongside the resulting NPC speech. + + Joined from three persistence rows keyed by ``request_id``: + + * ``npc_speak_requests`` — the dispatch itself (Master → NPC) + * ``npc_speak_results`` — NPC's reply (accepted / rejected / failed) + * ``npc_playback_events`` — TTS + Discord playback outcome + + Any of the three may be missing for an in-flight or interrupted + request; only ``request_id`` / ``phase_id`` / ``npc_id`` / ``seat_no`` + / ``created_at_ms`` are guaranteed. + """ + + model_config = _StrictConfig + + request_id: str + phase_id: str + npc_id: str + seat_no: int + suggested_intent: str + selection_reason: str | None + public_state_snapshot: dict[str, Any] | None + logic_packet_id: str + created_at_ms: int + expires_at_ms: int + + # speak_results join (None if NPC never replied / TTL expired) + result_status: str | None = None + result_text: str | None = None + result_intent: str | None = None + result_failure_reason: str | None = None + result_received_at_ms: int | None = None + + # playback_events join (None if request was rejected before TTS) + playback_outcome: str | None = None + playback_failure_reason: str | None = None + playback_finished_at_ms: int | None = None + tts_outcome: str | None = None + tts_duration_ms: int | None = None + + class GameExport(BaseModel): """Top-level shape of one ``viewer/games/{id}.json`` file.""" @@ -178,9 +221,11 @@ class GameExport(BaseModel): seats: list[SeatExport] phases: list[PhaseSection] trace: list[TraceEntry] + arbiter_decisions: list[ArbiterDecisionEntry] = [] __all__ = [ + "ArbiterDecisionEntry", "CoDeclaration", "DeathCause", "DiscussionMode", diff --git a/tests/test_game_export.py b/tests/test_game_export.py index fc4129d..cea2198 100644 --- a/tests/test_game_export.py +++ b/tests/test_game_export.py @@ -289,6 +289,130 @@ async def test_export_game_filters_player_speech_logs( assert "PLAYER_SPEECH" not in all_log_kinds +async def test_export_game_inlines_arbiter_decisions( + fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path +) -> None: + """Arbiter decisions (Master-side `SpeakRequest` dispatches) are joined + from the three NPC orchestration tables and emitted under the new + ``arbiter_decisions`` key. The viewer uses this to render the "why this + NPC, why now" breadcrumb alongside each NPC speech event. + """ + repo, db_path = fixture_repo + await _seed_minimal_game(repo) + + # Insert one full request → result → playback chain. + await repo.insert_npc_speak_request( + request_id="sr_t1", + game_id=GAME_ID, + phase_id=f"{GAME_ID}::day1::DAY_DISCUSSION::1", + npc_id="npc_setsu", + seat_no=2, + logic_packet_id="lp_t1", + suggested_intent="speak", + max_chars=80, + max_duration_ms=12_000, + priority=0, + expires_at_ms=1_700_000_300_000, + created_at_ms=1_700_000_200_000, + selection_reason="addressed", + public_state_snapshot={ + "last_addressed_seat": 2, + "silent_seats": [1, 2], + "alive_seat_nos": [1, 2], + "online_npc_seats": [2], + }, + ) + await repo.insert_npc_speak_result( + request_id="sr_t1", + game_id=GAME_ID, + phase_id=f"{GAME_ID}::day1::DAY_DISCUSSION::1", + npc_id="npc_setsu", + status="accepted", + text="占い師COします", + used_logic_ids=["co-1-seer"], + intent="speak", + estimated_duration_ms=2_500, + failure_reason=None, + received_at_ms=1_700_000_205_000, + ) + await repo.open_npc_playback( + request_id="sr_t1", + game_id=GAME_ID, + phase_id=f"{GAME_ID}::day1::DAY_DISCUSSION::1", + npc_id="npc_setsu", + speech_event_id="se_t1", + authorized_at_ms=1_700_000_205_500, + playback_deadline_ms=1_700_000_217_500, + ) + await repo.close_npc_playback( + "sr_t1", + finished_at_ms=1_700_000_208_000, + outcome="success", + failure_reason=None, + ) + + # And a second request that was rejected before TTS — verifies + # LEFT JOIN behavior (no playback row, but result row with status). + await repo.insert_npc_speak_request( + request_id="sr_t2", + game_id=GAME_ID, + phase_id=f"{GAME_ID}::day1::DAY_DISCUSSION::1", + npc_id="npc_gina", + seat_no=3, + logic_packet_id="lp_t2", + suggested_intent="speak", + max_chars=80, + max_duration_ms=12_000, + priority=0, + expires_at_ms=1_700_000_320_000, + created_at_ms=1_700_000_220_000, + selection_reason="silent_rotation", + public_state_snapshot={"silent_seats": [3]}, + ) + await repo.insert_npc_speak_result( + request_id="sr_t2", + game_id=GAME_ID, + phase_id=f"{GAME_ID}::day1::DAY_DISCUSSION::1", + npc_id="npc_gina", + status="rejected", + text=None, + used_logic_ids=None, + intent=None, + estimated_duration_ms=None, + failure_reason="stale_phase", + received_at_ms=1_700_000_225_000, + ) + + out = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=tmp_path / "out", + ) + payload = json.loads(out.read_text(encoding="utf-8")) + + decisions = payload["arbiter_decisions"] + assert len(decisions) == 2 + + # Chronological order — sr_t1 first, sr_t2 second. + d1, d2 = decisions + assert d1["request_id"] == "sr_t1" + assert d1["selection_reason"] == "addressed" + assert d1["public_state_snapshot"]["last_addressed_seat"] == 2 + assert d1["result_status"] == "accepted" + assert d1["result_text"] == "占い師COします" + assert d1["playback_outcome"] == "success" + assert d1["playback_finished_at_ms"] == 1_700_000_208_000 + + # Rejected request: result populated but playback fields all None. + assert d2["request_id"] == "sr_t2" + assert d2["selection_reason"] == "silent_rotation" + assert d2["result_status"] == "rejected" + assert d2["result_failure_reason"] == "stale_phase" + assert d2["playback_outcome"] is None + assert d2["playback_finished_at_ms"] is None + + async def test_export_game_raises_for_unknown_game( fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path ) -> None: diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index 86a73c6..4aca51d 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -634,6 +634,228 @@ async def test_try_dispatch_next_prefers_silent_seat_over_lowest( assert not buf2, "non-silent NPC at seat 2 must not be picked" +async def _fetch_selection_reason( + repo: SqliteRepo, game_id: str +) -> tuple[str | None, str | None]: + """Pull (seat_no_repr, reason) for the most recent dispatched request. + + Used by the selection_reason classification tests; the column is + only populated by the `try_dispatch_next` path, so a test that uses + the lower-level `dispatch_request` directly will see ``None``. + """ + async with repo._conn.execute( # type: ignore[attr-defined] + """ + SELECT seat_no, selection_reason, public_state_snapshot_json + FROM npc_speak_requests + WHERE game_id = ? + ORDER BY created_at_ms DESC + LIMIT 1 + """, + (game_id,), + ) as cur: + row = await cur.fetchone() + if row is None: + return (None, None) + return (str(row[0]), row[1]) + + +async def test_try_dispatch_next_records_selection_reason_addressed( + repo: SqliteRepo, +) -> None: + """When state.last_addressed_seat matches an online NPC seat, the + arbiter records ``selection_reason='addressed'`` and snapshots the + public-state context (silent_seats, alive_seat_nos, online_npc_seats) + onto the persisted row so the viewer can render the "why". + """ + g = Game( + id="rv-reason-addr", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + Seat(seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu"), + Seat(seat_no=3, display_name="ジーナ", discord_user_id=None, is_llm=True, persona_key="gina"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + await repo.set_player_role(g.id, 3, Role.VILLAGER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + # Human at seat 1 addresses NPC at seat 3 — this should override + # silent-rotation ordering (seat 2 has lower number but isn't addressed). + from wolfbot.services.discussion_service import make_voice_stt_event + + await store.insert( + make_voice_stt_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="ジーナどう思う?", + stt_confidence=0.9, + audio_start_ms=10, + audio_end_ms=20, + addressed_seat_no=3, + created_at_ms=2, + ) + ) + + registry = InMemoryNpcRegistry() + buf2: list[str] = [] + buf3: list[str] = [] + registry.register( + npc_id="npc_setsu", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(buf2), + now_ms=1000, + persona_key="setsu", + ) + registry.register( + npc_id="npc_gina", + discord_bot_user_id="bot3", + supported_voices=(), + version="1", + send=_captured_send(buf3), + now_ms=1000, + persona_key="gina", + ) + registry.assign("npc_setsu", seat=2, game_id=g.id, phase_id=phase_id) + registry.assign("npc_gina", seat=3, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + await arb.try_dispatch_next(g.id) + + assert buf3, "addressed NPC at seat 3 must receive the SpeakRequest" + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "3" + assert reason == "addressed" + + +async def test_try_dispatch_next_records_selection_reason_silent_rotation( + repo: SqliteRepo, +) -> None: + """No addressed seat → silent NPC wins; reason='silent_rotation'.""" + g = Game( + id="rv-reason-silent", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + Seat(seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu"), + Seat(seat_no=3, display_name="ジーナ", discord_user_id=None, is_llm=True, persona_key="gina"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + await repo.set_player_role(g.id, 3, Role.VILLAGER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + # Seat 2 already spoke. silent_seats={1,3}; seat 1 is human (no NPC), + # so the only silent NPC is seat 3 → reason should be silent_rotation. + from wolfbot.services.discussion_service import make_npc_generated_event + + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="seat 2 already spoke", + created_at_ms=2, + ) + ) + + registry = InMemoryNpcRegistry() + buf2: list[str] = [] + buf3: list[str] = [] + registry.register( + npc_id="npc_setsu", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(buf2), + now_ms=1000, + persona_key="setsu", + ) + registry.register( + npc_id="npc_gina", + discord_bot_user_id="bot3", + supported_voices=(), + version="1", + send=_captured_send(buf3), + now_ms=1000, + persona_key="gina", + ) + registry.assign("npc_setsu", seat=2, game_id=g.id, phase_id=phase_id) + registry.assign("npc_gina", seat=3, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + await arb.try_dispatch_next(g.id) + + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "3" + assert reason == "silent_rotation" + + def test_logic_packet_builder_includes_co_claims_in_summary() -> None: state = PublicDiscussionState( game_id="g", diff --git a/viewer/sample-data/export-schema.json b/viewer/sample-data/export-schema.json index 4bea60c..23bf8d7 100644 --- a/viewer/sample-data/export-schema.json +++ b/viewer/sample-data/export-schema.json @@ -1,5 +1,200 @@ { "$defs": { + "ArbiterDecisionEntry": { + "additionalProperties": false, + "description": "One Master-side `SpeakRequest` dispatch — the \"why this NPC, why now\"\nbreadcrumb the viewer surfaces alongside the resulting NPC speech.\n\nJoined from three persistence rows keyed by ``request_id``:\n\n* ``npc_speak_requests`` — the dispatch itself (Master → NPC)\n* ``npc_speak_results`` — NPC's reply (accepted / rejected / failed)\n* ``npc_playback_events`` — TTS + Discord playback outcome\n\nAny of the three may be missing for an in-flight or interrupted\nrequest; only ``request_id`` / ``phase_id`` / ``npc_id`` / ``seat_no``\n/ ``created_at_ms`` are guaranteed.", + "properties": { + "request_id": { + "title": "Request Id", + "type": "string" + }, + "phase_id": { + "title": "Phase Id", + "type": "string" + }, + "npc_id": { + "title": "Npc Id", + "type": "string" + }, + "seat_no": { + "title": "Seat No", + "type": "integer" + }, + "suggested_intent": { + "title": "Suggested Intent", + "type": "string" + }, + "selection_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Selection Reason" + }, + "public_state_snapshot": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Public State Snapshot" + }, + "logic_packet_id": { + "title": "Logic Packet Id", + "type": "string" + }, + "created_at_ms": { + "title": "Created At Ms", + "type": "integer" + }, + "expires_at_ms": { + "title": "Expires At Ms", + "type": "integer" + }, + "result_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Result Status" + }, + "result_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Result Text" + }, + "result_intent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Result Intent" + }, + "result_failure_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Result Failure Reason" + }, + "result_received_at_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Result Received At Ms" + }, + "playback_outcome": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Playback Outcome" + }, + "playback_failure_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Playback Failure Reason" + }, + "playback_finished_at_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Playback Finished At Ms" + }, + "tts_outcome": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tts Outcome" + }, + "tts_duration_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tts Duration Ms" + } + }, + "required": [ + "request_id", + "phase_id", + "npc_id", + "seat_no", + "suggested_intent", + "selection_reason", + "public_state_snapshot", + "logic_packet_id", + "created_at_ms", + "expires_at_ms" + ], + "title": "ArbiterDecisionEntry", + "type": "object" + }, "GameMeta": { "additionalProperties": false, "properties": { @@ -676,6 +871,14 @@ }, "title": "Trace", "type": "array" + }, + "arbiter_decisions": { + "default": [], + "items": { + "$ref": "#/$defs/ArbiterDecisionEntry" + }, + "title": "Arbiter Decisions", + "type": "array" } }, "required": [ diff --git a/viewer/src/components/GameView.tsx b/viewer/src/components/GameView.tsx index 3f2cd76..8331240 100644 --- a/viewer/src/components/GameView.tsx +++ b/viewer/src/components/GameView.tsx @@ -66,6 +66,7 @@ export default function GameView({ phase={phase} seats={data.seats} trace={data.trace} + arbiterDecisions={data.arbiter_decisions ?? []} onOpenTrace={setOpenTrace} /> ))} diff --git a/viewer/src/components/PhaseSection.tsx b/viewer/src/components/PhaseSection.tsx index 6ae6a66..5fb539b 100644 --- a/viewer/src/components/PhaseSection.tsx +++ b/viewer/src/components/PhaseSection.tsx @@ -21,6 +21,7 @@ import { sourceJa, } from "@/lib/format"; import type { + ArbiterDecision, PhaseSection as PhaseSectionType, Seat, SpeechEvent, @@ -32,14 +33,16 @@ export default function PhaseSection({ phase, seats, trace, + arbiterDecisions, onOpenTrace, }: { phase: PhaseSectionType; seats: Seat[]; trace: TraceEntry[]; + arbiterDecisions: ArbiterDecision[]; onOpenTrace: (entry: TraceEntry) => void; }) { - const events = buildTimeline(phase, seats, trace); + const events = buildTimeline(phase, seats, trace, arbiterDecisions); return ( @@ -91,7 +94,13 @@ export default function PhaseSection({ type TimelineEvent = | { kind: "log"; ts: number; data: PhaseSectionType["public_logs"][number] } - | { kind: "speech"; ts: number; data: SpeechEvent; trace: TraceEntry | null } + | { + kind: "speech"; + ts: number; + data: SpeechEvent; + trace: TraceEntry | null; + arbiter: ArbiterDecision | null; + } | { kind: "vote"; ts: number; data: Vote; trace: TraceEntry | null } | { kind: "night_action"; @@ -104,6 +113,7 @@ function buildTimeline( phase: PhaseSectionType, seats: Seat[], trace: TraceEntry[], + arbiterDecisions: ArbiterDecision[], ): TimelineEvent[] { const events: TimelineEvent[] = []; for (const log of phase.public_logs) { @@ -115,6 +125,7 @@ function buildTimeline( ts: sp.created_at_ms, data: sp, trace: matchTraceForSpeech(sp, phase, trace), + arbiter: matchArbiterForSpeech(sp, phase, arbiterDecisions), }); } for (const v of phase.votes) { @@ -137,6 +148,46 @@ function buildTimeline( return events; } +/** + * Match an `npc_generated` SpeechEvent back to the Master-side + * `SpeakRequest` dispatch that produced it. The DB doesn't carry a + * direct foreign key (request_id is in `npc_speak_results`, not in + * `speech_events`), so we match on: + * + * 1. phase_id (canonical {gid}::dayN::PHASE::seq) + * 2. seat_no — the NPC's assigned seat + * 3. result_text equality with the spoken utterance — disambiguates + * multiple dispatches to the same seat in the same phase + * + * Falls back to the most recent dispatch for the seat in the phase + * when text doesn't match (text may differ slightly: leading whitespace, + * the NPC's first/last words trimmed, etc.). Returns `null` when the + * speech is from a human / text channel, since arbiter dispatch only + * fires for `npc_generated`. + */ +function matchArbiterForSpeech( + sp: SpeechEvent, + phase: PhaseSectionType, + decisions: ArbiterDecision[], +): ArbiterDecision | null { + if (sp.source !== "npc_generated" || sp.speaker_seat == null) return null; + const phaseMatches = (d: ArbiterDecision) => + d.phase_id.includes(`::day${phase.day}::${phase.phase}`); + const seatMatches = (d: ArbiterDecision) => d.seat_no === sp.speaker_seat; + const exactText = + decisions.find( + (d) => + phaseMatches(d) && seatMatches(d) && d.result_text === sp.text, + ) ?? null; + if (exactText) return exactText; + const sameSeat = decisions.filter((d) => phaseMatches(d) && seatMatches(d)); + if (sameSeat.length === 0) return null; + // Latest dispatch for the seat in this phase, by created_at_ms. + return sameSeat.reduce((best, cur) => + cur.created_at_ms > best.created_at_ms ? cur : best, + ); +} + function matchTraceForSpeech( sp: SpeechEvent, phase: PhaseSectionType, @@ -326,6 +377,7 @@ function EventRow({ /> )} + {event.arbiter && } {event.data.text} {event.data.summary && ( @@ -333,6 +385,7 @@ function EventRow({ 要約: {event.data.summary} )} + {event.arbiter && } @@ -394,6 +447,105 @@ function TimeCell({ time }: { time: string }) { ); } +const ARBITER_REASON_JA: Record = { + addressed: "指名", + silent_rotation: "未発言ローテ", + seat_tiebreak: "席順", +}; + +const ARBITER_REASON_TIP: Record = { + addressed: + "直前の発言で addressed_seat_no がこの NPC の席だったため最優先で選ばれた", + silent_rotation: + "このフェーズでまだ発言していない NPC を優先して選んだ", + seat_tiebreak: + "全員が一度発言済み — 席番号の若い順で選んだ", +}; + +function ArbiterChip({ decision }: { decision: ArbiterDecision }) { + const label = decision.selection_reason + ? ARBITER_REASON_JA[decision.selection_reason] ?? decision.selection_reason + : "発話選定"; + const tip = + decision.selection_reason && + ARBITER_REASON_TIP[decision.selection_reason] + ? ARBITER_REASON_TIP[decision.selection_reason] + : "Master が SpeakRequest を送出した記録"; + return ( + + + + ); +} + +function ArbiterDetail({ decision }: { decision: ArbiterDecision }) { + const snap = decision.public_state_snapshot; + const addressed = + snap && typeof snap.last_addressed_seat === "number" + ? `席${snap.last_addressed_seat}` + : "なし"; + const silent = Array.isArray(snap?.silent_seats) + ? `[${(snap!.silent_seats as number[]).join(", ")}]` + : "—"; + const onlineNpcs = Array.isArray(snap?.online_npc_seats) + ? `[${(snap!.online_npc_seats as number[]).join(", ")}]` + : "—"; + const latencyMs = + decision.result_received_at_ms != null + ? decision.result_received_at_ms - decision.created_at_ms + : null; + const playbackMs = + decision.playback_finished_at_ms != null && + decision.result_received_at_ms != null + ? decision.playback_finished_at_ms - decision.result_received_at_ms + : null; + const status = decision.result_status ?? "in-flight"; + const failure = + decision.result_failure_reason ?? + decision.playback_failure_reason ?? + null; + return ( + + + addressed={addressed} silent={silent} online_npcs={onlineNpcs} + + + result={status} + {latencyMs != null && ` (LLM ${latencyMs}ms)`} + {playbackMs != null && ` / 再生 ${playbackMs}ms`} + {decision.tts_outcome && ` / TTS ${decision.tts_outcome}`} + {failure && ( + + ・失敗理由: {failure} + + )} + + + ); +} + function TraceButton({ entry, onOpen, diff --git a/viewer/src/lib/types.ts b/viewer/src/lib/types.ts index bb4ad59..ee1f738 100644 --- a/viewer/src/lib/types.ts +++ b/viewer/src/lib/types.ts @@ -102,6 +102,42 @@ export interface TraceEntry { file_stem?: string; } +/** + * One Master-side `SpeakRequest` dispatch — the "why this NPC, why now" + * breadcrumb the viewer surfaces alongside the resulting NPC speech. + * + * `selection_reason` is one of: + * - "addressed": state.last_addressed_seat matched this NPC's seat + * - "silent_rotation": no addressed seat; this NPC hadn't yet spoken in the phase + * - "seat_tiebreak": all online NPCs already spoke this phase; lowest seat wins + * + * Older games (pre-migration) have `selection_reason=null`. Result and + * playback fields are LEFT-joined and may be `null` for in-flight or + * rejected requests. + */ +export interface ArbiterDecision { + request_id: string; + phase_id: string; + npc_id: string; + seat_no: number; + suggested_intent: string; + selection_reason: string | null; + public_state_snapshot: Record | null; + logic_packet_id: string; + created_at_ms: number; + expires_at_ms: number; + result_status: string | null; + result_text: string | null; + result_intent: string | null; + result_failure_reason: string | null; + result_received_at_ms: number | null; + playback_outcome: string | null; + playback_failure_reason: string | null; + playback_finished_at_ms: number | null; + tts_outcome: string | null; + tts_duration_ms: number | null; +} + export interface GameSample { game: { id: string; @@ -117,4 +153,5 @@ export interface GameSample { seats: Seat[]; phases: PhaseSection[]; trace: TraceEntry[]; + arbiter_decisions?: ArbiterDecision[]; } From ac0c00ad8770c2287640ac5204a2bb8da0b779d9 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 09:22:09 +0900 Subject: [PATCH 051/133] chore(viewer): migrate to ESLint flat config and add @eslint/eslintrc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `next lint` is deprecated and ESLint 9 dropped support for legacy .eslintrc — the viewer had no usable lint setup. Add eslint.config.mjs using FlatCompat to load next/core-web-vitals + next/typescript, switch the npm script from `next lint` to direct `eslint .`, and pin @eslint/eslintrc as a devDependency. Lint, typecheck, and tests all pass. --- viewer/eslint.config.mjs | 26 ++++++++++++++++++++++++++ viewer/package.json | 3 ++- viewer/pnpm-lock.yaml | 3 +++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 viewer/eslint.config.mjs diff --git a/viewer/eslint.config.mjs b/viewer/eslint.config.mjs new file mode 100644 index 0000000..1d3257a --- /dev/null +++ b/viewer/eslint.config.mjs @@ -0,0 +1,26 @@ +import { FlatCompat } from "@eslint/eslintrc"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const compat = new FlatCompat({ baseDirectory: __dirname }); + +const config = [ + { + ignores: [ + ".next/**", + "node_modules/**", + "out/**", + "next-env.d.ts", + "tsconfig.tsbuildinfo", + "sample-data/**", + "games/**", + "public/**", + ], + }, + ...compat.extends("next/core-web-vitals", "next/typescript"), +]; + +export default config; diff --git a/viewer/package.json b/viewer/package.json index 3f1c97f..0565576 100644 --- a/viewer/package.json +++ b/viewer/package.json @@ -7,7 +7,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest" @@ -24,6 +24,7 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@eslint/eslintrc": "^3.3.5", "@types/node": "^22.10.2", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", diff --git a/viewer/pnpm-lock.yaml b/viewer/pnpm-lock.yaml index 4f85c5f..0903df0 100644 --- a/viewer/pnpm-lock.yaml +++ b/viewer/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: specifier: ^19.0.0 version: 19.2.5(react@19.2.5) devDependencies: + '@eslint/eslintrc': + specifier: ^3.3.5 + version: 3.3.5 '@types/node': specifier: ^22.10.2 version: 22.19.17 From f9e35bcc33b82777ddd5009a0f8cd9dbabe0930f Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 09:43:06 +0900 Subject: [PATCH 052/133] feat(narration): announce night phase entry on execution-confirmed path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state-machine emits an EXECUTION public log but no PHASE_CHANGE when transitioning into NIGHT, so role-holders previously got silent DMs after "席X の処刑が確定致しました" with no spoken cue that night had begun. Append the existing PHASE_CHANGE-NIGHT line to the EXECUTION voice template, gated on ctx.phase == NIGHT so victory endings still hand off cleanly to the VICTORY narration. --- src/wolfbot/master/narration.py | 13 +++++++++++++ tests/test_master_narration.py | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/wolfbot/master/narration.py b/src/wolfbot/master/narration.py index 7db41b6..1fec0a1 100644 --- a/src/wolfbot/master/narration.py +++ b/src/wolfbot/master/narration.py @@ -178,6 +178,19 @@ def _narrate_execution(entry: LogEntry, ctx: NarrationContext) -> NarrationOutpu headline, tally = _split_headline_and_tally(entry.text) target = _seat_label(ctx.seats_by_no, entry.actor_seat) voice = f"{target} の処刑が確定致しました。" + # The state-machine path emits no PHASE_CHANGE log between the EXECUTION + # row and the NIGHT phase entry, so role-holders would otherwise get + # silent DMs without any "夜です" cue. Append the same line that + # `_narrate_phase_change` would have voiced for Phase.NIGHT — but only + # when the next phase is actually NIGHT (when execution triggers a + # victory the game flips to GAME_OVER and the VICTORY narration takes + # over instead). + if ctx.phase is Phase.NIGHT: + durations = current_phase_durations() + voice += ( + f"夜のフェイズへ移行致します。制限時間は {durations.night} 秒でございます。" + "役職を持つ参加者の方は、DM の選択 UI から行動をお願い致します。" + ) # Strip the headline from the chat post — we voice it. Keep the tally so # the audit trail of who voted whom stays in the channel. chat = tally if tally else None diff --git a/tests/test_master_narration.py b/tests/test_master_narration.py index 5be8d6b..2d12092 100644 --- a/tests/test_master_narration.py +++ b/tests/test_master_narration.py @@ -180,6 +180,32 @@ def test_execution_voices_only_headline_chat_keeps_tally() -> None: assert "席1: セツ → 席3 Alice" in out.chat_text +def test_execution_voice_appends_night_phase_cue_when_next_phase_is_night() -> None: + """state_machine emits no PHASE_CHANGE between EXECUTION and NIGHT entry, + so the EXECUTION narration must announce the night transition itself — + otherwise role-holders get DMs with no spoken context that night began. + """ + full_text = "席1 セツ が処刑されました。\n\n席1: セツ → 席3 Alice" + entry = _entry("EXECUTION", text=full_text, actor_seat=1) + out = render_master_narration(entry, _ctx(phase=Phase.NIGHT)) + assert out.voice_text is not None + assert "処刑が確定" in out.voice_text + assert "夜のフェイズへ移行" in out.voice_text + assert "役職を持つ参加者" in out.voice_text + + +def test_execution_voice_omits_night_cue_on_victory() -> None: + """When execution triggers victory the live game.phase has flipped to + GAME_OVER; the VICTORY narration takes over and the EXECUTION line must + NOT also announce a night transition that will never happen.""" + full_text = "席1 セツ が処刑されました。\n\n席1: セツ → 席3 Alice" + entry = _entry("EXECUTION", text=full_text, actor_seat=1) + out = render_master_narration(entry, _ctx(phase=Phase.GAME_OVER)) + assert out.voice_text is not None + assert "処刑が確定" in out.voice_text + assert "夜のフェイズへ移行" not in out.voice_text + + def test_no_execution_with_runoff_tie_branches_voice_text() -> None: full_text = "決選投票も同票のため、本日は処刑なしで夜を迎えます。\n\n席1: 棄権" entry = _entry("NO_EXECUTION", text=full_text) From e79d95982ac56265454062ebcb24ed6c0be9c23f Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 09:52:44 +0900 Subject: [PATCH 053/133] fix(reactive_voice): self-heal PrivateStateSnapshot push after roles assigned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-D NPC vote / night-action / wolf-chat decisions all short-circuit to target=None when `game_states[game_id]` is empty. The snapshot push was wired into the unassigned-NPC loop in `_assign_online_npcs_to_seats`, but the first call from `_on_reactive_game_start` runs *before* `plan_setup` writes roles, so every snapshot is silently dropped by the "no role" guard. The next call from `_on_reactive_phase_enter` finds every NPC already assigned (idempotent skip) and never re-pushes. Result: NPCs receive DecideVoteRequest / DecideNightActionRequest / WolfChatRequest with empty in-memory state and abstain across the whole game — no day-1 votes, no night actions, peaceful mornings, and once humans force-skip out of day 2 nothing else happens. Fix: at the end of `_assign_online_npcs_to_seats`, re-push snapshots for every NPC currently assigned to the game (not just newly-touched ones). Idempotent on the NPC side, costs one small WS frame per assigned NPC per phase change, and self-heals the early-no-role case the moment DAY_DISCUSSION enters. --- src/wolfbot/main.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 8ad2760..d83a0fa 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -384,6 +384,10 @@ async def _assign_online_npcs_to_seats(game_id: str) -> int: # Phase-D: push the seat's full private state right after # SeatAssigned so the NPC bot can rebuild its in-memory # game state before the first decision request lands. + # This may skip with `private_state_snapshot_skip_no_role` + # when called pre-SETUP (the `/wolf start` path runs before + # role assignment); the self-heal below re-pushes once + # roles are visible. await _push_private_state_snapshot( npc_entry.send, npc_id=npc_entry.npc_id, @@ -391,6 +395,32 @@ async def _assign_online_npcs_to_seats(game_id: str) -> int: seat_no=seat.seat_no, persona_key=npc_entry.persona_key or "", ) + # Self-heal: re-push snapshots for every NPC currently assigned to + # this game, not just the ones we touched above. The first push + # from `/wolf start`'s `_on_reactive_game_start` runs before + # `plan_setup` writes roles, so the snapshot is silently dropped + # by `_push_private_state_snapshot`'s "no role" guard. Without a + # second push the NPC's `game_states[game_id]` stays empty for + # the entire game — every DecideVoteRequest / + # DecideNightActionRequest / WolfChatRequest then falls back to + # `target=None` because the decision handler short-circuits on + # missing state. Re-pushing on every phase entry is idempotent + # (the NPC client overwrites `game_states[game_id]` from the new + # snapshot) and only adds one small WS frame per assigned NPC + # per phase change. Safe-by-default also covers Master restarts: + # a re-attached NPC gets fresh state at the next phase enter. + for npc_entry in npc_reg.all_online(): + if npc_entry.assigned_seat is None or npc_entry.game_id != game_id: + continue + if npc_entry.send is None: + continue + await _push_private_state_snapshot( + npc_entry.send, + npc_id=npc_entry.npc_id, + game_id=game_id, + seat_no=npc_entry.assigned_seat, + persona_key=npc_entry.persona_key or "", + ) return dispatched async def _wait_for_npcs_in_vc(expected_count: int, timeout: float = 5.0) -> None: From 2d14e9c6b96dcd4ef3bcb1a4214dd58b2ba722f7 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 10:06:46 +0900 Subject: [PATCH 054/133] chore(reactive_voice): wire per-game in-memory cleanup at game end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A long-lived Master process and each NPC bot process accumulated per-game state forever — the registry was unassigned on `_on_reactive_game_end` but the deeper caches were never swept: - NPC `game_states[game_id]` (NpcGameState mirror) - NPC `_logic_cache` (LogicPacket cache, packet_id keyed) - Master `NpcDecisionDispatcher._pending` / `_pending_wolf_chat` (resolve-or-timeout only; aborts could leak) - Master `SpeakArbiter._pending` / `_active_playback` / `_playback_deadlines` Add explicit cleanup hooks so each process only carries live state for active games: - `NpcClient._on_seat_released` pops `game_states[game_id]` and filters `_logic_cache` by `phase_id` prefix. - `NpcDecisionDispatcher.cleanup_game(game_id)` resolves stale pending futures to `None` (matching the timeout fall-back). Pending types now carry `game_id` so cleanup is precise. - `SpeakArbiter.cleanup_game(game_id)` drops `_pending` / `_active_playback` / `_playback_deadlines` entries for the game. - `_on_reactive_game_end` in main.py invokes both cleanup methods via the LLMAdapter / arbiter references after the SeatReleased fan-out. DB rows are intentionally kept (export / replay). --- src/wolfbot/main.py | 23 ++++++++ src/wolfbot/master/decision_dispatcher.py | 45 +++++++++++++- src/wolfbot/master/speak_arbiter.py | 28 +++++++++ src/wolfbot/npc/client.py | 16 +++++ tests/test_master_decision_dispatcher.py | 72 +++++++++++++++++++++++ tests/test_npc_game_state.py | 47 +++++++++++++++ tests/test_reactive_voice_master.py | 51 ++++++++++++++++ 7 files changed, 281 insertions(+), 1 deletion(-) diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index d83a0fa..5ebac14 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -566,6 +566,29 @@ async def _on_reactive_game_end(game_id: str) -> None: ) npc_reg.unassign(entry.npc_id) log.info("npc_seat_unassigned npc=%s game=%s", entry.npc_id, game_id) + # Drop in-memory dispatcher / arbiter state for this game so a + # long-lived Master process doesn't accumulate stale pending + # futures + playback gates across many games. DB rows are kept + # (export / replay depends on them); only the live dicts are + # swept here. Best-effort: missing references (rounds-mode build + # without dispatcher / arbiter wired) are silent no-ops. + dispatcher = getattr(llm_adapter, "_npc_decision_dispatcher", None) + if dispatcher is not None and hasattr(dispatcher, "cleanup_game"): + try: + dispatcher.cleanup_game(game_id) + except Exception: + log.exception( + "decision_dispatcher_cleanup_failed game=%s", game_id + ) + if _reactive_phase_cb: + arbiter_ref = _reactive_phase_cb[0] + if hasattr(arbiter_ref, "cleanup_game"): + try: + arbiter_ref.cleanup_game(game_id) + except Exception: + log.exception( + "speak_arbiter_cleanup_failed game=%s", game_id + ) # Drop Master's own VC connection too — keeps the bot out of the # voice channel between games. Reattaches at the next /wolf start. await _master_leave_vc() diff --git a/src/wolfbot/master/decision_dispatcher.py b/src/wolfbot/master/decision_dispatcher.py index 51e12d0..48f88b3 100644 --- a/src/wolfbot/master/decision_dispatcher.py +++ b/src/wolfbot/master/decision_dispatcher.py @@ -65,6 +65,7 @@ class _PendingDecision: seat_no: int npc_id: str request_id: str + game_id: str @dataclass @@ -75,6 +76,7 @@ class _PendingWolfChat: seat_no: int npc_id: str request_id: str + game_id: str class NpcDecisionDispatcher: @@ -335,6 +337,7 @@ async def _dispatch_one_vote( request_id=request_id, seat_no=voter.seat_no, npc_id=entry.npc_id, + game_id=game_id, send=entry.send, payload_json=req.model_dump_json(), label="vote", @@ -379,6 +382,7 @@ async def _dispatch_one_wolf_chat( self._pending_wolf_chat[request_id] = _PendingWolfChat( future=future, seat_no=wolf.seat_no, npc_id=entry.npc_id, request_id=request_id, + game_id=game_id, ) try: await entry.send(req.model_dump_json()) @@ -449,6 +453,7 @@ async def _dispatch_one_night( request_id=request_id, seat_no=actor.seat_no, npc_id=entry.npc_id, + game_id=game_id, send=entry.send, payload_json=req.model_dump_json(), label=f"night-{action_kind}", @@ -460,6 +465,7 @@ async def _send_and_wait( request_id: str, seat_no: int, npc_id: str, + game_id: str, send: Callable[[str], Awaitable[None]], payload_json: str, label: str, @@ -467,7 +473,11 @@ async def _send_and_wait( loop = asyncio.get_running_loop() future: asyncio.Future[int | None] = loop.create_future() self._pending[request_id] = _PendingDecision( - future=future, seat_no=seat_no, npc_id=npc_id, request_id=request_id, + future=future, + seat_no=seat_no, + npc_id=npc_id, + request_id=request_id, + game_id=game_id, ) try: await send(payload_json) @@ -495,6 +505,39 @@ def _find_npc_for_seat(self, game_id: str, seat_no: int) -> NpcEntry | None: return entry return None + def cleanup_game(self, game_id: str) -> int: + """Cancel and drop every in-flight request belonging to ``game_id``. + + Called from the game-end hook so a long-lived Master process + doesn't accumulate pending futures across games. Each cancelled + future resolves to ``None`` (= abstain / skip text), matching the + timeout fall-back the in-flight code already handles. + + Returns the number of pending entries swept (vote/night + + wolf-chat combined) for log visibility. + """ + swept = 0 + for rid, pending in list(self._pending.items()): + if pending.game_id != game_id: + continue + if not pending.future.done(): + pending.future.set_result(None) + self._pending.pop(rid, None) + swept += 1 + for rid, pending_wc in list(self._pending_wolf_chat.items()): + if pending_wc.game_id != game_id: + continue + if not pending_wc.future.done(): + pending_wc.future.set_result(None) + self._pending_wolf_chat.pop(rid, None) + swept += 1 + if swept: + log.info( + "decision_dispatcher_cleanup_game game=%s swept=%d", + game_id, swept, + ) + return swept + __all__ = ["DecisionDispatcherConfig", "NpcDecisionDispatcher"] diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index da1aead..f0d5c97 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -680,6 +680,34 @@ def _pick_key(e: object) -> tuple[int, int, int]: ) return + # ------------------------------------------------------------- game-end cleanup + + def cleanup_game(self, game_id: str) -> int: + """Drop in-memory speak/playback state belonging to ``game_id``. + + Companion to :meth:`NpcDecisionDispatcher.cleanup_game`. Called + from the game-end hook so a long-lived Master process doesn't + carry pending arbitration state across games. The DB rows + (``npc_speak_requests`` / ``_results`` / ``_playback_events``) + are intentionally kept for replay/export — only the in-memory + gates / dicts are swept here. + + Returns the count of in-flight `_pending` entries dropped. + """ + swept = 0 + for rid, pending in list(self._pending.items()): + if pending.game_id != game_id: + continue + self._pending.pop(rid, None) + self._active_playback.discard(rid) + self._playback_deadlines.pop(rid, None) + swept += 1 + if swept: + log.info( + "speak_arbiter_cleanup_game game=%s swept=%d", game_id, swept, + ) + return swept + # ------------------------------------------------------------- restart sweep async def reactive_voice_recovery_sweep(self, game_id: str) -> None: diff --git a/src/wolfbot/npc/client.py b/src/wolfbot/npc/client.py index d751953..f9b2312 100644 --- a/src/wolfbot/npc/client.py +++ b/src/wolfbot/npc/client.py @@ -278,6 +278,22 @@ async def _on_seat_released(self, msg: SeatReleased) -> None: self.assigned_seat = None self.assigned_game_id = None self.assigned_phase_id = None + # Drop per-game caches so a long-lived NPC process doesn't + # accumulate `NpcGameState` + LogicPackets across every game it + # plays. The state is push-replaced by Master at the next + # PrivateStateSnapshot, so dropping it here only costs the next + # game its first snapshot — already mandatory anyway. Guard for + # the rare null game_id (older `SeatReleased` payloads carry it + # as optional): without a key we can't target the right game, so + # we skip the cleanup rather than risk wiping the wrong entry. + if msg.game_id is not None: + self.game_states.pop(msg.game_id, None) + prefix = f"{msg.game_id}::" + self._logic_cache = { + pid: pkt + for pid, pkt in self._logic_cache.items() + if not pkt.phase_id.startswith(prefix) + } if self.on_vc_leave is not None: try: await self.on_vc_leave() diff --git a/tests/test_master_decision_dispatcher.py b/tests/test_master_decision_dispatcher.py index d920603..fdeff09 100644 --- a/tests/test_master_decision_dispatcher.py +++ b/tests/test_master_decision_dispatcher.py @@ -391,3 +391,75 @@ async def _drive() -> dict[int, int | None]: ) ) await task + + +async def test_cleanup_game_resolves_pending_for_target_game_only() -> None: + """`cleanup_game` is wired into `_on_reactive_game_end` so a long-lived + Master process doesn't accumulate pending decision futures across games. + Two-game scenario: dispatch one in-flight vote per game, then cleanup g1 + and verify the g1 future resolves to None while g2's is untouched. + """ + registry = InMemoryNpcRegistry() + seat2_buf: list[str] = [] + seat3_buf: list[str] = [] + registry.register( + npc_id="npc_g1_seat2", discord_bot_user_id="bot2", + supported_voices=(), version="1", + send=_capture_send(seat2_buf), now_ms=1000, persona_key="setsu", + ) + registry.assign( + "npc_g1_seat2", seat=2, game_id="g1", phase_id="g1::day1::DAY_VOTE::1" + ) + registry.register( + npc_id="npc_g2_seat3", discord_bot_user_id="bot3", + supported_voices=(), version="1", + send=_capture_send(seat3_buf), now_ms=1000, persona_key="gina", + ) + registry.assign( + "npc_g2_seat3", seat=3, game_id="g2", phase_id="g2::day1::DAY_VOTE::1" + ) + + dispatcher = NpcDecisionDispatcher( + registry=registry, + # Long TTL so the test isn't racing the timeout path; cleanup must + # win regardless. + config=DecisionDispatcherConfig(request_ttl_ms=60_000), + now_ms=lambda: 1_000, + ) + + async def _drive(game_id: str, voter_seat: int) -> dict[int, int | None]: + return await dispatcher.dispatch_votes( + game_id=game_id, day=1, round_=0, + voters=[Player(seat_no=voter_seat, role=Role.VILLAGER, alive=True)], + seats=_seats(), + candidate_seats=[1, 2, 3], + ) + + g1_task = asyncio.create_task(_drive("g1", 2)) + g2_task = asyncio.create_task(_drive("g2", 3)) + # Wait until both requests have been sent. + for _ in range(100): + if seat2_buf and seat3_buf: + break + await asyncio.sleep(0.01) + assert seat2_buf and seat3_buf + + assert len(dispatcher._pending) == 2 + + swept = dispatcher.cleanup_game("g1") + assert swept == 1 + assert len(dispatcher._pending) == 1 + + g1_result = await g1_task + assert g1_result == {2: None}, "g1 voter resolves to abstain after cleanup" + assert not g2_task.done(), "g2 future must not be touched by g1 cleanup" + + # Resolve g2 normally so the test exits cleanly. + sent_g2 = DecideVoteRequest.model_validate_json(seat3_buf[0]) + await dispatcher.on_vote_decision( + VoteDecision( + ts=2_000, trace_id=sent_g2.trace_id, request_id=sent_g2.request_id, + npc_id="npc_g2_seat3", seat_no=3, target_seat=1, + ) + ) + await g2_task diff --git a/tests/test_npc_game_state.py b/tests/test_npc_game_state.py index 6d571a3..030e041 100644 --- a/tests/test_npc_game_state.py +++ b/tests/test_npc_game_state.py @@ -305,6 +305,53 @@ async def test_client_drops_update_when_no_snapshot_seen() -> None: assert "never_snapshotted" not in client.game_states +@pytest.mark.asyncio +async def test_seat_released_drops_per_game_state_and_logic_cache() -> None: + """`_on_seat_released` is the long-term cleanup hook for an NPC bot + that plays many games in one process. Without it `game_states` and + the LogicPacket cache grow unbounded across games. + + This exercises both: + - `game_states[game_id]` is popped (but other games stay). + - `_logic_cache` entries whose phase_id starts with the released + game_id are dropped (other games preserved). + """ + from wolfbot.domain.ws_messages import LogicPacket, SeatReleased + + client, _sent = _make_client_with_capture() + # Seed state for two games and two logic packets. + await client.process_message(_snapshot(game_id="g1").model_dump_json()) + await client.process_message(_snapshot(game_id="g2").model_dump_json()) + assert "g1" in client.game_states and "g2" in client.game_states + + pkt_g1 = LogicPacket( + ts=1, trace_id="t", + packet_id="lp_aaa", phase_id="g1::day1::DAY_DISCUSSION::1", + recipient_npc_id="npc_setsu", + public_state_summary="(d)", expires_at_ms=9999, + ) + pkt_g2 = LogicPacket( + ts=1, trace_id="t", + packet_id="lp_bbb", phase_id="g2::day1::DAY_DISCUSSION::1", + recipient_npc_id="npc_setsu", + public_state_summary="(d)", expires_at_ms=9999, + ) + client._logic_cache[pkt_g1.packet_id] = pkt_g1 + client._logic_cache[pkt_g2.packet_id] = pkt_g2 + + # Release g1. + msg = SeatReleased( + ts=2000, trace_id="rel-g1", + npc_id="npc_setsu", game_id="g1", reason="game_ended", + ) + await client.process_message(msg.model_dump_json()) + + assert "g1" not in client.game_states, "released game state must be dropped" + assert "g2" in client.game_states, "other-game state must be preserved" + assert "lp_aaa" not in client._logic_cache + assert "lp_bbb" in client._logic_cache + + def test_npcgamestate_constructs_with_defaults() -> None: """Sanity: the dataclass instantiates with defaults so test fixtures can build empty state without going through a snapshot.""" diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index 4aca51d..4ea9d8f 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -856,6 +856,57 @@ async def test_try_dispatch_next_records_selection_reason_silent_rotation( assert reason == "silent_rotation" +async def test_arbiter_cleanup_game_drops_only_target_game(repo: SqliteRepo) -> None: + """`cleanup_game` is wired into `_on_reactive_game_end` so a long-lived + Master process doesn't carry stale `_pending` / `_active_playback` / + `_playback_deadlines` across games. Two-game scenario: seed pending + state for both, sweep g1, verify g2 untouched. + """ + from wolfbot.master.speak_arbiter import _PendingRequest + + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, + registry=InMemoryNpcRegistry(), + discussion=discussion, + now_ms=lambda: 1_000, + ) + # Seed: two pending requests in g1, one in g2 — plus mirroring entries + # in active_playback / playback_deadlines (the gates the user-facing + # serial-speech check inspects). + arb._pending["req-g1-a"] = _PendingRequest( + request_id="req-g1-a", npc_id="npc1", seat_no=2, + phase_id="g1::day1::DAY_DISCUSSION::1", game_id="g1", + expires_at_ms=10_000, + ) + arb._pending["req-g1-b"] = _PendingRequest( + request_id="req-g1-b", npc_id="npc2", seat_no=3, + phase_id="g1::day1::DAY_DISCUSSION::1", game_id="g1", + expires_at_ms=10_000, + ) + arb._pending["req-g2-c"] = _PendingRequest( + request_id="req-g2-c", npc_id="npc3", seat_no=4, + phase_id="g2::day1::DAY_DISCUSSION::1", game_id="g2", + expires_at_ms=10_000, + ) + arb._active_playback.update({"req-g1-a", "req-g2-c"}) + arb._playback_deadlines.update({"req-g1-a": 5_000, "req-g2-c": 5_000}) + + swept = arb.cleanup_game("g1") + assert swept == 2 + # g1 entries gone everywhere. + assert "req-g1-a" not in arb._pending + assert "req-g1-b" not in arb._pending + assert "req-g1-a" not in arb._active_playback + assert "req-g1-a" not in arb._playback_deadlines + # g2 entries preserved. + assert "req-g2-c" in arb._pending + assert "req-g2-c" in arb._active_playback + assert arb._playback_deadlines["req-g2-c"] == 5_000 + + def test_logic_packet_builder_includes_co_claims_in_summary() -> None: state = PublicDiscussionState( game_id="g", From ad80f276b4d1cf0279f707eff0704ce8a6d84a6c Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 10:23:36 +0900 Subject: [PATCH 055/133] fix(reactive_voice): rotate fairly after first round (avoid last speaker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arbiter's sort key was (addressed, in_silent, seat_no). Once every alive NPC had spoken once `silent_seats` emptied and the third key — seat_no — picked the lowest seat forever. Observed live: seat 1 (Raqio) spoke 8 times in a row after the first round, monopolizing the phase until the host aborted. Add `last_speaker_seat` to `PublicDiscussionState` (populated by both the per-event update and the recovery rebuild paths) and slot a 3rd sort key into the arbiter: (addressed, in_silent, is_just_spoke, seat_no) The new `is_just_spoke` flag de-prioritizes the immediate previous speaker, so the post-first-round rotation walks NPCs LRU-style by seat number instead of pinning seat 1. Surface the new branch as `selection_reason="lru_rotation"` and re-purpose `seat_tiebreak` to mean "no other candidate" (effectively a 1-NPC degenerate case). Viewer chip text + tooltip updated to match. --- src/wolfbot/domain/discussion.py | 7 ++ src/wolfbot/master/speak_arbiter.py | 23 ++++-- src/wolfbot/services/discussion_service.py | 7 ++ tests/test_reactive_voice_master.py | 92 ++++++++++++++++++++++ viewer/src/components/PhaseSection.tsx | 5 +- 5 files changed, 127 insertions(+), 7 deletions(-) diff --git a/src/wolfbot/domain/discussion.py b/src/wolfbot/domain/discussion.py index c1d6abb..043c121 100644 --- a/src/wolfbot/domain/discussion.py +++ b/src/wolfbot/domain/discussion.py @@ -153,3 +153,10 @@ class PublicDiscussionState(BaseModel): last_addressed_seat: int | None = None last_addressed_speaker_seat: int | None = None last_addressed_text: str = "" + # Most recent non-sentinel speech_event speaker. SpeakArbiter uses this + # as a de-prioritization signal for the *next* dispatch so that once + # `silent_seats` empties (= every alive NPC has spoken once), the + # rotation can't re-pick the just-finished speaker. Without this, the + # arbiter falls back to seat-number tiebreak and seat 1 monopolizes + # the rest of the phase. + last_speaker_seat: int | None = None diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index f0d5c97..d014551 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -623,25 +623,33 @@ async def try_dispatch_next(self, game_id: str) -> None: if state is None: return - # Pick the next NPC. Priority order, applied as a 3-key sort: + # Pick the next NPC. Priority order, applied as a 4-key sort: # 1. addressed seat — if a recent human utterance carries # `addressed_seat_no`, that NPC must reply before anyone else. # 2. silent seats — NPCs who haven't yet spoken in this phase win # over those who have. Without this the lowest-seat NPC would # monopolize pure-NPC games where no human speech triggers - # rotation. Once every alive NPC has spoken the bucket becomes - # a no-op and order falls back to seat number. - # 3. lowest assigned_seat as a stable tiebreaker. + # rotation. + # 3. NOT the immediate previous speaker — once `silent_seats` + # empties (= every alive NPC has spoken once), this is the + # only thing that prevents seat 1 from looping forever. The + # reactive_voice mode previously fell straight through to + # seat number, so seat 1 monopolized the rest of the phase. + # 4. lowest assigned_seat as a stable tiebreaker. addressed = state.last_addressed_seat + last_speaker = state.last_speaker_seat online = self.registry.all_online() - def _pick_key(e: object) -> tuple[int, int, int]: + def _pick_key(e: object) -> tuple[int, int, int, int]: seat = getattr(e, "assigned_seat", None) or 99 is_addressed = 0 if ( addressed is not None and seat == addressed ) else 1 in_silent = 0 if seat in state.silent_seats else 1 - return (is_addressed, in_silent, seat) + is_just_spoke = 1 if ( + last_speaker is not None and seat == last_speaker + ) else 0 + return (is_addressed, in_silent, is_just_spoke, seat) online_npc_seats = sorted( e.assigned_seat @@ -653,6 +661,7 @@ def _pick_key(e: object) -> tuple[int, int, int]: "day": state.day, "phase": game.phase.value, "last_addressed_seat": addressed, + "last_speaker_seat": last_speaker, "silent_seats": sorted(state.silent_seats), "alive_seat_nos": sorted(state.alive_seat_nos), "online_npc_seats": online_npc_seats, @@ -668,6 +677,8 @@ def _pick_key(e: object) -> tuple[int, int, int]: reason = "addressed" elif seat in state.silent_seats: reason = "silent_rotation" + elif last_speaker is not None and seat != last_speaker: + reason = "lru_rotation" else: reason = "seat_tiebreak" await self.dispatch_request( diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py index f83c403..698a08a 100644 --- a/src/wolfbot/services/discussion_service.py +++ b/src/wolfbot/services/discussion_service.py @@ -523,6 +523,9 @@ def apply_speech_event( last_addressed_speaker_seat = speaker last_addressed_text = event.text + last_speaker_seat = ( + speaker if speaker is not None else state.last_speaker_seat + ) return PublicDiscussionState( game_id=state.game_id, phase_id=state.phase_id, @@ -537,6 +540,7 @@ def apply_speech_event( last_addressed_seat=last_addressed_seat, last_addressed_speaker_seat=last_addressed_speaker_seat, last_addressed_text=last_addressed_text, + last_speaker_seat=last_speaker_seat, ) @@ -587,11 +591,13 @@ def rebuild_public_state_from_events( last_addressed_seat: int | None = None last_addressed_speaker_seat: int | None = None last_addressed_text: str = "" + last_speaker_seat: int | None = None for event in events: if event.source == SpeechSource.PHASE_BASELINE: continue if event.speaker_seat is not None: spoken_seats.add(event.speaker_seat) + last_speaker_seat = event.speaker_seat recent_ids.append(event.event_id) if event.source == SpeechSource.NPC_GENERATED: last_addressed_seat = None @@ -624,6 +630,7 @@ def rebuild_public_state_from_events( state.last_addressed_seat = last_addressed_seat state.last_addressed_speaker_seat = last_addressed_speaker_seat state.last_addressed_text = last_addressed_text + state.last_speaker_seat = last_speaker_seat return state diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index 4ea9d8f..eb0cfc1 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -856,6 +856,98 @@ async def test_try_dispatch_next_records_selection_reason_silent_rotation( assert reason == "silent_rotation" +async def test_try_dispatch_next_avoids_immediate_repeat_after_first_round( + repo: SqliteRepo, +) -> None: + """Once `silent_seats` is empty (every alive NPC spoke once), the + arbiter must not re-pick the most recent speaker. Without this guard + the lowest-seat NPC monopolizes the rest of the phase — observed in + the wild where seat 1 (Raqio) spoke 8 times in a row after the + rotation. + """ + g = Game( + id="rv-lru", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, + is_llm=True, persona_key="raqio"), + Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, + is_llm=True, persona_key="setsu"), + Seat(seat_no=3, display_name="🟣ジナ", discord_user_id=None, + is_llm=True, persona_key="gina"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + await repo.set_player_role(g.id, 3, Role.VILLAGER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], created_at_ms=1, + ) + ) + # Simulate a complete first round: every alive NPC spoke once. Last + # speaker is seat 1 (Raqio). silent_seats becomes empty. + from wolfbot.services.discussion_service import make_npc_generated_event + + for ts, seat_no, text in ( + (10, 2, "セツ first"), + (20, 3, "ジナ first"), + (30, 1, "ラキオ first"), # ラキオ is the most recent speaker + ): + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat_no, text=text, created_at_ms=ts, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"raqio": [], "setsu": [], "gina": []} + for npc_id, persona, seat_no in ( + ("npc_raqio", "raqio", 1), + ("npc_setsu", "setsu", 2), + ("npc_gina", "gina", 3), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", + supported_voices=(), version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 2000, + ) + await arb.try_dispatch_next(g.id) + + assert not bufs["raqio"], ( + "ラキオ was the immediate previous speaker — must NOT be re-picked" + ) + # Seat 2 (Setsu) wins by lowest seat among non-last-speakers. + assert bufs["setsu"], "expected next pick to land on seat 2 Setsu" + assert not bufs["gina"] + + async def test_arbiter_cleanup_game_drops_only_target_game(repo: SqliteRepo) -> None: """`cleanup_game` is wired into `_on_reactive_game_end` so a long-lived Master process doesn't carry stale `_pending` / `_active_playback` / diff --git a/viewer/src/components/PhaseSection.tsx b/viewer/src/components/PhaseSection.tsx index 5fb539b..40105c8 100644 --- a/viewer/src/components/PhaseSection.tsx +++ b/viewer/src/components/PhaseSection.tsx @@ -450,6 +450,7 @@ function TimeCell({ time }: { time: string }) { const ARBITER_REASON_JA: Record = { addressed: "指名", silent_rotation: "未発言ローテ", + lru_rotation: "LRO ローテ", seat_tiebreak: "席順", }; @@ -458,8 +459,10 @@ const ARBITER_REASON_TIP: Record = { "直前の発言で addressed_seat_no がこの NPC の席だったため最優先で選ばれた", silent_rotation: "このフェーズでまだ発言していない NPC を優先して選んだ", + lru_rotation: + "全員が一度発言済み — 直前の話者を除外し、席番号の若い順で選んだ", seat_tiebreak: - "全員が一度発言済み — 席番号の若い順で選んだ", + "他の候補が無く、席番号の若い順で選んだ (通常 1 NPC のみ生存時の縮退)", }; function ArbiterChip({ decision }: { decision: ArbiterDecision }) { From 82fc6d4d5294c86a0978388be54af5f32c0e80a4 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 10:34:18 +0900 Subject: [PATCH 056/133] feat(reactive_voice): NPC speeches emit addressed_seat_no like humans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Human voice goes through GeminiAudioAnalyzer which emits addressed_name → resolved to a seat → fed into the arbiter's `addressed` priority bucket so the named seat speaks next. NPC speeches had no equivalent: the structured-output schema lacked addressed_seat_no and the state fold cleared `last_addressed_seat` on every NPC utterance. Live result: Raqio said "席9 ユリコ…" 8 times in a row because the address pointer was wiped after his first try and Yuriko was never prioritized. Mirror the human path end-to-end: 1. NPC LLM response schema (`_RESPONSE_SCHEMA` + DeepSeek json contract) now carries `addressed_seat_no: int | null`. System prompt instructs the model to set it when targeting a specific seat. 2. `_build_speech_from_json` extracts the field; `NpcGeneratedSpeech` carries it through `NpcSpeechService.respond` into `SpeakResult`, with self-address (==speaker) silently dropped. 3. `SpeakResult` ws message gains `addressed_seat_no`. Master arbiter persists it on the `SpeechEvent`, after validating alive + non-self so a hallucinated seat number can't poison routing. 4. `_apply_event_to_state` + `rebuild_public_state_from_events` no longer wipe the standing address on every NPC speech. New rules: - any speech (human OR NPC) with addressed_seat_no overrides the pointer. - an NPC whose speaker_seat matches the pending address consumes it (= addressee replied). - all other NPC speech keeps the address pending. Three new tests cover the regression scenarios the production game hit. --- src/wolfbot/domain/ws_messages.py | 12 +++ src/wolfbot/master/speak_arbiter.py | 27 +++++ .../npc/openai_compatible_generator.py | 35 ++++++- src/wolfbot/npc/speech_service.py | 9 ++ src/wolfbot/services/discussion_service.py | 50 +++++++--- tests/test_addressed_npc_routing.py | 99 +++++++++++++++++++ 6 files changed, 216 insertions(+), 16 deletions(-) diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index b7e9c40..563ae44 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -210,6 +210,18 @@ class SpeakResult(BaseEnvelope): "generator. Authoritative — Master persists it on SpeechEvent." ), ) + addressed_seat_no: int | None = Field( + default=None, + description=( + "Seat number this utterance is directed at, mirroring the " + "field humans get via voice STT analysis. Master persists it " + "on SpeechEvent so the arbiter can prioritize the addressed " + "seat on the next dispatch (same code path as human address " + "routing). None for general remarks not aimed at a specific " + "seat. Self-address (==speaker_seat) is silently dropped on " + "the Master side." + ), + ) # ------------------------- Phase D: per-seat decision delegation diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index d014551..1db48b5 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -484,6 +484,32 @@ async def _record_rejection(reason: str) -> None: failure_reason=None, received_at_ms=now, ) + # Pull `addressed_seat_no` from the NPC's structured output so the + # next dispatch can prioritize the named seat the same way human + # voice does (via the analyzer's addressed_name → seat resolve). + # Validate alive + non-self at the boundary so a hallucinated or + # out-of-roster seat number can't poison the address routing. + addressed_seat_no = result.addressed_seat_no + if addressed_seat_no is not None: + if addressed_seat_no == pending.seat_no: + addressed_seat_no = None + else: + try: + alive_seats = await self.repo.load_players(pending.game_id) + alive_set = {p.seat_no for p in alive_seats if p.alive} + except Exception: + log.exception( + "addressed_seat_alive_check_failed game=%s", + pending.game_id, + ) + alive_set = set() + if addressed_seat_no not in alive_set: + log.info( + "npc_addressed_seat_unknown game=%s seat=%d " + "addressed=%s — dropped", + pending.game_id, pending.seat_no, addressed_seat_no, + ) + addressed_seat_no = None speech_event = SpeechEvent( event_id=new_event_id(), game_id=pending.game_id, @@ -495,6 +521,7 @@ async def _record_rejection(reason: str) -> None: speaker_seat=pending.seat_no, text=result.text, co_declaration=result.co_declaration, + addressed_seat_no=addressed_seat_no, created_at_ms=now, ) await self.discussion.record(speech_event) diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index 4ed866e..af58d34 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -56,7 +56,13 @@ "schema": { "type": "object", "additionalProperties": False, - "required": ["text", "intent", "used_logic_ids", "co_declaration"], + "required": [ + "text", + "intent", + "used_logic_ids", + "co_declaration", + "addressed_seat_no", + ], "properties": { "text": {"type": "string", "maxLength": 300}, "intent": { @@ -71,6 +77,16 @@ "type": ["string", "null"], "enum": [*CO_CLAIM_VALUES, None], }, + "addressed_seat_no": { + "type": ["integer", "null"], + "description": ( + "Seat number this utterance is directed at. Mirror the " + "field human voice analysis emits so Master can route " + "the next dispatch via the same arbiter priority " + "(addressed > silent_rotation > lru_rotation > seat). " + "Use null for general remarks aimed at the whole table." + ), + }, }, }, } @@ -128,6 +144,11 @@ def _build_system( "`text` は「実は私、占い師なんだ」など自然な名乗りにする。" "CO しないなら `co_declaration=null`。" "「占いCO」のような語そのものは `text` に書かない。\n" + "- 特定の席に向けて話す場合は `addressed_seat_no` にその席番号を入れる " + "(例: 席3 に問いかけるなら `3`)。" + "誰宛でもない一般的な発言や全体への呼びかけは `null`。" + "自分の席を指定しても無効化されるので、相手の席を必ず入れること。" + "`text` 中で席番号や名前を呼んだ場合はそれと一致させる。\n" ) @@ -266,9 +287,11 @@ def _format_candidate(c: LogicCandidate) -> str: - "intent": "speak" | "agree" | "disagree" | "question" | "accuse" | "defend" | "skip" - "used_logic_ids": string の配列 (空配列でもよい) - "co_declaration": "seer" | "medium" | "knight" | null +- "addressed_seat_no": integer | null (特定の席に向けて話すときその席番号、一般発言は null) 例: -{"text": "私もそこは引っかかってた。", "intent": "agree", "used_logic_ids": [], "co_declaration": null} +{"text": "私もそこは引っかかってた。", "intent": "agree", "used_logic_ids": [], "co_declaration": null, "addressed_seat_no": null} +{"text": "席3、それは矛盾してるよ。", "intent": "accuse", "used_logic_ids": [], "co_declaration": null, "addressed_seat_no": 3} """ @@ -574,6 +597,13 @@ def _build_speech_from_json(data: dict[str, object]) -> NpcGeneratedSpeech | Non ) co_raw = data.get("co_declaration") co_declaration = co_raw if co_raw in CO_CLAIM_VALUES else None + # `addressed_seat_no` is optional on older provider responses (the + # field was added in 2026-04 to mirror human voice analysis); coerce + # to int|None and silently drop garbage rather than fail the speech. + raw_addr = data.get("addressed_seat_no") + addressed_seat_no: int | None = None + if isinstance(raw_addr, int) and not isinstance(raw_addr, bool): + addressed_seat_no = raw_addr # Rough estimate: ~150ms per character for TTS estimated_ms = max(500, len(text) * 150) @@ -583,6 +613,7 @@ def _build_speech_from_json(data: dict[str, object]) -> NpcGeneratedSpeech | Non used_logic_ids=used_ids, estimated_duration_ms=estimated_ms, co_declaration=co_declaration, + addressed_seat_no=addressed_seat_no, ) diff --git a/src/wolfbot/npc/speech_service.py b/src/wolfbot/npc/speech_service.py index 2ee28eb..4a74b94 100644 --- a/src/wolfbot/npc/speech_service.py +++ b/src/wolfbot/npc/speech_service.py @@ -28,6 +28,7 @@ class NpcGeneratedSpeech: used_logic_ids: tuple[str, ...] estimated_duration_ms: int co_declaration: str | None = None + addressed_seat_no: int | None = None @runtime_checkable @@ -123,6 +124,13 @@ async def respond( co_declaration: CoDeclaration | None = None if speech.co_declaration in CO_CLAIM_VALUES: co_declaration = speech.co_declaration # type: ignore[assignment] + # Drop self-address (==speaker_seat) defensively. We can't validate + # alive / on-roster here without seat data; Master applies the + # alive/self-filter when persisting, so a hallucinated seat number + # is filtered out at the boundary, not silently dropped here. + addressed_seat_no = speech.addressed_seat_no + if addressed_seat_no is not None and addressed_seat_no == request.seat_no: + addressed_seat_no = None return SpeakResult( ts=now_ms, trace_id=request.trace_id, @@ -135,6 +143,7 @@ async def respond( intent=speech.intent, estimated_duration_ms=speech.estimated_duration_ms, co_declaration=co_declaration, + addressed_seat_no=addressed_seat_no, ) diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py index 698a08a..4db9ee1 100644 --- a/src/wolfbot/services/discussion_service.py +++ b/src/wolfbot/services/discussion_service.py @@ -257,6 +257,7 @@ def make_npc_generated_event( speaker_seat: int, text: str, co_declaration: str | None = None, + addressed_seat_no: int | None = None, created_at_ms: int | None = None, ) -> SpeechEvent: return SpeechEvent( @@ -270,6 +271,7 @@ def make_npc_generated_event( speaker_seat=speaker_seat, text=text, co_declaration=co_declaration, + addressed_seat_no=addressed_seat_no, created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), ) @@ -507,21 +509,33 @@ def apply_speech_event( recent = [*state.recent_speech_event_ids, event.event_id][-10:] - # An NPC utterance answers any pending address; a fresh human address - # supersedes prior ones. Anything else (other human speech without an - # `addressed_seat_no`) keeps the standing address — humans often cluster - # short sentences and we don't want a generic follow-up to clear the hint. + # Address routing rules. The arbiter consumes `last_addressed_seat` + # to prioritize whoever was just called out, so we have to be careful + # about *when* it's cleared: + # + # - Human or NPC speech with its own `addressed_seat_no` always + # supersedes the prior address (= a fresh call-out wins). + # - An NPC speaks but the prior addressee is themselves (= the + # addressed seat replied) → consume the address. + # - Anything else keeps the standing address. Without this, e.g. a + # silent_rotation pick that "jumps the line" before the addressed + # NPC replies would silently clear the hint and the addressee + # never gets prioritized. last_addressed_seat = state.last_addressed_seat last_addressed_speaker_seat = state.last_addressed_speaker_seat last_addressed_text = state.last_addressed_text - if event.source == SpeechSource.NPC_GENERATED: - last_addressed_seat = None - last_addressed_speaker_seat = None - last_addressed_text = "" - elif event.addressed_seat_no is not None: + if event.addressed_seat_no is not None: last_addressed_seat = event.addressed_seat_no last_addressed_speaker_seat = speaker last_addressed_text = event.text + elif ( + event.source == SpeechSource.NPC_GENERATED + and speaker is not None + and speaker == state.last_addressed_seat + ): + last_addressed_seat = None + last_addressed_speaker_seat = None + last_addressed_text = "" last_speaker_seat = ( speaker if speaker is not None else state.last_speaker_seat @@ -599,14 +613,22 @@ def rebuild_public_state_from_events( spoken_seats.add(event.speaker_seat) last_speaker_seat = event.speaker_seat recent_ids.append(event.event_id) - if event.source == SpeechSource.NPC_GENERATED: - last_addressed_seat = None - last_addressed_speaker_seat = None - last_addressed_text = "" - elif event.addressed_seat_no is not None: + # Mirror the per-event update logic in `_apply_event_to_state`: + # only consume the standing address when the NPC speaker IS the + # previously addressed seat (= the addressee replied). A new + # `addressed_seat_no` always overrides the prior pointer. + if event.addressed_seat_no is not None: last_addressed_seat = event.addressed_seat_no last_addressed_speaker_seat = event.speaker_seat last_addressed_text = event.text + elif ( + event.source == SpeechSource.NPC_GENERATED + and event.speaker_seat is not None + and event.speaker_seat == last_addressed_seat + ): + last_addressed_seat = None + last_addressed_speaker_seat = None + last_addressed_text = "" if event.speaker_seat is None: continue role_key = _resolve_co_role(event) diff --git a/tests/test_addressed_npc_routing.py b/tests/test_addressed_npc_routing.py index 5645a0e..0189b90 100644 --- a/tests/test_addressed_npc_routing.py +++ b/tests/test_addressed_npc_routing.py @@ -718,3 +718,102 @@ async def test_end_to_end_addressed_dispatch(repo: SqliteRepo) -> None: assert any("ジナさん" in m for m in buf3) +# -- Layer 5: NPC's own addressed_seat_no propagates through state ---- + + +def test_npc_speech_with_addressed_seat_sets_last_addressed() -> None: + """When an NPC's structured output sets `addressed_seat_no`, the + PublicDiscussionState fold must surface it as `last_addressed_seat` + so the next arbiter dispatch prioritizes the addressee. Reproduces + the production bug where Raqio (seat 1) said "席9 ユリコ…" 8 times + in a row because every NPC speech cleared the address pointer. + """ + phase_id = make_phase_id("g", 1, Phase.DAY_DISCUSSION) + events = [ + make_phase_baseline( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 9], created_at_ms=1, + ), + # NPC at seat 1 names seat 9 — this used to clear last_addressed. + make_npc_generated_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="席9ユリコ、君が処刑候補だ", + addressed_seat_no=9, created_at_ms=10, + ), + ] + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.last_addressed_seat == 9, ( + "NPC's addressed_seat_no must propagate to last_addressed_seat" + ) + assert state.last_addressed_speaker_seat == 1 + + +def test_unrelated_npc_speech_does_not_clear_pending_address() -> None: + """If NPC A addresses seat X and a different NPC B jumps in (without + naming anyone), the pending address must NOT be cleared — otherwise + the arbiter loses the cue to prioritize X next. Pre-fix, every NPC + speech wiped last_addressed_seat unconditionally. + """ + phase_id = make_phase_id("g", 1, Phase.DAY_DISCUSSION) + events = [ + make_phase_baseline( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 9], created_at_ms=1, + ), + make_npc_generated_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="席9ユリコ、説明しろ", + addressed_seat_no=9, created_at_ms=10, + ), + # Seat 2 jumps in without naming anyone — must not consume the + # address pending for seat 9. + make_npc_generated_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, text="一旦落ち着こう", + addressed_seat_no=None, created_at_ms=20, + ), + ] + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.last_addressed_seat == 9, ( + "Unrelated NPC speech must not clear the pending address" + ) + + +def test_addressed_npc_reply_consumes_the_address() -> None: + """When the addressed NPC actually replies (speaker_seat == prior + last_addressed_seat) without naming a new target, the address is + consumed so the arbiter doesn't keep prioritizing them forever.""" + phase_id = make_phase_id("g", 1, Phase.DAY_DISCUSSION) + events = [ + make_phase_baseline( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 9], created_at_ms=1, + ), + make_npc_generated_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="席9ユリコ、答えろ", + addressed_seat_no=9, created_at_ms=10, + ), + # Yuriko (seat 9) replies — this consumes the address. + make_npc_generated_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=9, text="言いがかりだ", + addressed_seat_no=None, created_at_ms=20, + ), + ] + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.last_addressed_seat is None + assert state.last_addressed_speaker_seat is None + + From b979d50a2776d41a4dfe1c21648ce1f49abf454d Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 10:45:36 +0900 Subject: [PATCH 057/133] fix(reactive_voice): hydrate snapshot with persisted role-specific history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_push_private_state_snapshot` was building `PrivateStateSnapshot` with empty seer_results / medium_results / guard_history / wolf_chat_history even though the data was already in `logs_private` and `night_actions`. The seer NPC's day-1 user prompt therefore had no `## 自分の占い結果` block at all, so the strategy text "初日ランダム白を出して早めに CO する" had nothing to back it up and the seer correctly chose silence over fabrication. Live result: nobody declared seer on day 1. Add `load_private_state_for_seat` in `master/private_state.py` that walks: * `logs_private` for SEER_RESULT_NIGHT0 / SEER_RESULT / MEDIUM_RESULT / WOLF_CHAT entries, parsing the canonical Japanese text format with role-specific regexes and resolving target names back to seats. * `night_actions` for KNIGHT_GUARD entries, joined with the next day's MORNING public log for `peaceful_morning`. Wire it into `_push_private_state_snapshot` so every snapshot — at game start, on phase entry, after Master restart — carries the full role history the NPC's prompt builder uses to populate the per-role private blocks. Idempotent push from the seat-assignment self-heal keeps this current across phases. --- src/wolfbot/main.py | 25 ++- src/wolfbot/master/private_state.py | 257 +++++++++++++++++++++++++++- tests/test_master_private_state.py | 177 ++++++++++++++++++- 3 files changed, 452 insertions(+), 7 deletions(-) diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 5ebac14..ee063f8 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -286,7 +286,10 @@ async def _push_private_state_snapshot( skipped; the NPC will see the historical empty-state behavior on decision requests until the next snapshot opportunity (re-register). """ - from wolfbot.master.private_state import build_snapshot_for_seat + from wolfbot.master.private_state import ( + build_snapshot_for_seat, + load_private_state_for_seat, + ) try: game = await repo.load_game(game_id) @@ -311,6 +314,22 @@ async def _push_private_state_snapshot( npc_id, seat_no, game_id, ) return + # Pull persisted role-specific history (NIGHT_0 random white, + # past divinations / mediums / guards / wolf chat) from the + # logs_private + night_actions tables. Without this the seer + # NPC sees no concrete data to CO with on day 1, even though + # the strategy block tells them to declare early — which is + # exactly the day-1 silence the live game hit. + seer_results, medium_results, guard_history, wolf_chat_history = ( + await load_private_state_for_seat( + repo, + game_id=game_id, + seat_no=seat_no, + role=me.role, + players=players, + seats=seats, + ) + ) snapshot = build_snapshot_for_seat( npc_id=npc_id, game_id=game_id, @@ -320,6 +339,10 @@ async def _push_private_state_snapshot( day_number=game.day_number, players=players, seats=seats, + seer_results=seer_results, + medium_results=medium_results, + guard_history=guard_history, + wolf_chat_history=wolf_chat_history, ts=int(asyncio.get_running_loop().time() * 1000), trace_id=f"snapshot-{game_id}-{seat_no}", ) diff --git a/src/wolfbot/master/private_state.py b/src/wolfbot/master/private_state.py index 4887d64..cdb0e73 100644 --- a/src/wolfbot/master/private_state.py +++ b/src/wolfbot/master/private_state.py @@ -8,19 +8,30 @@ This module turns Master DB rows into the WS payloads: * :func:`build_snapshot_for_seat` — full state replace, sent at game - start and on NPC re-register. + start and on NPC re-register. Pure function of supplied data. +* :func:`load_private_state_for_seat` — async helper that reads + ``logs_private`` + ``night_actions`` and rebuilds the per-role + histories the snapshot carries (seer / medium / guard / wolf-chat). + Used by ``main.py`` so a snapshot push after a Master restart or + a delayed first-phase entry recovers the seer's day-0 random white, + the medium's past results, the knight's guard history, and the + wolves' chat log. * update factories (e.g. :func:`make_seer_result_update`) — per-event patches sent as Master computes new private results. -Pure functions of repo data + ws_messages — no I/O, no asyncio. The -callers (main.py / arbiter / state-machine glue) own the WS send. +The *snapshot builder* stays a pure function; the *loader* does I/O +against the repo. Callers in main.py / arbiter / state-machine glue +own the WS send. """ from __future__ import annotations +import logging +import re from collections.abc import Sequence +from typing import TYPE_CHECKING -from wolfbot.domain.enums import Role +from wolfbot.domain.enums import Role, SubmissionType from wolfbot.domain.models import Player, Seat from wolfbot.domain.ws_messages import ( GuardEntry, @@ -31,6 +42,11 @@ WolfChatLine, ) +if TYPE_CHECKING: + from wolfbot.persistence.sqlite_repo import SqliteRepo + +log = logging.getLogger(__name__) + def _seat_pairs( players: Sequence[Player], @@ -113,6 +129,238 @@ def build_snapshot_for_seat( ) +# --- DB → snapshot history loader ------------------------------------ + +# Private logs persist results as natural Japanese text; the structured +# fields (target_seat, is_wolf) are not stored in dedicated columns. +# Parse them back out here so the NPC's snapshot can carry the same +# info rounds-mode prompt builds get from raw text reads. +_SEER_NIGHT0_RE = re.compile(r"^初日ランダム白:\s*(?P.+?)\s+は") +_SEER_RESULT_RE = re.compile(r"^占い結果:\s*(?P.+?)\s+は") +_MEDIUM_RESULT_RE = re.compile(r"^霊媒結果:\s*(?P.+?)\s+は") + + +def _resolve_seat_by_name( + name: str, seats_by_no: dict[int, Seat] +) -> tuple[int, str] | None: + """Match a parsed display name back to a seat. Tries exact match + first, then strips a leading emoji+space prefix as a fall-back so + the seer's NIGHT_0 random-white text "👑ユリコ" still resolves + when display_name lookup uses a different normalization.""" + for seat in seats_by_no.values(): + if seat.display_name == name: + return (seat.seat_no, seat.display_name) + # Fall-back: try stripping leading non-word characters (emoji etc.) + stripped = name.lstrip() + while stripped and not (stripped[0].isalnum() or "ぁ" <= stripped[0] <= "ヿ"): + stripped = stripped[1:] + if stripped: + for seat in seats_by_no.values(): + if seat.display_name.endswith(stripped): + return (seat.seat_no, seat.display_name) + return None + + +async def load_private_state_for_seat( + repo: SqliteRepo, + *, + game_id: str, + seat_no: int, + role: Role, + players: Sequence[Player], + seats: Sequence[Seat], +) -> tuple[ + tuple[SeerResult, ...], + tuple[MediumResult, ...], + tuple[GuardEntry, ...], + tuple[WolfChatLine, ...], +]: + """Rebuild role-specific history for a seat from persisted DB rows. + + Returns ``(seer_results, medium_results, guard_history, + wolf_chat_history)``. Each tuple is non-empty only when the role + actually owns that history: + + * ``seer_results`` — populated for ``Role.SEER`` from + SEER_RESULT_NIGHT0 + SEER_RESULT private logs. + * ``medium_results`` — populated for ``Role.MEDIUM`` from + MEDIUM_RESULT private logs. + * ``guard_history`` — populated for ``Role.KNIGHT`` from + ``night_actions`` (KNIGHT_GUARD entries) joined with the + MORNING public log to derive ``peaceful_morning``. + * ``wolf_chat_history`` — populated for ``Role.WEREWOLF`` from + WOLF_CHAT private logs (every wolf seat sees them via the + ``audience_seat IS NULL`` branch of ``load_private_logs``). + + Best-effort: a parse failure on one row is logged and skipped, not + fatal — a partially populated snapshot is still strictly better + than the previous always-empty default. + """ + seats_by_no = {s.seat_no: s for s in seats} + + seer_results: list[SeerResult] = [] + medium_results: list[MediumResult] = [] + wolf_chat_history: list[WolfChatLine] = [] + guard_history: list[GuardEntry] = [] + + if role in (Role.SEER, Role.MEDIUM): + try: + rows = await repo.load_private_logs_for_audience( + game_id, audience_seat=seat_no, limit=200, + ) + except Exception: + log.exception( + "private_state_load_failed_seer_medium game=%s seat=%d", + game_id, seat_no, + ) + rows = [] + for r in rows: + day = int(r["day"]) + text = str(r["text"]) + kind = r["kind"] + if role is Role.SEER and kind == "SEER_RESULT_NIGHT0": + m = _SEER_NIGHT0_RE.match(text) + if m is None: + continue + resolved = _resolve_seat_by_name(m.group("name"), seats_by_no) + if resolved is None: + continue + target_seat, target_name = resolved + seer_results.append( + SeerResult( + day=day, target_seat=target_seat, + target_name=target_name, is_wolf=False, + ) + ) + elif role is Role.SEER and kind == "SEER_RESULT": + m = _SEER_RESULT_RE.match(text) + if m is None: + continue + resolved = _resolve_seat_by_name(m.group("name"), seats_by_no) + if resolved is None: + continue + target_seat, target_name = resolved + is_wolf = "ありません" not in text + seer_results.append( + SeerResult( + day=day, target_seat=target_seat, + target_name=target_name, is_wolf=is_wolf, + ) + ) + elif role is Role.MEDIUM and kind == "MEDIUM_RESULT": + m = _MEDIUM_RESULT_RE.match(text) + if m is None: + # "本日の霊媒結果はありません(処刑なし)。" — just skip. + continue + resolved = _resolve_seat_by_name(m.group("name"), seats_by_no) + if resolved is None: + continue + target_seat, target_name = resolved + is_wolf = "ありませんでした" not in text + medium_results.append( + MediumResult( + day=day, target_seat=target_seat, + target_name=target_name, is_wolf=is_wolf, + ) + ) + + if role is Role.WEREWOLF: + try: + rows = await repo.load_private_logs_for_audience( + game_id, audience_seat=seat_no, limit=200, + ) + except Exception: + log.exception( + "private_state_load_failed_wolf_chat game=%s seat=%d", + game_id, seat_no, + ) + rows = [] + for r in rows: + if r["kind"] != "WOLF_CHAT": + continue + speaker_seat = r["actor_seat"] + if speaker_seat is None or speaker_seat not in seats_by_no: + continue + speaker_name = seats_by_no[speaker_seat].display_name + wolf_chat_history.append( + WolfChatLine( + day=int(r["day"]), + speaker_seat=int(speaker_seat), + speaker_name=speaker_name, + text=str(r["text"]), + ) + ) + + if role is Role.KNIGHT: + # Knight's guard targets come from the night_actions table for + # every day the knight has been alive. Each guard entry's + # ``peaceful_morning`` is derived from the next day's MORNING + # public log. + for p in players: + if p.seat_no == seat_no and p.role is Role.KNIGHT: + break + else: + return ( + tuple(seer_results), tuple(medium_results), + tuple(guard_history), tuple(wolf_chat_history), + ) + try: + public_logs = await repo.load_public_logs(game_id, limit=200) + except Exception: + log.exception( + "private_state_load_failed_public_logs game=%s", game_id, + ) + public_logs = [] + morning_by_day: dict[int, bool] = {} + for log_row in public_logs: + if log_row.get("kind") == "MORNING": + # MORNING is emitted for the *new* day after a NIGHT + # resolution; the guard it resolves was submitted on + # ``day - 1``. Stash by submission day. + resolved_day = int(log_row.get("day", 0)) - 1 + if resolved_day < 0: + continue + peaceful = "平和な朝" in str(log_row.get("text", "")) + morning_by_day[resolved_day] = peaceful + try: + seen_days: set[int] = set() + # 9-player game runs at most a handful of nights; loading per-day is fine. + for day in range(0, 30): + actions = await repo.load_night_actions(game_id, day=day) + for a in actions: + if ( + a.actor_seat == seat_no + and a.kind is SubmissionType.KNIGHT_GUARD + and a.target_seat is not None + and a.target_seat in seats_by_no + and day not in seen_days + ): + seen_days.add(day) + guard_history.append( + GuardEntry( + day=day, + target_seat=a.target_seat, + target_name=seats_by_no[a.target_seat].display_name, + peaceful_morning=morning_by_day.get(day), + ) + ) + if not actions and day > 1: + # No actions ⇒ later days won't have any either. + break + except Exception: + log.exception( + "private_state_load_failed_guard game=%s seat=%d", + game_id, seat_no, + ) + + return ( + tuple(seer_results), + tuple(medium_results), + tuple(guard_history), + tuple(wolf_chat_history), + ) + + def make_seer_result_update( *, npc_id: str, @@ -300,6 +548,7 @@ def make_day_advanced_update( __all__ = [ "build_snapshot_for_seat", + "load_private_state_for_seat", "make_alive_changed_update", "make_day_advanced_update", "make_guard_entry_update", diff --git a/tests/test_master_private_state.py b/tests/test_master_private_state.py index 8c99617..7248923 100644 --- a/tests/test_master_private_state.py +++ b/tests/test_master_private_state.py @@ -12,10 +12,17 @@ from __future__ import annotations -from wolfbot.domain.enums import Role -from wolfbot.domain.models import Player, Seat +import tempfile +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest_asyncio + +from wolfbot.domain.enums import Phase, Role, SubmissionType +from wolfbot.domain.models import Game, LogEntry, NightAction, Player, Seat from wolfbot.master.private_state import ( build_snapshot_for_seat, + load_private_state_for_seat, make_alive_changed_update, make_day_advanced_update, make_guard_entry_update, @@ -25,6 +32,8 @@ make_wolf_chat_update, ) from wolfbot.npc.game_state import apply_update, state_from_snapshot +from wolfbot.persistence.schema import migrate +from wolfbot.persistence.sqlite_repo import SqliteRepo def _seats() -> list[Seat]: @@ -237,3 +246,167 @@ def test_day_advanced_update_increments_day() -> None: day_number=3, ts=2, trace_id="t2", )) assert state.day_number == 3 + + +# ---- DB → snapshot history loader ------------------------------------ + + +@pytest_asyncio.fixture +async def fresh_repo() -> AsyncIterator[SqliteRepo]: + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "test.db" + await migrate(db) + r = SqliteRepo(db) + await r.connect() + try: + yield r + finally: + await r.close() + + +async def _seed_game_with_seer(repo: SqliteRepo) -> Game: + """A minimal game with seat 5 = SEER and seat 9 = the night-0 + random-white target. Only enough rows for the loader to parse.""" + g = Game( + id="snap_seed", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=i, display_name=f"NPC{i}", is_llm=True, + persona_key=None, discord_user_id=None) + for i in range(1, 10) + ] + seats[8] = Seat( # seat 9 with emoji prefix to mirror live data + seat_no=9, display_name="👑ユリコ", is_llm=True, + persona_key="yuriko", discord_user_id=None, + ) + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 5, Role.SEER) + await repo.set_player_role(g.id, 9, Role.VILLAGER) + return g + + +async def test_load_private_state_seer_recovers_night0_random_white( + fresh_repo: SqliteRepo, +) -> None: + """The seer's NIGHT_0 random white must round-trip through the + snapshot loader. Pre-fix the snapshot was always built with + seer_results=() — Gina's day-1 user prompt had no `## 自分の占い結果` + block and she correctly chose not to fabricate a CO without data.""" + g = await _seed_game_with_seer(fresh_repo) + await fresh_repo.insert_log_private( + LogEntry( + game_id=g.id, day=0, phase=Phase.NIGHT_0, + kind="SEER_RESULT_NIGHT0", + actor_seat=None, audience_seat=5, visibility="PRIVATE", + text="初日ランダム白: 👑ユリコ は 人狼ではありません。", + created_at=1, + ) + ) + seers, mediums, guards, wolves = await load_private_state_for_seat( + fresh_repo, game_id=g.id, seat_no=5, role=Role.SEER, + players=await fresh_repo.load_players(g.id), + seats=await fresh_repo.load_seats(g.id), + ) + assert mediums == () and guards == () and wolves == () + assert len(seers) == 1 + assert seers[0].day == 0 + assert seers[0].target_seat == 9 + assert seers[0].target_name == "👑ユリコ" + assert seers[0].is_wolf is False + + +async def test_load_private_state_seer_parses_day1_black_and_white( + fresh_repo: SqliteRepo, +) -> None: + """Day 1+ SEER_RESULT log text format ('〜 は 人狼です' / '〜 は + 人狼ではありません'). Both branches resolve to the right is_wolf.""" + g = await _seed_game_with_seer(fresh_repo) + await fresh_repo.insert_log_private( + LogEntry( + game_id=g.id, day=1, phase=Phase.NIGHT, + kind="SEER_RESULT", actor_seat=None, audience_seat=5, + visibility="PRIVATE", + text="占い結果: NPC2 は 人狼 です。", created_at=10, + ) + ) + await fresh_repo.insert_log_private( + LogEntry( + game_id=g.id, day=2, phase=Phase.NIGHT, + kind="SEER_RESULT", actor_seat=None, audience_seat=5, + visibility="PRIVATE", + text="占い結果: NPC3 は 人狼ではありません。", created_at=20, + ) + ) + seers, _m, _g, _w = await load_private_state_for_seat( + fresh_repo, game_id=g.id, seat_no=5, role=Role.SEER, + players=await fresh_repo.load_players(g.id), + seats=await fresh_repo.load_seats(g.id), + ) + by_day = {s.day: s for s in seers} + assert by_day[1].target_seat == 2 and by_day[1].is_wolf is True + assert by_day[2].target_seat == 3 and by_day[2].is_wolf is False + + +async def test_load_private_state_wolf_chat_history_for_wolf( + fresh_repo: SqliteRepo, +) -> None: + """Wolf NPCs see WOLF_CHAT private logs (audience_seat=NULL).""" + g = await _seed_game_with_seer(fresh_repo) + await fresh_repo.set_player_role(g.id, 1, Role.WEREWOLF) + await fresh_repo.set_player_role(g.id, 3, Role.WEREWOLF) + await fresh_repo.insert_log_private( + LogEntry( + game_id=g.id, day=0, phase=Phase.NIGHT_0, + kind="WOLF_CHAT", actor_seat=3, audience_seat=None, + visibility="PRIVATE", text="今日は席5を狙うか", created_at=5, + ) + ) + _s, _m, _gh, wolves = await load_private_state_for_seat( + fresh_repo, game_id=g.id, seat_no=1, role=Role.WEREWOLF, + players=await fresh_repo.load_players(g.id), + seats=await fresh_repo.load_seats(g.id), + ) + assert len(wolves) == 1 + assert wolves[0].speaker_seat == 3 + assert wolves[0].text == "今日は席5を狙うか" + + +async def test_load_private_state_knight_guard_history_with_morning( + fresh_repo: SqliteRepo, +) -> None: + """Knight's guard history is rebuilt from night_actions, with + peaceful_morning derived from the next day's MORNING public log.""" + g = await _seed_game_with_seer(fresh_repo) + await fresh_repo.set_player_role(g.id, 7, Role.KNIGHT) + await fresh_repo.insert_night_action( + NightAction( + game_id=g.id, day=1, actor_seat=7, + kind=SubmissionType.KNIGHT_GUARD, target_seat=5, + submitted_at=100, + ) + ) + await fresh_repo.insert_log_public( + LogEntry( + game_id=g.id, day=2, phase=Phase.DAY_DISCUSSION, + kind="MORNING", actor_seat=None, visibility="PUBLIC", + text="平和な朝です。昨晩の犠牲者はいません。", created_at=200, + ) + ) + _s, _m, guards, _w = await load_private_state_for_seat( + fresh_repo, game_id=g.id, seat_no=7, role=Role.KNIGHT, + players=await fresh_repo.load_players(g.id), + seats=await fresh_repo.load_seats(g.id), + ) + assert len(guards) == 1 + assert guards[0].day == 1 + assert guards[0].target_seat == 5 + assert guards[0].peaceful_morning is True From 3a6c5538e78e70e64148c97efd407a16ef0e8c94 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 11:12:30 +0900 Subject: [PATCH 058/133] feat(reactive_voice): demote stuck pair volleys + same-seat consecutive cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A→B→A→B between two NPCs can persist indefinitely under the existing sort key because `is_addressed` always trumps LRU rotation: ラキオ addressed ジョナス, ジョナス replies addressing ラキオ, repeat. Live result: phase entirely consumed by two NPCs ping-ponging with no info gain, host had to abort. Add two diversity gates that fire on top of the existing 4-key sort: * Pair-volley demotion (B1): when the last 4 speech events come from exactly 2 distinct seats AND none flagged structured info (`co_declaration` is None for all), demote both seats. The next pick jumps to the third party. Window N=4 = "let them volley twice before stepping in" per the user's "n は増やしていい". * Consecutive-cap demotion (A1): same seat speaking ≥ 3 in a row is demoted defensively. Mostly fires when a human keeps re-addressing the same NPC; also covers any future bug that lets the same seat dispatch repeatedly. Implementation: 1. `PublicDiscussionState.recent_speech_summary` — sliding window of `(speaker_seat, has_info)` for the last 6 speech events. Populated by both the per-event update and the recovery rebuild paths. 2. `_compute_demoted_seats(summary)` in speak_arbiter applies both gates and returns a frozenset. 3. New 5-key sort: `(is_demoted, is_addressed, in_silent, is_just_spoke, seat)`. Demoted seats fall to the bottom but are picked as a fallback when no other candidate survives (offline / dead). 4. Two new `selection_reason` values: `low_info_diversion` (picked third party because of demoted volley) and `all_demoted_fallback`. Viewer chip labels + tooltips updated. 5. `demoted_seats` added to the snapshot dict so the viewer can highlight which seats got demoted. Tests cover the gate primitives, info-delta reset (CO unfreezes the demotion), and an end-to-end pair-volley → diverted-to-3rd-party scenario. --- src/wolfbot/domain/discussion.py | 11 ++ src/wolfbot/master/speak_arbiter.py | 82 +++++++++--- src/wolfbot/services/discussion_service.py | 16 +++ tests/test_reactive_voice_master.py | 143 +++++++++++++++++++++ viewer/src/components/PhaseSection.tsx | 8 +- 5 files changed, 242 insertions(+), 18 deletions(-) diff --git a/src/wolfbot/domain/discussion.py b/src/wolfbot/domain/discussion.py index 043c121..be887f8 100644 --- a/src/wolfbot/domain/discussion.py +++ b/src/wolfbot/domain/discussion.py @@ -160,3 +160,14 @@ class PublicDiscussionState(BaseModel): # arbiter falls back to seat-number tiebreak and seat 1 monopolizes # the rest of the phase. last_speaker_seat: int | None = None + # Sliding window of `(speaker_seat, has_info)` for the most recent + # non-sentinel speech events in this phase. ``has_info`` is True when + # the event added structured information (currently: a CO declaration; + # future signals can be added without changing the field shape). + # SpeakArbiter consumes this to detect a low-information pair volley + # (e.g. ラキオ ↔ ジョナス pinging each other for several rounds without + # CO or new accusation target) and demote both seats so a third NPC + # gets picked instead. Capped at 6 entries so the window stays small + # but big enough to cover both the pair-volley check (last 4) and the + # consecutive-speaker cap (last 3). + recent_speech_summary: tuple[tuple[int, bool], ...] = () diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index 1db48b5..a2bbba8 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -67,6 +67,47 @@ # 20 is plenty for an 80-char reactive reply and keeps WS frames small. _RECENT_SPEECH_CAP = 20 +# Diversity guards on top of the addressed / silent / LRU sort key. +# - `_PAIR_VOLLEY_WINDOW`: when the last N events come from exactly 2 +# distinct seats AND none carried structured "info" (currently a +# ``co_declaration``), demote both seats so a third NPC gets to speak. +# This breaks the ラキオ ↔ ジョナス ping-pong: each pair gets to volley +# the full window before being told to step aside. +# - `_CONSECUTIVE_CAP`: same seat speaking ≥ N times in a row is also +# demoted. Mostly fires when a human keeps re-addressing the same NPC +# or when a buggy upstream tries to dispatch the same seat twice. +_PAIR_VOLLEY_WINDOW = 4 +_CONSECUTIVE_CAP = 3 + + +def _compute_demoted_seats( + summary: Sequence[tuple[int, bool]], +) -> frozenset[int]: + """Return seats that should be demoted in the next pick. + + Two independent gates, OR'd together: + + 1. Last ``_PAIR_VOLLEY_WINDOW`` events came from exactly 2 distinct + seats AND none of them flagged ``has_info`` (= no CO declared) → + both seats demoted. + 2. Last ``_CONSECUTIVE_CAP`` events all came from a single seat → + that seat demoted. + + Returns an empty set when the window is too short or no gate fires. + """ + demoted: set[int] = set() + if len(summary) >= _PAIR_VOLLEY_WINDOW: + window = list(summary)[-_PAIR_VOLLEY_WINDOW:] + seats_in_window = {seat for seat, _ in window} + any_info = any(has_info for _, has_info in window) + if len(seats_in_window) == 2 and not any_info: + demoted |= seats_in_window + if len(summary) >= _CONSECUTIVE_CAP: + tail = list(summary)[-_CONSECUTIVE_CAP:] + if len({seat for seat, _ in tail}) == 1: + demoted.add(tail[0][0]) + return frozenset(demoted) + @dataclass class SpeakArbiterConfig: @@ -650,25 +691,23 @@ async def try_dispatch_next(self, game_id: str) -> None: if state is None: return - # Pick the next NPC. Priority order, applied as a 4-key sort: - # 1. addressed seat — if a recent human utterance carries - # `addressed_seat_no`, that NPC must reply before anyone else. - # 2. silent seats — NPCs who haven't yet spoken in this phase win - # over those who have. Without this the lowest-seat NPC would - # monopolize pure-NPC games where no human speech triggers - # rotation. - # 3. NOT the immediate previous speaker — once `silent_seats` - # empties (= every alive NPC has spoken once), this is the - # only thing that prevents seat 1 from looping forever. The - # reactive_voice mode previously fell straight through to - # seat number, so seat 1 monopolized the rest of the phase. - # 4. lowest assigned_seat as a stable tiebreaker. + # Pick the next NPC. Priority order, applied as a 5-key sort: + # 1. NOT demoted — `_compute_demoted_seats` flags seats stuck + # in a low-info pair volley OR exceeding the consecutive + # speaker cap. Demoted seats fall to the bottom regardless + # of being addressed, so a 3rd NPC can break in. + # 2. addressed seat — recent utterance's `addressed_seat_no`. + # 3. silent seats — NPCs who haven't yet spoken in this phase. + # 4. NOT the immediate previous speaker (LRU rotation). + # 5. lowest assigned_seat as a stable tiebreaker. addressed = state.last_addressed_seat last_speaker = state.last_speaker_seat + demoted = _compute_demoted_seats(state.recent_speech_summary) online = self.registry.all_online() - def _pick_key(e: object) -> tuple[int, int, int, int]: + def _pick_key(e: object) -> tuple[int, int, int, int, int]: seat = getattr(e, "assigned_seat", None) or 99 + is_demoted = 1 if seat in demoted else 0 is_addressed = 0 if ( addressed is not None and seat == addressed ) else 1 @@ -676,7 +715,7 @@ def _pick_key(e: object) -> tuple[int, int, int, int]: is_just_spoke = 1 if ( last_speaker is not None and seat == last_speaker ) else 0 - return (is_addressed, in_silent, is_just_spoke, seat) + return (is_demoted, is_addressed, in_silent, is_just_spoke, seat) online_npc_seats = sorted( e.assigned_seat @@ -692,6 +731,7 @@ def _pick_key(e: object) -> tuple[int, int, int, int]: "silent_seats": sorted(state.silent_seats), "alive_seat_nos": sorted(state.alive_seat_nos), "online_npc_seats": online_npc_seats, + "demoted_seats": sorted(demoted), } for entry in sorted(online, key=_pick_key): @@ -700,12 +740,20 @@ def _pick_key(e: object) -> tuple[int, int, int, int]: if entry.assigned_seat not in state.alive_seat_nos: continue seat = entry.assigned_seat - if addressed is not None and seat == addressed: + if seat in demoted: + # Reached this branch only when EVERY non-demoted + # candidate was filtered out (offline / dead / not in + # this game). Falling back is preferable to silence. + reason = "all_demoted_fallback" + elif addressed is not None and seat == addressed: reason = "addressed" elif seat in state.silent_seats: reason = "silent_rotation" elif last_speaker is not None and seat != last_speaker: - reason = "lru_rotation" + # When at least one seat got demoted *and* we ended up + # picking a non-demoted third party, label it so the + # viewer can show "stuck volley → diverted to seat N". + reason = "low_info_diversion" if demoted else "lru_rotation" else: reason = "seat_tiebreak" await self.dispatch_request( diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py index 4db9ee1..9f4d62a 100644 --- a/src/wolfbot/services/discussion_service.py +++ b/src/wolfbot/services/discussion_service.py @@ -540,6 +540,17 @@ def apply_speech_event( last_speaker_seat = ( speaker if speaker is not None else state.last_speaker_seat ) + # Append (speaker, has_info) to the sliding summary window the + # arbiter uses for low-info pair-volley detection. ``has_info`` is + # the structured "did this event move the discussion forward" + # signal. Today: a CO declaration counts. Wider signals (new + # accusation target, vote announcement) can be added here later + # without changing the field shape. + summary = list(state.recent_speech_summary) + if speaker is not None: + has_info = event.co_declaration is not None + summary.append((speaker, has_info)) + summary = summary[-6:] return PublicDiscussionState( game_id=state.game_id, phase_id=state.phase_id, @@ -555,6 +566,7 @@ def apply_speech_event( last_addressed_speaker_seat=last_addressed_speaker_seat, last_addressed_text=last_addressed_text, last_speaker_seat=last_speaker_seat, + recent_speech_summary=tuple(summary), ) @@ -601,6 +613,7 @@ def rebuild_public_state_from_events( spoken_seats: set[int] = set() co_claims: list[CoClaim] = [] recent_ids: list[str] = [] + summary: list[tuple[int, bool]] = [] seen_co: set[tuple[int, str]] = set() last_addressed_seat: int | None = None last_addressed_speaker_seat: int | None = None @@ -612,6 +625,8 @@ def rebuild_public_state_from_events( if event.speaker_seat is not None: spoken_seats.add(event.speaker_seat) last_speaker_seat = event.speaker_seat + has_info = event.co_declaration is not None + summary.append((event.speaker_seat, has_info)) recent_ids.append(event.event_id) # Mirror the per-event update logic in `_apply_event_to_state`: # only consume the standing address when the NPC speaker IS the @@ -653,6 +668,7 @@ def rebuild_public_state_from_events( state.last_addressed_speaker_seat = last_addressed_speaker_seat state.last_addressed_text = last_addressed_text state.last_speaker_seat = last_speaker_seat + state.recent_speech_summary = tuple(summary[-6:]) return state diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index eb0cfc1..6a77074 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -948,6 +948,149 @@ async def test_try_dispatch_next_avoids_immediate_repeat_after_first_round( assert not bufs["gina"] +async def test_pair_volley_demotion_fires_after_4_low_info_speeches( + repo: SqliteRepo, +) -> None: + """A→B→A→B with no CO declared = 4 low-info speeches between 2 seats. + `_compute_demoted_seats` must mark BOTH seats so the next arbiter + pick picks a 3rd NPC (e.g., 席3 ジナ) instead of one of the two stuck + in the volley. Reproduces the production loop where ラキオ ↔ ジョナス + spoke alternately for the entire phase. + """ + from wolfbot.master.speak_arbiter import _compute_demoted_seats + + # 4-event window: 1, 2, 1, 2 (no info) + summary = ((1, False), (2, False), (1, False), (2, False)) + demoted = _compute_demoted_seats(summary) + assert demoted == frozenset({1, 2}) + + +async def test_pair_volley_resets_when_co_declared() -> None: + """A CO declaration in the window flips ``has_info=True`` for that + event → the gate must NOT fire. Otherwise legitimate dramatic moments + (e.g. seer CO in the middle of a heated exchange) would be punished. + """ + from wolfbot.master.speak_arbiter import _compute_demoted_seats + + summary = ((1, False), (2, False), (1, True), (2, False)) + assert _compute_demoted_seats(summary) == frozenset() + + +async def test_consecutive_cap_demotion_after_3_same_seat() -> None: + """Same seat speaking 3 in a row → demote that seat. Mostly fires + when a human keeps re-addressing the same NPC, but defensive against + any future bug that lets the same seat dispatch repeatedly.""" + from wolfbot.master.speak_arbiter import _compute_demoted_seats + + summary = ((5, False), (5, False), (5, False)) + assert _compute_demoted_seats(summary) == frozenset({5}) + + +async def test_compute_demoted_seats_no_op_for_short_window() -> None: + """Window shorter than the gate thresholds yields an empty set.""" + from wolfbot.master.speak_arbiter import _compute_demoted_seats + + assert _compute_demoted_seats(()) == frozenset() + assert _compute_demoted_seats(((1, False), (2, False))) == frozenset() + + +async def test_try_dispatch_next_diverts_around_pair_volley( + repo: SqliteRepo, +) -> None: + """End-to-end: simulate ラキオ (1) ↔ ジョナス (2) ping-pong with no CO, + then verify the next dispatch targets ジナ (3) — the only third + online NPC — and the persisted selection_reason is ``low_info_diversion``. + """ + g = Game( + id="rv-divert", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, + is_llm=True, persona_key="raqio"), + Seat(seat_no=2, display_name="🎩ジョナス", discord_user_id=None, + is_llm=True, persona_key="jonas"), + Seat(seat_no=3, display_name="🟣ジナ", discord_user_id=None, + is_llm=True, persona_key="gina"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ((1, Role.WEREWOLF), (2, Role.MADMAN), (3, Role.SEER)): + await repo.set_player_role(g.id, sn, role) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], created_at_ms=1, + ) + ) + from wolfbot.services.discussion_service import make_npc_generated_event + # First, seed a single seat-3 speech so silent_seats becomes empty. + # Then 4 alternating no-info speeches between seats 1 and 2 — the + # last 4 events form the pair-volley window. Without the demotion + # gate, seat 1 would win as lowest non-last-speaker (LRU). With it, + # seat 3 is the only non-demoted candidate. + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=3, text="ジナの開始発言", + created_at_ms=5, + ) + ) + for ts, seat in ((10, 1), (20, 2), (30, 1), (40, 2)): + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat, text=f"seat {seat}", + created_at_ms=ts, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"raqio": [], "jonas": [], "gina": []} + for npc_id, persona, seat in ( + ("npc_raqio", "raqio", 1), + ("npc_jonas", "jonas", 2), + ("npc_gina", "gina", 3), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", + supported_voices=(), version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, persona_key=persona, + ) + registry.assign(npc_id, seat=seat, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 2000, + ) + await arb.try_dispatch_next(g.id) + + assert bufs["gina"], "third party (席3 ジナ) must break the volley" + assert not bufs["raqio"] + assert not bufs["jonas"] + + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "3" + assert reason == "low_info_diversion" + + async def test_arbiter_cleanup_game_drops_only_target_game(repo: SqliteRepo) -> None: """`cleanup_game` is wired into `_on_reactive_game_end` so a long-lived Master process doesn't carry stale `_pending` / `_active_playback` / diff --git a/viewer/src/components/PhaseSection.tsx b/viewer/src/components/PhaseSection.tsx index 40105c8..9192779 100644 --- a/viewer/src/components/PhaseSection.tsx +++ b/viewer/src/components/PhaseSection.tsx @@ -450,7 +450,9 @@ function TimeCell({ time }: { time: string }) { const ARBITER_REASON_JA: Record = { addressed: "指名", silent_rotation: "未発言ローテ", - lru_rotation: "LRO ローテ", + lru_rotation: "LRU ローテ", + low_info_diversion: "応酬迂回", + all_demoted_fallback: "全降格フォールバック", seat_tiebreak: "席順", }; @@ -461,6 +463,10 @@ const ARBITER_REASON_TIP: Record = { "このフェーズでまだ発言していない NPC を優先して選んだ", lru_rotation: "全員が一度発言済み — 直前の話者を除外し、席番号の若い順で選んだ", + low_info_diversion: + "2席だけが応酬し情報が増えていない (CO等なし) ため、両者を降格して別の NPC を選んだ", + all_demoted_fallback: + "降格対象しか候補が残っていなかったため、降格対象から拾った (オフライン等で第3者不在の縮退)", seat_tiebreak: "他の候補が無く、席番号の若い順で選んだ (通常 1 NPC のみ生存時の縮退)", }; From f56419c1ce8a92d582b357c2fa74918a2a9a7f14 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 11:36:19 +0900 Subject: [PATCH 059/133] fix(reactive_voice): names-only speech + no-abstain vote + execution/attack tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three day-1 issues hit in the latest playtest, all addressed in one pass: 1. NPCs were saying "席3はどう?" / "席4のラキオが…" — mixing seat numbers into the spoken text. The `addressed_seat_no` data path already carries the seat for routing; the speech itself should always use names. System prompt now explicitly forbids 席N references in `text` with both 禁止例 and 推奨例; DeepSeek contract example updated to match. 2. 7 of 9 NPCs abstained on day 1 because the vote schema accepted null and the prompt let "棄権" be a soft option. Tighten: - `_VOTE_SCHEMA` makes `target_seat: integer` required (no null). - `build_vote_prompt` says **棄権禁止** with explicit instruction to pick the relatively-most-suspect seat even when info is thin. - `_decide_vote_target` adds a defensive abstain → random-pick fallback (deterministic seed: `{game_id}:{seat}:{round}`) so the seat never ends up in the silent-abstain bucket. Reason on the wire becomes `abstain_fallback:`. 3. NPCs conflated yesterday's executions with last night's attacks (e.g. ジョナス said 「昨夜の惨劇か。席2の亡骸が…」 about an executed player). Add `dead_seat_causes: tuple[(seat_no, "EXECUTION" | "ATTACK"), ...]` to `PrivateStateSnapshot` + the `alive_changed` update payload + `NpcGameState`. Both speech and decision prompts now render dead seats as 「席3 コメット (処刑)」 / 「席8 ステラ (襲撃)」 so the model can't mislabel them. Tests cover the new prompt rule, the fallback path, and the dead-cause propagation through snapshot + alive_changed update. --- src/wolfbot/domain/ws_messages.py | 12 ++++ src/wolfbot/master/private_state.py | 20 +++++++ src/wolfbot/npc/client.py | 17 ++++++ src/wolfbot/npc/decision_service.py | 22 +++++-- src/wolfbot/npc/game_state.py | 15 +++++ .../npc/openai_compatible_generator.py | 32 +++++++++-- tests/test_npc_game_state.py | 57 +++++++++++++++++++ tests/test_npc_seat_assignment.py | 36 ++++++++++++ 8 files changed, 201 insertions(+), 10 deletions(-) diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index 563ae44..e838c87 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -190,6 +190,17 @@ class SpeakRequest(BaseEnvelope): "story as `alive_seats`." ), ) + dead_seat_causes: tuple[tuple[int, str], ...] = Field( + default_factory=tuple, + description=( + "Per-seat death cause tag for the dead_seats list, e.g. " + "((2, 'EXECUTION'), (8, 'ATTACK')). Lets the NPC distinguish " + "yesterday's vote victim from last night's attack victim " + "without parsing the morning text. Optional — empty for " + "back-compat with older Master builds; the NPC's prompt " + "builder falls back to an unlabelled list when missing." + ), + ) class SpeakResult(BaseEnvelope): @@ -297,6 +308,7 @@ class PrivateStateSnapshot(BaseEnvelope): day_number: int = 0 alive_seats: tuple[tuple[int, str], ...] = () dead_seats: tuple[tuple[int, str], ...] = () + dead_seat_causes: tuple[tuple[int, str], ...] = () # Wolf-only — empty for non-wolves. partner_wolves: tuple[tuple[int, str], ...] = () # Role-specific result history. Empty for roles without these powers. diff --git a/src/wolfbot/master/private_state.py b/src/wolfbot/master/private_state.py index cdb0e73..b04f86a 100644 --- a/src/wolfbot/master/private_state.py +++ b/src/wolfbot/master/private_state.py @@ -106,6 +106,16 @@ def build_snapshot_for_seat( ) -> PrivateStateSnapshot: """Compose the full snapshot the NPC bot rebuilds its state from.""" seats_by_no = {s.seat_no: s for s in seats} + # Death-cause tag per dead seat so the NPC prompt can distinguish + # yesterday's executions from last night's attacks. Players with no + # cause (= still alive, or some imported state) are skipped. + dead_causes = tuple( + sorted( + (p.seat_no, p.death_cause.value) + for p in players + if not p.alive and p.death_cause is not None + ) + ) return PrivateStateSnapshot( ts=ts, trace_id=trace_id, @@ -117,6 +127,7 @@ def build_snapshot_for_seat( day_number=day_number, alive_seats=_seat_pairs(players, seats_by_no, alive=True), dead_seats=_seat_pairs(players, seats_by_no, alive=False), + dead_seat_causes=dead_causes, partner_wolves=( _partner_wolves(players, seats_by_no, self_seat=seat_no) if role is Role.WEREWOLF @@ -522,6 +533,15 @@ def make_alive_changed_update( [pair[0], pair[1]] for pair in _seat_pairs(players, seats_by_no, alive=False) ], + # Death-cause tag per dead seat. NPC prompt uses this to + # distinguish 「昨日処刑された」 from 「昨夜襲われた」 — without + # it the model regularly conflates yesterday's vote victim + # with last night's attack victim. + "dead_seat_causes": [ + [p.seat_no, p.death_cause.value] + for p in players + if not p.alive and p.death_cause is not None + ], }, ) diff --git a/src/wolfbot/npc/client.py b/src/wolfbot/npc/client.py index f9b2312..31288a4 100644 --- a/src/wolfbot/npc/client.py +++ b/src/wolfbot/npc/client.py @@ -19,6 +19,7 @@ import asyncio import json import logging +import random from collections.abc import Awaitable, Callable from dataclasses import dataclass, field @@ -565,6 +566,22 @@ async def _decide_vote_target( ) return None, "llm_error" result = parse_decision(raw, legal_seats=legal) + # Forbid abstention in voting. The schema disallows null and the + # prompt explicitly says "棄権禁止", but if the model still drops + # back (parse error, out-of-set target, persona inertia) we pick + # a deterministic-but-uniform fallback so the seat doesn't end + # up in the silent-abstain bucket. + if result.target_seat is None and legal: + rng = random.Random( + f"{req.game_id}:{req.seat_no}:{req.round_}".__hash__() + ) + fallback = rng.choice(sorted(legal)) + log.info( + "npc_vote_abstain_fallback game=%s seat=%d -> %d reason=%s", + req.game_id, req.seat_no, fallback, + result.reason_summary or "(none)", + ) + return fallback, f"abstain_fallback:{result.reason_summary or ''}" return result.target_seat, result.reason_summary async def _on_decide_night_action_request( diff --git a/src/wolfbot/npc/decision_service.py b/src/wolfbot/npc/decision_service.py index fe91d76..978a574 100644 --- a/src/wolfbot/npc/decision_service.py +++ b/src/wolfbot/npc/decision_service.py @@ -73,8 +73,12 @@ async def decide_json( "required": ["target_seat", "reason"], "properties": { "target_seat": { - "type": ["integer", "null"], - "description": "Seat number of the vote target, or null to abstain.", + "type": "integer", + "description": ( + "Seat number of the vote target. Must be one of the " + "supplied candidate seats. Abstaining (null) is forbidden — " + "every alive voter has to pick someone." + ), }, "reason": { "type": "string", @@ -108,7 +112,13 @@ def _build_state_block(state: NpcGameState) -> str: alive = "、".join(f"席{s} {n}" for s, n in state.alive_seats) lines.append(f"生存者: {alive}") if state.dead_seats: - dead = "、".join(f"席{s} {n}" for s, n in state.dead_seats) + causes = state.dead_seat_causes + def _cause(seat_no: int) -> str: + c = causes.get(seat_no) + return " (処刑)" if c == "EXECUTION" else " (襲撃)" if c == "ATTACK" else "" + dead = "、".join( + f"席{s} {n}{_cause(s)}" for s, n in state.dead_seats + ) lines.append(f"死亡者: {dead}") if state.partner_wolves: partners = "、".join(f"席{s} {n}" for s, n in state.partner_wolves) @@ -217,8 +227,10 @@ def build_vote_prompt( f"## 投票候補席\n{candidates_str}", "", "上記すべてを踏まえ、この投票で誰に票を入れるかを決めてください。" - "投票しない場合 (棄権) は target_seat を null。" - "JSON は {\"target_seat\": <席番号 | null>, \"reason\": \"<短い理由>\"} の形。", + "**棄権は禁止**: 必ず候補席の中から1人を選んで `target_seat` に入れる。" + "情報が薄くても、最も怪しい/役割上吊りたい/相方ライン以外の中から相対的に最も票を入れたい1人を選ぶこと。" + "JSON は {\"target_seat\": <候補席番号>, \"reason\": \"<短い理由>\"} の形 " + "(`target_seat` は必ず整数、null 不可)。", ] return system, "\n".join(p for p in user_parts if p is not None) diff --git a/src/wolfbot/npc/game_state.py b/src/wolfbot/npc/game_state.py index 24622cb..283686b 100644 --- a/src/wolfbot/npc/game_state.py +++ b/src/wolfbot/npc/game_state.py @@ -75,6 +75,11 @@ class NpcGameState: day_number: int = 0 alive_seats: list[tuple[int, str]] = field(default_factory=list) dead_seats: list[tuple[int, str]] = field(default_factory=list) + # Per-dead-seat death cause label ("EXECUTION" | "ATTACK"), keyed by + # seat_no. Lets the prompt builder distinguish yesterday's vote + # victims from last night's attack victims so the NPC stops saying + # 「昨夜の犠牲者は◯◯」 about a player who was actually executed. + dead_seat_causes: dict[int, str] = field(default_factory=dict) partner_wolves: list[tuple[int, str]] = field(default_factory=list) seer_results: list[SeerResult] = field(default_factory=list) medium_results: list[MediumResult] = field(default_factory=list) @@ -92,6 +97,9 @@ def state_from_snapshot(snapshot: PrivateStateSnapshot) -> NpcGameState: day_number=snapshot.day_number, alive_seats=list(snapshot.alive_seats), dead_seats=list(snapshot.dead_seats), + dead_seat_causes={ + seat_no: cause for seat_no, cause in snapshot.dead_seat_causes + }, partner_wolves=list(snapshot.partner_wolves), seer_results=list(snapshot.seer_results), medium_results=list(snapshot.medium_results), @@ -160,6 +168,7 @@ def apply_update(state: NpcGameState, update: PrivateStateUpdate) -> None: elif kind == "alive_changed": alive = payload.get("alive_seats", []) dead = payload.get("dead_seats", []) + causes = payload.get("dead_seat_causes", []) if isinstance(alive, list): state.alive_seats = [ (_as_int(s[0]), _as_str(s[1])) @@ -172,6 +181,12 @@ def apply_update(state: NpcGameState, update: PrivateStateUpdate) -> None: for s in dead if isinstance(s, (list, tuple)) and len(s) >= 2 ] + if isinstance(causes, list): + state.dead_seat_causes = { + _as_int(s[0]): _as_str(s[1]) + for s in causes + if isinstance(s, (list, tuple)) and len(s) >= 2 + } elif kind == "day_advanced": state.day_number = _as_int(payload["day_number"]) # Unknown kinds: silently skip for forward-compat. diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index af58d34..475437d 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -139,16 +139,25 @@ def _build_system( " 代わりに状況描写や感情で言う: " "「あの白判定、無理に庇ってる気がする」「昨夜守ったのは◯◯」" "「もう 1 人組んでそうな人」「あと処刑できる回数を考えると…」 のように。\n" + "- **`text` 内で席番号 (席1, 席2, ..., 席9 や Seat3 等) を絶対に書かない。**" + "他のプレイヤーを呼ぶときは必ず生存者リストの display_name (キャラ名) を使う。" + "data 層 (`addressed_seat_no` 等) には正しい席番号を入れて構わないが、" + "発話そのものは「ジナさん」「ラキオ」のような自然な呼び方にする。\n" + " 禁止例: 「席3はどう思う?」「席4のラキオが…」「Seat 9、答えて」\n" + " 推奨例: 「ジョナスさんはどう思う?」「ラキオが…」「ユリコ、答えて」\n" "- 役職 CO (占い師・霊媒師・騎士として名乗る) をするときは、" "`co_declaration` を `\"seer\" / \"medium\" / \"knight\"` のいずれかに設定し、" "`text` は「実は私、占い師なんだ」など自然な名乗りにする。" "CO しないなら `co_declaration=null`。" "「占いCO」のような語そのものは `text` に書かない。\n" - "- 特定の席に向けて話す場合は `addressed_seat_no` にその席番号を入れる " - "(例: 席3 に問いかけるなら `3`)。" + "- 特定の席に向けて話す場合は `addressed_seat_no` にその席番号 (整数) を入れる。" "誰宛でもない一般的な発言や全体への呼びかけは `null`。" "自分の席を指定しても無効化されるので、相手の席を必ず入れること。" - "`text` 中で席番号や名前を呼んだ場合はそれと一致させる。\n" + "`text` 中で名前を呼んだ場合はその人の席番号を `addressed_seat_no` に設定する。\n" + "- 死亡者リストには (処刑) または (襲撃) の死因タグが付く。" + "前日の処刑死を「昨夜の犠牲者」と混同しない。逆も同様。" + "発言で死を語るときはタグに合わせた表現を使う" + "(例: 処刑死は「昨日処刑された」、襲撃死は「昨夜襲われた」)。\n" ) @@ -183,8 +192,21 @@ def _build_user( lines.append("") lines.append(f"## 生存者\n{alive_str}") if dead_seats: + # Tag each dead seat with the death cause so the model never + # confuses "executed yesterday" with "killed last night". + cause_map = (getattr(state, "dead_seat_causes", None) or {}) if state else {} + + def _cause_tag(seat_no: int) -> str: + cause = cause_map.get(seat_no) + if cause == "EXECUTION": + return " (処刑)" + if cause == "ATTACK": + return " (襲撃)" + return "" + dead_str = "、".join( - f"席{seat_no} {name}" for seat_no, name in dead_seats + f"席{seat_no} {name}{_cause_tag(seat_no)}" + for seat_no, name in dead_seats ) lines.append(f"## 死亡者\n{dead_str}") # Private state — only present when Phase-D snapshot was received. @@ -291,7 +313,7 @@ def _format_candidate(c: LogicCandidate) -> str: 例: {"text": "私もそこは引っかかってた。", "intent": "agree", "used_logic_ids": [], "co_declaration": null, "addressed_seat_no": null} -{"text": "席3、それは矛盾してるよ。", "intent": "accuse", "used_logic_ids": [], "co_declaration": null, "addressed_seat_no": 3} +{"text": "ジョナスさん、それは矛盾してるよ。", "intent": "accuse", "used_logic_ids": [], "co_declaration": null, "addressed_seat_no": 3} """ diff --git a/tests/test_npc_game_state.py b/tests/test_npc_game_state.py index 030e041..e8055a2 100644 --- a/tests/test_npc_game_state.py +++ b/tests/test_npc_game_state.py @@ -305,6 +305,63 @@ async def test_client_drops_update_when_no_snapshot_seen() -> None: assert "never_snapshotted" not in client.game_states +@pytest.mark.asyncio +async def test_vote_target_falls_back_to_random_when_llm_returns_null() -> None: + """Even though the schema forbids null, defensive coverage: if the + decision LLM returns ``target_seat=None`` (parse error, persona + inertia, etc.) the NPC must still vote — pick a deterministic-but- + pseudo-random candidate from the legal set, never abstain. + """ + client, sent = _make_client_with_capture() + await client.process_message(_snapshot().model_dump_json()) + + class _StubDecisionLLM: + async def decide_json( + self, *, system_prompt: str, user_prompt: str, + schema: dict[str, object], + ) -> str: + # Simulate an abstain response slipping through. + return '{"target_seat": null, "reason": "情報不足"}' + + client.decision_llm = _StubDecisionLLM() # type: ignore[assignment] + + vote_req = DecideVoteRequest( + ts=3000, trace_id="t-vote", + request_id="rv-fallback", npc_id="npc_setsu", seat_no=3, + game_id="g1", phase_id="g1::day1::DAY_VOTE::1", + candidate_seats=((1, "Alice"), (5, "Bob")), + expires_at_ms=10_000, + ) + await client.process_message(vote_req.model_dump_json()) + decisions = [VoteDecision.model_validate_json(m) for m in sent if '"vote_decision"' in m] + assert len(decisions) == 1 + assert decisions[0].target_seat in {1, 5}, ( + "abstain must be replaced by a legal candidate via the fallback" + ) + assert decisions[0].reason_summary is not None + assert "abstain_fallback" in decisions[0].reason_summary + + +@pytest.mark.asyncio +async def test_alive_changed_update_carries_dead_seat_causes() -> None: + """`alive_changed` payload now propagates per-seat death cause so + the NPC prompt can label dead seats as 処刑/襲撃.""" + client, _sent = _make_client_with_capture() + await client.process_message(_snapshot().model_dump_json()) + upd = PrivateStateUpdate( + ts=2000, trace_id="t", npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="alive_changed", + payload={ + "alive_seats": [[1, "Alice"], [3, "セツ"]], + "dead_seats": [[5, "Bob"], [8, "Stella"]], + "dead_seat_causes": [[5, "EXECUTION"], [8, "ATTACK"]], + }, + ) + await client.process_message(upd.model_dump_json()) + state = client.game_states["g1"] + assert state.dead_seat_causes == {5: "EXECUTION", 8: "ATTACK"} + + @pytest.mark.asyncio async def test_seat_released_drops_per_game_state_and_logic_cache() -> None: """`_on_seat_released` is the long-term cleanup hook for an NPC bot diff --git a/tests/test_npc_seat_assignment.py b/tests/test_npc_seat_assignment.py index 5d18a56..084d2ad 100644 --- a/tests/test_npc_seat_assignment.py +++ b/tests/test_npc_seat_assignment.py @@ -424,6 +424,42 @@ def test_build_user_prompt_falls_back_to_request_when_state_none() -> None: assert "## 自分の占い結果" not in out +def test_build_user_prompt_tags_dead_seats_with_death_cause() -> None: + """Dead seat list must distinguish executions from attacks so the + NPC stops calling yesterday's executed player "昨夜の犠牲者".""" + from wolfbot.npc.game_state import NpcGameState + + logic = LogicPacket( + ts=1, trace_id="t", packet_id="lp", phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="(d)", expires_at_ms=9999, + ) + request = SpeakRequest( + ts=1, trace_id="t", request_id="sr", npc_id="npc_1", + phase_id="ph", seat_no=2, logic_packet_id="lp", + suggested_intent="speak", max_chars=80, expires_at_ms=5000, + ) + state = NpcGameState( + game_id="g1", seat_no=2, persona_key="setsu", role="VILLAGER", + day_number=2, + alive_seats=[(1, "Alice"), (2, "セツ")], + dead_seats=[(3, "コメット"), (8, "ステラ")], + dead_seat_causes={3: "EXECUTION", 8: "ATTACK"}, + ) + out = _build_user(logic, request, state) + assert "席3 コメット (処刑)" in out + assert "席8 ステラ (襲撃)" in out + + +def test_system_prompt_forbids_seat_numbers_in_text() -> None: + """The NPC speech rule must explicitly forbid 席N references in the + free-text payload — otherwise the model leaks "席3はどう?" patterns.""" + persona = PERSONAS_BY_KEY["setsu"] + sys = _build_system(persona, max_chars=80) + assert "席番号" in sys + assert "display_name" in sys.lower() or "display_name" in sys + + def test_build_user_prompt_no_candidates_no_pressure() -> None: logic = LogicPacket( ts=1, From d86acba8b7dc5968524917337577796ee24d1a06 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 11:44:25 +0900 Subject: [PATCH 060/133] fix(reactive_voice): carry CO claims across phase boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PublicDiscussionState was rebuilt per-phase, so day-2 NPC prompts showed `co_claims=[(none)]` even when day-1 had a clear seer CO. Wolves who needed that visibility to time a counter-CO had nothing to react to once the day flipped. Add `extract_co_claims_from_events` (factored from the existing in-fold extraction logic) and call it from `SpeakArbiter.rebuild_public_state` against the *full* speech_events history for the game, layered on top of the per-phase fold. The per-phase silent_seats / last_speaker / address pointer all stay phase-scoped — only `co_claims` is promoted to game-wide. Strategy decisions (when to counter-CO, how aggressively, etc.) stay on the NPC's LLM side per the user's request. DiscussionService now exposes `load_for_game` so the arbiter doesn't need to peek at `_store` directly. Test seeds a day-1 seer CO and a day-2 non-CO speech, then asserts day-2 state still surfaces the seer CO. --- src/wolfbot/master/speak_arbiter.py | 19 ++++- src/wolfbot/services/discussion_service.py | 44 ++++++++++++ tests/test_addressed_npc_routing.py | 84 ++++++++++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index a2bbba8..5cafc45 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -838,15 +838,30 @@ async def rebuild_public_state( """Re-fold `speech_events` for the active phase. Used after Master restart to seed the in-memory `PublicDiscussionState` - before re-entering the arbitration loop. + before re-entering the arbitration loop. CO claims are layered on + top from a *game-wide* event scan so day-2+ NPC prompts still show + the day-1 seer CO etc.; without that carry, the per-phase fold + starts each new day with empty `co_claims` and wolves miss the + chance to counter-CO. """ from wolfbot.services.discussion_service import ( + extract_co_claims_from_events, rebuild_public_state_from_events, ) phase_id = make_phase_id(game_id, day, phase) events: Sequence[SpeechEvent] = await self.discussion.load_phase(game_id, phase_id) - return rebuild_public_state_from_events(events) + state = rebuild_public_state_from_events(events) + if state is None: + return None + try: + all_events = await self.discussion.load_for_game(game_id) + except Exception: + log.exception("co_claim_history_load_failed game=%s", game_id) + all_events = () + if all_events: + state.co_claims = extract_co_claims_from_events(all_events) + return state __all__ = ["SpeakArbiter", "SpeakArbiterConfig"] diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py index 9f4d62a..4b0fc90 100644 --- a/src/wolfbot/services/discussion_service.py +++ b/src/wolfbot/services/discussion_service.py @@ -36,6 +36,7 @@ import aiosqlite from wolfbot.domain.discussion import ( + CoClaim, PublicDiscussionState, SpeakerKind, SpeechEvent, @@ -446,6 +447,14 @@ async def begin_phase_if_absent( async def load_phase(self, game_id: str, phase_id: str) -> Sequence[SpeechEvent]: return await self._store.load_phase(game_id, phase_id) + async def load_for_game(self, game_id: str) -> Sequence[SpeechEvent]: + """All non-baseline speech events for `game_id`, ordered by time. + + Exposed so the arbiter can extract historical CO claims across + phase boundaries without going around the store interface. + """ + return await self._store.load_for_game(game_id) + # ---------------------------------------------------------------------- Rebuild @@ -698,3 +707,38 @@ def _resolve_co_role(event: SpeechEvent) -> str | None: if marker in event.text: return role_key return None + + +def extract_co_claims_from_events( + events: Sequence[SpeechEvent], +) -> tuple[CoClaim, ...]: + """Walk events of a single game and return the de-duplicated CO claims. + + Used by SpeakArbiter to carry CO claims across phase boundaries so the + NPC's prompt still shows "席4 seerCO" on day 2 even though the day-2 + PublicDiscussionState fold only sees day-2 events. De-dup key is + ``(speaker_seat, role_claim)`` — the earliest event wins, matching + the in-phase fold semantics. + """ + claims: list[CoClaim] = [] + seen: set[tuple[int, str]] = set() + for event in events: + if event.source == SpeechSource.PHASE_BASELINE: + continue + if event.speaker_seat is None: + continue + role_key = _resolve_co_role(event) + if role_key is None: + continue + key = (event.speaker_seat, role_key) + if key in seen: + continue + seen.add(key) + claims.append( + CoClaim( + seat=event.speaker_seat, + role_claim=role_key, + declared_at_event_id=event.event_id, + ) + ) + return tuple(claims) diff --git a/tests/test_addressed_npc_routing.py b/tests/test_addressed_npc_routing.py index 0189b90..fe72c1f 100644 --- a/tests/test_addressed_npc_routing.py +++ b/tests/test_addressed_npc_routing.py @@ -786,6 +786,90 @@ def test_unrelated_npc_speech_does_not_clear_pending_address() -> None: ) +async def test_co_claims_carry_across_phase_boundaries(repo: SqliteRepo) -> None: + """Day-1 seer CO must still be visible in day-2's PublicDiscussionState + so a wolf NPC can decide to counter-CO on day 2. Previously the fold + rebuilt per-phase and the day-2 prompt showed `co_claims=[(none)]` + even though day 1 had a clear seer CO. + """ + g = Game( + id="rv-co-carry", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=2, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", + is_llm=False, persona_key=None), + Seat(seat_no=4, display_name="🦋ラキオ", discord_user_id=None, + is_llm=True, persona_key="raqio"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 4, Role.SEER) + + day1_phase = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + day2_phase = make_phase_id(g.id, 2, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + + # Day-1: Raqio CO's as seer. + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=day1_phase, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 4], created_at_ms=1, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=day1_phase, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=4, text="実は僕、占い師だ。", + co_declaration="seer", + created_at_ms=10, + ) + ) + # Day-2: only baseline + a non-CO speech (no fresh CO this phase). + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=day2_phase, day=2, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 4], created_at_ms=100, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=day2_phase, day=2, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="昨日のことだけど", + created_at_ms=110, + ) + ) + + arb = SpeakArbiter( + repo=repo, + registry=InMemoryNpcRegistry(), + discussion=discussion, + now_ms=lambda: 200, + ) + state = await arb.rebuild_public_state( + game_id=g.id, day=2, phase=Phase.DAY_DISCUSSION, + ) + assert state is not None + assert any( + c.seat == 4 and c.role_claim == "seer" for c in state.co_claims + ), f"day-2 state must carry day-1 seer CO, got co_claims={state.co_claims}" + + def test_addressed_npc_reply_consumes_the_address() -> None: """When the addressed NPC actually replies (speaker_seat == prior last_addressed_seat) without naming a new target, the address is From c13ed48b1880fb935d113ccec9415d5e4c8128d4 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 11:57:33 +0900 Subject: [PATCH 061/133] fix(reactive_voice): pair-volley gate now ignores re-declared COs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 4-event pair-volley demotion gate keyed off ``has_info = event.co_declaration is not None``. Live game showed Raqio (seer) re-emitting the same `co_declaration='seer'` flag on every reply to ジョナス, so each speech looked like fresh info and the gate never fired. Result: ジョナス↔ラキオ ping-pong consumed the entire phase again, even though my earlier demotion fix was supposed to stop it. Tighten ``has_info`` to fire only when the event introduces a *new* ``(speaker_seat, role_claim)`` CO key — same dedup rule the fold already uses for ``state.co_claims``. Repeated CO from the same seat no longer counts as forward motion, so the gate fires after 4 low-info volleys regardless of how many times the seer re-asserts. Both fold paths (`_apply_event_to_state` and `rebuild_public_state_from_events`) updated. Test seeds a real CO followed by 4 alternating no-new-info speeches and asserts the gate demotes both seats. --- src/wolfbot/services/discussion_service.py | 31 +++++--- tests/test_reactive_voice_master.py | 86 +++++++++++++++++++++- 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py index 4b0fc90..4da18ba 100644 --- a/src/wolfbot/services/discussion_service.py +++ b/src/wolfbot/services/discussion_service.py @@ -502,6 +502,7 @@ def apply_speech_event( co_claims = list(state.co_claims) seen_co = {(c.seat, c.role_claim) for c in co_claims} + is_new_co = False # tracks whether THIS event added a fresh CO if speaker is not None: role_key = _resolve_co_role(event) if role_key is not None: @@ -515,6 +516,7 @@ def apply_speech_event( declared_at_event_id=event.event_id, ) ) + is_new_co = True recent = [*state.recent_speech_event_ids, event.event_id][-10:] @@ -552,13 +554,16 @@ def apply_speech_event( # Append (speaker, has_info) to the sliding summary window the # arbiter uses for low-info pair-volley detection. ``has_info`` is # the structured "did this event move the discussion forward" - # signal. Today: a CO declaration counts. Wider signals (new - # accusation target, vote announcement) can be added here later - # without changing the field shape. + # signal. Today: only a *first-time* CO counts (re-declaring an + # already-recorded CO doesn't bypass the gate — that was the + # ジョナス↔ラキオ ping-pong escape hatch where Raqio kept emitting + # the same `co_declaration='seer'` flag and made every speech look + # like new info). Wider signals (new accusation target, vote + # announcement) can be added here later without changing the + # field shape. summary = list(state.recent_speech_summary) if speaker is not None: - has_info = event.co_declaration is not None - summary.append((speaker, has_info)) + summary.append((speaker, is_new_co)) summary = summary[-6:] return PublicDiscussionState( game_id=state.game_id, @@ -634,8 +639,6 @@ def rebuild_public_state_from_events( if event.speaker_seat is not None: spoken_seats.add(event.speaker_seat) last_speaker_seat = event.speaker_seat - has_info = event.co_declaration is not None - summary.append((event.speaker_seat, has_info)) recent_ids.append(event.event_id) # Mirror the per-event update logic in `_apply_event_to_state`: # only consume the standing address when the NPC speaker IS the @@ -655,13 +658,21 @@ def rebuild_public_state_from_events( last_addressed_text = "" if event.speaker_seat is None: continue + # `is_new_co` flag goes into `recent_speech_summary` so the arbiter + # can detect "two seats arguing without new information" — a + # repeated CO from the same seat does NOT count as info. + is_new_co = False role_key = _resolve_co_role(event) + if role_key is not None: + key = (event.speaker_seat, role_key) + if key not in seen_co: + is_new_co = True + summary.append((event.speaker_seat, is_new_co)) if role_key is None: continue - key = (event.speaker_seat, role_key) - if key in seen_co: + if (event.speaker_seat, role_key) in seen_co: continue - seen_co.add(key) + seen_co.add((event.speaker_seat, role_key)) co_claims.append( CoClaim( seat=event.speaker_seat, diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index 6a77074..ec84f8b 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -966,9 +966,11 @@ async def test_pair_volley_demotion_fires_after_4_low_info_speeches( async def test_pair_volley_resets_when_co_declared() -> None: - """A CO declaration in the window flips ``has_info=True`` for that - event → the gate must NOT fire. Otherwise legitimate dramatic moments - (e.g. seer CO in the middle of a heated exchange) would be punished. + """A *fresh* CO declaration in the window flips ``has_info=True`` for + that event → the gate must NOT fire. Otherwise legitimate dramatic + moments (e.g. seer CO in the middle of a heated exchange) would be + punished. Note: only the first CO per (seat, role) sets has_info — + the fold dedups so re-declaring an existing CO doesn't bypass. """ from wolfbot.master.speak_arbiter import _compute_demoted_seats @@ -976,6 +978,84 @@ async def test_pair_volley_resets_when_co_declared() -> None: assert _compute_demoted_seats(summary) == frozenset() +async def test_repeated_co_from_same_seat_does_not_bypass_gate( + repo: SqliteRepo, +) -> None: + """The day-1 ジョナス↔ラキオ ping-pong escaped the gate because + Raqio kept emitting the same `co_declaration='seer'` flag on every + speech, making each event look like new info. Dedup CO at fold + time so the gate fires when the only "info" is a repeated CO. + """ + g = Game( + id="rv-co-repeat", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", + is_llm=False, persona_key=None), + Seat(seat_no=2, display_name="🎩ジョナス", discord_user_id=None, + is_llm=True, persona_key="jonas"), + Seat(seat_no=3, display_name="🦋ラキオ", discord_user_id=None, + is_llm=True, persona_key="raqio"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.WEREWOLF) + await repo.set_player_role(g.id, 3, Role.SEER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], created_at_ms=1, + ) + ) + from wolfbot.services.discussion_service import ( + make_npc_generated_event, + rebuild_public_state_from_events, + ) + # First CO at ts=5 is genuine; the next 4 events form the volley + # window. Raqio re-emits `co_declaration='seer'` on every reply but + # the dedup makes those repeats has_info=False, so the window's last + # 4 entries are all (seat, False) → gate fires. + events_seq = [ + (5, 3, "seer"), # first CO (outside the last-4 window) + (10, 2, None), # window start + (20, 3, "seer"), # repeat — should NOT count as info + (30, 2, None), + (40, 3, "seer"), # another repeat + ] + for ts, seat, co in events_seq: + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat, text=f"seat {seat} ts {ts}", + co_declaration=co, created_at_ms=ts, + ) + ) + events = await store.load_phase(g.id, phase_id) + state = rebuild_public_state_from_events(events) + assert state is not None + # 5 events → summary is capped at 6 → all 5 retained. + # Last 4 entries: (2, False), (3, False), (2, False), (3, False) + # Pair volley: 2 distinct seats {2,3}, no has_info in window → demote. + from wolfbot.master.speak_arbiter import _compute_demoted_seats + assert _compute_demoted_seats(state.recent_speech_summary) == frozenset({2, 3}) + + async def test_consecutive_cap_demotion_after_3_same_seat() -> None: """Same seat speaking 3 in a row → demote that seat. Mostly fires when a human keeps re-addressing the same NPC, but defensive against From 2e25babe8c53049d472734ecc1f707c3e358ebc1 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 12:17:28 +0900 Subject: [PATCH 062/133] feat(reactive_voice): expose past-day vote history in NPC prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two day-2+ issues observed in the latest playtest, addressed together: 1. ジナ misremembered her own day-1 vote target (claimed コメット, actually voted セツ); ラキオ asked the table for everyone's day-1 ballot. Both stem from the same gap: the NPC user prompt has `## 場の状況` / `## 直近の発言` / `## 生存者` etc. but no public vote ledger. The EXECUTION public log carried "🗳 投票結果: ..." once, but the per-phase fold drops it on day rollover. 2. Day-1 seer often stays silent because the strategy text framed early CO as a "選択肢を強く持つ" suggestion. With Stella's day-0 random white right there in her private state, she still chose silence — judgment-call, but the user wants more push toward day-1 info. Fix #1: add `past_votes: tuple[(day, round, ((voter_seat, target_seat | None), ...)), ...]` to `LogicPacket`. SpeakArbiter loads completed days from the votes table via `_load_past_votes` and threads through `build_logic_packet`. NPC's `_build_user` renders the section with seat + display_name resolution from alive+dead lists, mapping target=None to 棄権. Round 0 = main vote, round 1 = runoff. Fix #2: tighten seer + medium role strategy to push proactive CO unless an explicit reason to stay silent exists. Phrasing change ("選択肢を強く持つ" → "原則として CO する") so the model doesn't keep defaulting to "様子見". Tests cover the past-votes prompt rendering (with names + abstain), the strengthened seer wording, and the existing rounds-mode strategy test was retargeted to the new substring. --- src/wolfbot/domain/ws_messages.py | 15 +++++++ src/wolfbot/llm/prompt_builder.py | 19 +++++--- src/wolfbot/master/logic_service.py | 4 ++ src/wolfbot/master/speak_arbiter.py | 35 +++++++++++++++ .../npc/openai_compatible_generator.py | 30 +++++++++++++ tests/test_llm_prompt_builder.py | 2 +- tests/test_llm_service.py | 2 +- tests/test_npc_seat_assignment.py | 45 +++++++++++++++++++ 8 files changed, 143 insertions(+), 9 deletions(-) diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index e838c87..6bf26f6 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -145,6 +145,21 @@ class LogicPacket(BaseEnvelope): "bots stay schema-compatible." ), ) + past_votes: tuple[ + tuple[int, int, tuple[tuple[int, int | None], ...]], ... + ] = Field( + default_factory=tuple, + description=( + "Public vote history for completed past days. Each entry is " + "``(day, round, ((voter_seat, target_seat | None), ...))`` so " + "the NPC prompt can render 「day1: 席1 → 席3, …」 without " + "asking each NPC to remember its own ballot. Without this, " + "models routinely fabricate a different vote target than the " + "one they actually cast (observed live: ジナ said her vote " + "was コメット when she actually voted セツ). Round 0 = main " + "vote, round 1 = runoff." + ), + ) class SpeakRequest(BaseEnvelope): diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index 7a9a98e..6b010ba 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -499,9 +499,12 @@ def _build_game_rules_block() -> str: "完全な村置きとしては扱わない。\n" "- CO タイミング・対抗 CO の有無・投票と判定の噛み合いを重視し、" "偽占い視点の破綻を探す。\n" - "- 公開ログ上まだ占い師 CO が出ていない状態で議論が進む場合は、" - "初日ランダム白と以後の占い結果を時系列で出して早めに CO する選択肢を強く持つ。" - "真占いが沈黙し続けると、偽 CO を単独真として扱わせてしまう。\n" + "- **day 1 の議論中盤までに占い師 CO が一切出ていない場合は、原則として CO する。**" + "初日ランダム白と以後の占い結果を時系列で出すこと。" + "真占いが沈黙し続けると、偽 CO を単独真として扱わせてしまうし、" + "占い結果が活かせないまま村が情報不足で負ける。" + "潜伏を選ぶのは「狂人らしい騙りが既に出ている」「相方候補の偽 CO を釣り出す具体的な計画がある」など" + "明確な理由があるときだけにし、漠然とした様子見では潜伏しない。\n" "- 偽占い師 CO が出た場合は、原則として早めに対抗 CO し、" "初日ランダム白を含む全判定履歴を時系列で公開する。潜伏を続けるなら理由が必要。\n" "- 黒を引いた場合は、CO して黒結果・過去の白結果・投票理由を明示し、" @@ -541,10 +544,12 @@ def _build_game_rules_block() -> str: "- 自分の霊媒結果が占い視点に与える影響 (真占い補強、偽占い否定など) を整理して発言する。\n" "- 処刑された相手が狂人でも、霊媒結果は『人狼ではありませんでした』になる。" "黒になるのは本物の人狼だけで、白結果だけでは村置き確定にはならない。\n" - "- 処刑が発生した翌日は、霊媒結果を公開して議論の軸を作る価値が高い。" - "沈黙すると偽霊媒 CO を単独真として扱わせてしまうリスクがある。\n" - "- 処刑がまだ発生していない段階では断定を増やしすぎず、" - "占い師 CO への反応を観察する。\n" + "- **処刑が発生した翌日は、原則として早めに霊媒師 CO を出して結果を公開する。**" + "沈黙すると偽霊媒 CO を単独真として扱わせてしまい、判定情報も活かせない。" + "潜伏を選ぶのは、占い師 CO の真贋整理を先に進めたい等の具体的理由があるときだけにする。\n" + "- day 1 (まだ処刑前) は霊媒結果がないので CO を急ぐ必要はないが、" + "占い師 CO の整理が進んだ後の発言で「霊媒の見立て」を示すことで信頼を取りやすくなる。" + "完全な潜伏ではなく、能動的な意見を出して立ち位置を作る。\n" "- 対抗霊媒が出た場合は、自分の結果履歴と相手の矛盾を時系列で整理し、" "ローラーで自分も巻き込まれる可能性を織り込んで発言する。\n" "- 占い師 CO を処刑して霊媒結果が白だった場合、それは占い師 CO 偽の証明ではない。" diff --git a/src/wolfbot/master/logic_service.py b/src/wolfbot/master/logic_service.py index ba3c5a6..2140b9b 100644 --- a/src/wolfbot/master/logic_service.py +++ b/src/wolfbot/master/logic_service.py @@ -40,6 +40,9 @@ def build_logic_packet( pressure: dict[int, float] | None = None, additional_candidates: Iterable[LogicCandidate] = (), recent_speeches: Iterable[RecentSpeech] = (), + past_votes: Iterable[ + tuple[int, int, tuple[tuple[int, int | None], ...]] + ] = (), ) -> LogicPacket: """Construct a `LogicPacket` for `recipient_npc_id`. @@ -93,6 +96,7 @@ def build_logic_packet( pressure=pressure or {}, expires_at_ms=expires_at_ms, recent_speeches=tuple(recent_speeches), + past_votes=tuple(past_votes), ) diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index 5cafc45..3afcd54 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -268,6 +268,7 @@ async def dispatch_request( recent_speeches, alive_seats, dead_seats, role_name, role_strategy = ( await self._collect_request_context(state, seat_no) ) + past_votes = await self._load_past_votes(game_id, state.day) # Build LogicPacket (sent first so the NPC has context for the # subsequent speak_request). @@ -277,6 +278,7 @@ async def dispatch_request( expires_at_ms=now + self.config.request_ttl_ms, now_ms=now, recent_speeches=recent_speeches, + past_votes=past_votes, ) try: await entry.send(packet.model_dump_json()) @@ -442,6 +444,39 @@ async def _collect_request_context( return recent, alive_seats, dead_seats, role_name, role_strategy + async def _load_past_votes( + self, game_id: str, current_day: int + ) -> tuple[tuple[int, int, tuple[tuple[int, int | None], ...]], ...]: + """Load completed-day vote ballots so the prompt builder can show + every NPC the public ledger of "who voted whom". + + Without this, models routinely fabricate their own past vote + because the EXECUTION public log isn't surfaced anywhere in the + per-phase fold (state has co_claims and silent_seats but not + votes). Returns empty when no past day exists or on any DB + glitch — best-effort. + """ + if current_day <= 1: + return () + out: list[tuple[int, int, tuple[tuple[int, int | None], ...]]] = [] + try: + for day in range(1, current_day): + for round_ in (0, 1): + rows = await self.repo.load_votes( + game_id, day=day, round_=round_, + ) + if not rows: + continue + pairs = tuple( + (v.voter_seat, v.target_seat) + for v in sorted(rows, key=lambda v: v.voter_seat) + ) + out.append((day, round_, pairs)) + except Exception: + log.exception("past_votes_load_failed game=%s", game_id) + return () + return tuple(out) + # ------------------------------------------------------------- handle result async def handle_speak_result( diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index 475437d..36dddfd 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -255,6 +255,36 @@ def _cause_tag(seat_no: int) -> str: lines.append( f" day{wc.day} 席{wc.speaker_seat} {wc.speaker_name}: {wc.text}" ) + if logic.past_votes: + # Public vote history. Each NPC saw the EXECUTION public log when + # it landed, but the per-phase fold doesn't carry that text into + # the next day's prompt. Surfacing it here lets NPCs reason about + # actual ballots ("ジナ → セツ") instead of fabricating their + # own vote target. + lines.append("") + lines.append("## 公開された投票履歴") + # Build a name lookup from alive + dead so dead voters still get + # a display name. + seat_name_lookup = { + seat_no: name + for seat_no, name in ( + list(alive_seats) + list(dead_seats) + ) + } + + def _seat_label(seat: int | None) -> str: + if seat is None: + return "棄権" + name = seat_name_lookup.get(seat, "?") + return f"席{seat} {name}" if name and name != "?" else f"席{seat}" + + for day, round_, pairs in logic.past_votes: + label = "決選投票" if round_ >= 1 else "投票" + lines.append(f"- day{day} {label}:") + for voter, target in pairs: + lines.append( + f" {_seat_label(voter)} → {_seat_label(target)}" + ) if logic.recent_speeches: lines.append("") lines.append("## 直近の発言 (古い順)") diff --git a/tests/test_llm_prompt_builder.py b/tests/test_llm_prompt_builder.py index 242823f..f68dfb4 100644 --- a/tests/test_llm_prompt_builder.py +++ b/tests/test_llm_prompt_builder.py @@ -976,7 +976,7 @@ def test_seer_strategy_covers_proactive_and_counter_co() -> None: appeared, counter-CO against a fake seer with time-ordered history disclosure, and the black-pull CO procedure.""" block = _build_strategy_block(Role.SEER) - assert "まだ占い師 CO が出ていない" in block + assert "占い師 CO が一切出ていない" in block assert "対抗 CO" in block assert "時系列で公開" in block assert "黒を引いた場合" in block diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index 27ca8ff..f73babb 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -1804,7 +1804,7 @@ async def test_ask_system_prompt_seer_includes_counter_co_strategy( counter-CO (when a fake seer appears), and black-pull CO guidance so the true seer doesn't stay silent and cede single-truth treatment to a fake.""" system_prompt = await _capture_ask_system_prompt(repo, Role.SEER) - assert "まだ占い師 CO が出ていない" in system_prompt + assert "占い師 CO が一切出ていない" in system_prompt assert "対抗 CO" in system_prompt assert "時系列で公開" in system_prompt assert "黒を引いた場合" in system_prompt diff --git a/tests/test_npc_seat_assignment.py b/tests/test_npc_seat_assignment.py index 084d2ad..443a34f 100644 --- a/tests/test_npc_seat_assignment.py +++ b/tests/test_npc_seat_assignment.py @@ -460,6 +460,51 @@ def test_system_prompt_forbids_seat_numbers_in_text() -> None: assert "display_name" in sys.lower() or "display_name" in sys +def test_build_user_prompt_renders_past_votes_with_names() -> None: + """Past-day votes must surface in the prompt with seat + display name + so NPCs can reference 「ジナ → セツ」 instead of fabricating their + own ballot.""" + logic = LogicPacket( + ts=1, trace_id="t", packet_id="lp", phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="(d)", expires_at_ms=9999, + past_votes=( + (1, 0, ((1, 3), (2, 4), (3, 1), (4, 1))), + ), + ) + request = SpeakRequest( + ts=1, trace_id="t", request_id="sr", npc_id="npc_1", + phase_id="ph", seat_no=2, logic_packet_id="lp", + suggested_intent="speak", max_chars=80, expires_at_ms=5000, + alive_seats=((1, "Alice"), (2, "ジナ"), (3, "ラキオ"), (4, "セツ")), + dead_seats=(), + ) + out = _build_user(logic, request) + assert "## 公開された投票履歴" in out + assert "day1 投票" in out + assert "席1 Alice → 席3 ラキオ" in out + assert "席2 ジナ → 席4 セツ" in out + + +def test_build_user_prompt_handles_past_vote_abstain() -> None: + """target=None must render as 棄権.""" + logic = LogicPacket( + ts=1, trace_id="t", packet_id="lp", phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="(d)", expires_at_ms=9999, + past_votes=((1, 0, ((1, None), (2, 1))),), + ) + request = SpeakRequest( + ts=1, trace_id="t", request_id="sr", npc_id="npc_1", + phase_id="ph", seat_no=2, logic_packet_id="lp", + suggested_intent="speak", max_chars=80, expires_at_ms=5000, + alive_seats=((1, "Alice"), (2, "ジナ")), + dead_seats=(), + ) + out = _build_user(logic, request) + assert "席1 Alice → 棄権" in out + + def test_build_user_prompt_no_candidates_no_pressure() -> None: logic = LogicPacket( ts=1, From 37e4c4b5f4f61a46bf8293936189a2cf68e16557 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 12:30:24 +0900 Subject: [PATCH 063/133] fix(reactive_voice): dispatch next NPC after TTS timeout instead of stalling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_on_tts_failed` looked up `arbiter._pending[request_id]` *after* calling `handle_tts_failed`, but the handler pops `_pending` as part of gate cleanup. The post-call lookup was always None, so the `try_dispatch_next(...)` recovery branch never fired. Production effect: Jonas' second day-1 speech timed out on VOICEVOX (`voicevox_timeout` recorded on the playback row). The gate cleared, but no other NPC ever got dispatched — the phase went silent and the host had to abort. `_on_playback_finished` and `_on_playback_failed` already follow the correct pattern (capture game_id before the handler call); this brings `_on_tts_failed` in line. New unit test documents the contract that `handle_tts_failed` pops `_pending`, so any future refactor keeps the "capture first, then call" ordering. --- src/wolfbot/main.py | 13 ++++-- tests/test_reactive_voice_master.py | 72 +++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index ee063f8..7a5c29c 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -1029,11 +1029,18 @@ async def _on_tts_finished(msg: Any, _ctx: Any) -> None: await arbiter.handle_tts_finished(msg) async def _on_tts_failed(msg: Any, _ctx: Any) -> None: + # Resolve game_id BEFORE handle_tts_failed pops `_pending`, + # otherwise the post-pop lookup is always None and the next + # NPC never gets dispatched. Production hit: Jonas' second + # speech timed out on VOICEVOX, the gate cleared, but the + # arbiter stalled silently for the rest of the phase + # because game_id was looked up after the pop. + pending = arbiter._pending.get(msg.request_id) + game_id = pending.game_id if pending is not None else None await arbiter.handle_tts_failed(msg) # Gate cleared — try dispatching next NPC. - pending = arbiter._pending.get(msg.request_id) - if pending is not None: - await arbiter.try_dispatch_next(pending.game_id) + if game_id is not None: + await arbiter.try_dispatch_next(game_id) async def _on_playback_finished(msg: Any, _ctx: Any) -> None: # Resolve game_id before the pending entry is popped. diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index ec84f8b..96caa8e 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -1171,6 +1171,78 @@ async def test_try_dispatch_next_diverts_around_pair_volley( assert reason == "low_info_diversion" +async def test_handle_tts_failed_pops_pending_before_returning( + repo: SqliteRepo, +) -> None: + """`handle_tts_failed` releases the playback gate AND removes the + `_pending` row in the same call. The wiring in `main._on_tts_failed` + therefore has to read game_id BEFORE invoking the handler — production + bug had the lookup *after* the call, returning None, and the next + NPC was never dispatched (silent stall after a VOICEVOX timeout). + + This test documents the contract so future refactors keep the + "capture game_id first" invariant. + """ + from wolfbot.domain.ws_messages import TtsFailed + from wolfbot.master.speak_arbiter import _PendingRequest + + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, + registry=InMemoryNpcRegistry(), + discussion=discussion, + now_ms=lambda: 1_000, + ) + g = Game( + id="rv-tts-fail", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + arb._pending["sr-x"] = _PendingRequest( + request_id="sr-x", npc_id="npc1", seat_no=2, + phase_id="rv-tts-fail::day1::DAY_DISCUSSION::1", + game_id=g.id, + expires_at_ms=10_000, + ) + arb._active_playback.add("sr-x") + arb._playback_deadlines["sr-x"] = 5_000 + # Open the playback row so handle_tts_failed has something to close. + await repo.open_npc_playback( + request_id="sr-x", + game_id=g.id, + phase_id="rv-tts-fail::day1::DAY_DISCUSSION::1", + npc_id="npc1", + speech_event_id="se-x", + authorized_at_ms=1_000, + playback_deadline_ms=5_000, + ) + + captured_game_id = arb._pending["sr-x"].game_id + + msg = TtsFailed( + ts=2_000, trace_id="t-x", request_id="sr-x", + npc_id="npc1", failure_reason="voicevox_timeout", + ) + await arb.handle_tts_failed(msg) + + # Contract: post-call, _pending no longer has the entry. If the + # main.py wiring reads game_id AFTER calling handle_tts_failed, it + # gets None and the dispatch chain stalls. + assert "sr-x" not in arb._pending + assert "sr-x" not in arb._active_playback + assert captured_game_id == g.id # what the wiring should have captured pre-call + + async def test_arbiter_cleanup_game_drops_only_target_game(repo: SqliteRepo) -> None: """`cleanup_game` is wired into `_on_reactive_game_end` so a long-lived Master process doesn't carry stale `_pending` / `_active_playback` / From 9ae68fa1ac7c48acc36a30bcfa0ec040536af64a Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 12:44:33 +0900 Subject: [PATCH 064/133] fix(reactive_voice): forbid night-action skip the same way as votes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live game deadlocked on day-1 NIGHT because the knight (Setsu) returned ``target_seat=None`` with reason "Day1初夜のため盤面情報不足。 情報なしで特定護衛せずGJリスク回避し次夜余地残す。" Master rejected the null with ILLEGAL_TARGET, leaving missing_seats=[5] in pending_decisions and the night phase frozen until the host aborted. The vote path already had an abstain-forbidden trio (schema rejects null, prompt says "棄権禁止", client falls back to a deterministic random pick). Mirror it for night actions: * `_NIGHT_SCHEMA.target_seat` becomes required integer (no null). * `build_night_prompt` says **スキップ禁止**, must pick a candidate even when info is thin; "捨て護衛" is one possible *strategic* choice but always backed by a legal seat. * `_decide_night_target` adds the same deterministic-random fallback on null, with `abstain_fallback:` reason on the wire so the viewer trace makes the recovery explicit. Test seeds a knight whose stub LLM returns null and asserts the client emits a legal target instead of stalling. --- src/wolfbot/npc/client.py | 19 +++++++++++++ src/wolfbot/npc/decision_service.py | 19 ++++++++++--- tests/test_npc_game_state.py | 41 +++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/wolfbot/npc/client.py b/src/wolfbot/npc/client.py index 31288a4..50eed9b 100644 --- a/src/wolfbot/npc/client.py +++ b/src/wolfbot/npc/client.py @@ -697,6 +697,25 @@ async def _decide_night_target( ) return None, "llm_error" result = parse_decision(raw, legal_seats=legal) + # Forbid skipping for night actions. Master rejects target=None + # with ILLEGAL_TARGET; the missing seat then deadlocks the + # NIGHT phase via pending_decisions until the host force-skips. + # Live game stuck on day-1 NIGHT because the knight returned + # null saying "GJ リスク回避し次夜余地残す". Force a legal pick + # so the phase always advances; persona keeps a chance to do + # 捨て護衛 / 価値の薄い位置 via the LLM choice itself. + if result.target_seat is None and legal: + rng = random.Random( + f"{req.game_id}:{req.seat_no}:{req.action_kind}".__hash__() + ) + fallback = rng.choice(sorted(legal)) + log.info( + "npc_night_abstain_fallback game=%s seat=%d kind=%s -> %d " + "reason=%s", + req.game_id, req.seat_no, req.action_kind, fallback, + result.reason_summary or "(none)", + ) + return fallback, f"abstain_fallback:{result.reason_summary or ''}" return result.target_seat, result.reason_summary diff --git a/src/wolfbot/npc/decision_service.py b/src/wolfbot/npc/decision_service.py index 978a574..ac409cc 100644 --- a/src/wolfbot/npc/decision_service.py +++ b/src/wolfbot/npc/decision_service.py @@ -94,8 +94,15 @@ async def decide_json( "required": ["target_seat", "reason"], "properties": { "target_seat": { - "type": ["integer", "null"], - "description": "Seat number of the night-action target, or null to skip.", + "type": "integer", + "description": ( + "Seat number of the night-action target. Must be one of " + "the supplied candidate seats. Skipping (null) is forbidden — " + "every wolf_attack / seer_divine / knight_guard must pick " + "an actual target. Master rejects null with ILLEGAL_TARGET " + "and the night phase stalls on missing submissions, so " + "the decision LLM has to commit to a candidate." + ), }, "reason": { "type": "string", @@ -352,8 +359,12 @@ def build_night_prompt( f"## 行動候補席\n{candidates_str}", "", "上記すべてを踏まえ、夜の行動対象を決めてください。" - "対象を選ばない/選べない場合は target_seat を null。" - "JSON は {\"target_seat\": <席番号 | null>, \"reason\": \"<短い理由>\"} の形。", + "**スキップ禁止**: 必ず候補席の中から1人を選んで `target_seat` に入れる。" + "情報が薄くても、相対的に最も対象として価値がある1人を選ぶこと " + "(占い: 情報を取りたい灰、人狼: 噛み価値の高い位置、騎士: 守るべき情報役/重要位置)。" + "「捨て護衛」のような戦術選択をしたい場合も、null ではなく合法候補から1人を選ぶ。" + "JSON は {\"target_seat\": <候補席番号>, \"reason\": \"<短い理由>\"} の形 " + "(`target_seat` は必ず整数、null 不可)。", ] return system, "\n".join(p for p in user_parts if p is not None) diff --git a/tests/test_npc_game_state.py b/tests/test_npc_game_state.py index e8055a2..0ad6b54 100644 --- a/tests/test_npc_game_state.py +++ b/tests/test_npc_game_state.py @@ -342,6 +342,47 @@ async def decide_json( assert "abstain_fallback" in decisions[0].reason_summary +@pytest.mark.asyncio +async def test_night_target_falls_back_to_random_when_llm_returns_null() -> None: + """Master rejects night actions with `target_seat=None` (ILLEGAL_TARGET), + and the missing seat deadlocks the NIGHT phase via pending_decisions. + Live game stalled when the knight chose to skip the day-1 guard + ("GJ リスク回避"). Force a legal pick so the phase always advances. + """ + client, sent = _make_client_with_capture() + await client.process_message(_snapshot(role="KNIGHT").model_dump_json()) + + class _StubDecisionLLM: + async def decide_json( + self, *, system_prompt: str, user_prompt: str, + schema: dict[str, object], + ) -> str: + # Knight tries to skip — must NOT be allowed through. + return '{"target_seat": null, "reason": "情報不足のため次夜余地残す"}' + + client.decision_llm = _StubDecisionLLM() # type: ignore[assignment] + + night_req = DecideNightActionRequest( + ts=4000, trace_id="t-night", + request_id="rn-fallback", npc_id="npc_setsu", seat_no=3, + game_id="g1", phase_id="g1::day1::NIGHT::1", + action_kind="knight_guard", + candidate_seats=((1, "Alice"), (5, "Bob")), + expires_at_ms=20_000, + ) + await client.process_message(night_req.model_dump_json()) + decisions = [ + NightActionDecision.model_validate_json(m) + for m in sent if '"night_action_decision"' in m + ] + assert len(decisions) == 1 + assert decisions[0].target_seat in {1, 5}, ( + "skip must be replaced by a legal candidate via the fallback" + ) + assert decisions[0].reason_summary is not None + assert "abstain_fallback" in decisions[0].reason_summary + + @pytest.mark.asyncio async def test_alive_changed_update_carries_dead_seat_causes() -> None: """`alive_changed` payload now propagates per-seat death cause so From a15528995249c9be947c45ff06cf26dc9cc2d32e Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 13:04:29 +0900 Subject: [PATCH 065/133] feat(reactive_voice): role-callout detection + propagation to NPC prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a human or NPC asks "占い師の方は名乗り出てください" / "霊媒師 いますか?", the request should reach real role holders (so they CO) and wolf-side NPCs (so they can weigh fake CO). Previously the analyzer only emitted `co_claim` / `addressed_name` / etc., so the question landed in the dense `## 直近の発言` blob with no structured hint and most NPCs ignored it. Hybrid approach (schema + light prompt support, strategy left to LLM): * Schema: add `role_callout: "seer"|"medium"|"knight"|null` to `SttResult`, `TextAnalysis`, `SpeechEventPayload`, `SpeechEvent`, `LogicPacket`, and the `speech_events.role_callout` DB column + additive migration. * Analyzer prompts (Gemini audio + text) now emit `role_callout` when they detect explicit "出てきて/名乗って/CO して/いますか" semantics for a specific role; mere mentions of a role name stay null. * `voice_ingest_service` + `discord_service` text path forward the field through `SpeechEventPayload` → ingest → `SpeechEvent`. Both `make_human_text_event` and `make_npc_generated_event` accept it. * `PublicDiscussionState.pending_role_callouts` (frozenset[str]) tracks outstanding requests within a phase; the per-event update and rebuild fold both add on `role_callout` events and discard on matching CO. SpeakArbiter surfaces it on the LogicPacket and the NPC's `_build_user` renders a dedicated `## 未回答の役職呼びかけ` block so the LLM treats it as a 1st-class signal. Six new tests cover analyzer plumbing, fold add/clear semantics, and the prompt-rendering contract. --- src/wolfbot/domain/discussion.py | 19 +++++ src/wolfbot/domain/ws_messages.py | 22 ++++++ src/wolfbot/master/ingest_service.py | 1 + src/wolfbot/master/logic_service.py | 7 ++ src/wolfbot/master/stt_service.py | 21 ++++- src/wolfbot/master/text_analyzer.py | 16 +++- src/wolfbot/master/voice_ingest_service.py | 6 ++ .../npc/openai_compatible_generator.py | 21 +++++ src/wolfbot/persistence/schema.py | 5 ++ src/wolfbot/services/discord_service.py | 3 + src/wolfbot/services/discussion_service.py | 39 ++++++++-- tests/test_addressed_npc_routing.py | 78 +++++++++++++++++++ tests/test_npc_seat_assignment.py | 23 ++++++ 13 files changed, 253 insertions(+), 8 deletions(-) diff --git a/src/wolfbot/domain/discussion.py b/src/wolfbot/domain/discussion.py index be887f8..0c17cbb 100644 --- a/src/wolfbot/domain/discussion.py +++ b/src/wolfbot/domain/discussion.py @@ -99,6 +99,18 @@ class SpeechEvent(BaseModel): "the next speaker." ), ) + role_callout: str | None = Field( + default=None, + description=( + "Role the utterance is calling out for " + "('占い師の方どうぞ' → 'seer'). Stored alongside the speech so " + "the arbiter and NPC prompt builders can surface a 'pending " + "role callout' to every NPC: real role holders take it as a " + "CO trigger, wolf-side NPCs take it as a chance to fake CO. " + "Distinct from `co_declaration` (= self-declaration). Values " + "are the canonical CoDeclaration enum strings." + ), + ) created_at_ms: int def is_baseline(self) -> bool: @@ -171,3 +183,10 @@ class PublicDiscussionState(BaseModel): # but big enough to cover both the pair-volley check (last 4) and the # consecutive-speaker cap (last 3). recent_speech_summary: tuple[tuple[int, bool], ...] = () + # Set of role names ("seer" / "medium" / "knight") that some speaker + # has explicitly called for in this phase but no one has CO'd as yet. + # Cleared when a matching CO arrives (= the call was answered) and + # reset per phase. Surfaces in the NPC prompt so real role holders + # take it as a CO trigger and wolf-side NPCs can decide whether to + # fake CO. + pending_role_callouts: frozenset[str] = frozenset() diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index 6bf26f6..db460b4 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -160,6 +160,17 @@ class LogicPacket(BaseEnvelope): "vote, round 1 = runoff." ), ) + pending_role_callouts: tuple[str, ...] = Field( + default_factory=tuple, + description=( + "Roles that some speaker has called out for in the current " + "phase but no one has CO'd as yet (e.g. ``('seer',)`` after " + "「占い師の方は名乗り出てください」). Real role holders should " + "treat this as a CO trigger; wolf-side NPCs should consider " + "whether to fake CO. Cleared on the Master side when a " + "matching CO arrives." + ), + ) class SpeakRequest(BaseEnvelope): @@ -589,6 +600,17 @@ class SpeechEventPayload(BaseEnvelope): "analyzer wasn't grounded or couldn't pick a seat." ), ) + role_callout: CoDeclaration | None = Field( + default=None, + description=( + "Role the utterance is calling out for (e.g. \"占い師いますか?\" " + "→ ``seer``). Mirrors the human-side STT field so wolf-side " + "NPCs and real role holders can react. None for the vast " + "majority of utterances; only set when the analyzer detects " + "an explicit role-callout intent (not mere mentions of a " + "role name in unrelated context)." + ), + ) class SttFailed(BaseEnvelope): diff --git a/src/wolfbot/master/ingest_service.py b/src/wolfbot/master/ingest_service.py index 7af7e0e..7c84f0d 100644 --- a/src/wolfbot/master/ingest_service.py +++ b/src/wolfbot/master/ingest_service.py @@ -280,6 +280,7 @@ async def ingest_voice( summary=payload.summary, co_declaration=payload.co_declaration, addressed_seat_no=addressed_seat_no, + role_callout=payload.role_callout, created_at_ms=default_now_ms(), ) await self.discussion.record(event) diff --git a/src/wolfbot/master/logic_service.py b/src/wolfbot/master/logic_service.py index 2140b9b..1530622 100644 --- a/src/wolfbot/master/logic_service.py +++ b/src/wolfbot/master/logic_service.py @@ -69,6 +69,12 @@ def build_logic_packet( else "(none)" ) summary = f"phase_id={state.phase_id} day={state.day} co_claims=[{co_repr}] {silent_repr}" + if state.pending_role_callouts: + # Outstanding "誰か占い師?" / "霊媒師の方どうぞ" requests that no + # one has answered yet. Real role holders should treat this as a + # CO trigger; wolf-side NPCs should consider whether to fake CO. + callouts_repr = ", ".join(sorted(state.pending_role_callouts)) + summary += f" pending_role_callouts=[{callouts_repr}]" if state.last_addressed_seat is not None: speaker_repr = ( f"席{state.last_addressed_speaker_seat}" @@ -97,6 +103,7 @@ def build_logic_packet( expires_at_ms=expires_at_ms, recent_speeches=tuple(recent_speeches), past_votes=tuple(past_votes), + pending_role_callouts=tuple(sorted(state.pending_role_callouts)), ) diff --git a/src/wolfbot/master/stt_service.py b/src/wolfbot/master/stt_service.py index ddb50b5..b4b93e4 100644 --- a/src/wolfbot/master/stt_service.py +++ b/src/wolfbot/master/stt_service.py @@ -177,6 +177,12 @@ class SttResult: # the legacy resolver knows). ``None`` when no roster was given # or the analyzer couldn't pick a seat. addressed_seat_no: int | None = None + # `role_callout` flags speeches that explicitly call for a specific + # role to come out (e.g. "占い師の方は名乗り出てください"). The + # downstream arbiter / NPC prompt builder uses this so real role + # holders and wolf-side fakers can react. ``None`` for the vast + # majority of utterances. + role_callout: str | None = None raw_analysis: dict[str, Any] | None = None @@ -329,7 +335,8 @@ class GeminiAudioAnalyzer: ' "vote_target_seat": null,\n' ' "stance": {},\n' ' "addressed_name": null,\n' - ' "addressed_seat_no": null\n' + ' "addressed_seat_no": null,\n' + ' "role_callout": null\n' "}\n" "```\n\n" "フィールド説明:\n" @@ -342,6 +349,12 @@ class GeminiAudioAnalyzer: "- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 \"セツ\"、\"ジーナさん\"、\"席3\"、\"3番\")、なければ null。" "「みんな」「全員」など全体への呼びかけは null。さん/くん/ちゃん 等の敬称は付けたままでも構わない。\n" "- addressed_seat_no: 上の addressed_name と同じ人物の席番号(整数)、または null。roster が与えられているときは必ず埋める。\n" + "- role_callout: 特定の役職に名乗り出を求める呼びかけがあれば " + "\"seer\"/\"medium\"/\"knight\" のいずれか、なければ null。" + "例: 「占い師の方は名乗り出てください」「霊媒師いますか?」「騎士は誰?」" + "「占いCO お願いします」 → 該当役職を返す。" + "ただし役職名を単に話題にしただけ (例: 「占い師の判定が気になる」「霊媒師の信用は?」) は呼びかけではないので null。" + "明確な「出てきて/名乗って/CO して/いますか」のような請求が含まれる場合のみ設定する。\n" "\n音声が不明瞭な場合は confidence を低くし、transcript は聞き取れた範囲で。" ) @@ -460,6 +473,11 @@ async def transcribe( addressed_name = stripped or None addressed_seat_no = _coerce_seat_no(parsed.get("addressed_seat_no")) + callout_raw = parsed.get("role_callout") + role_callout = ( + callout_raw if callout_raw in CO_CLAIM_VALUES else None + ) + # Estimate duration from audio size (assume 16kHz 16-bit mono WAV) data_bytes = max(0, len(audio) - 44) duration_ms = int(data_bytes / (16_000 * 2) * 1000) @@ -472,6 +490,7 @@ async def transcribe( co_declaration=co_declaration, addressed_name=addressed_name, addressed_seat_no=addressed_seat_no, + role_callout=role_callout, raw_analysis=parsed or None, ) diff --git a/src/wolfbot/master/text_analyzer.py b/src/wolfbot/master/text_analyzer.py index 7db7bf5..82001e1 100644 --- a/src/wolfbot/master/text_analyzer.py +++ b/src/wolfbot/master/text_analyzer.py @@ -43,6 +43,9 @@ class TextAnalysis: addressed_name: str | None = None co_declaration: str | None = None + # Role the utterance is calling for (e.g. "占い師の方どうぞ" → "seer"). + # None for non-callouts. Mirrors `SttResult.role_callout`. + role_callout: str | None = None class TextAnalyzerError(RuntimeError): @@ -107,7 +110,8 @@ class GeminiTextAnalyzer: "```json\n" "{\n" ' "co_claim": null,\n' - ' "addressed_name": null\n' + ' "addressed_name": null,\n' + ' "role_callout": null\n' "}\n" "```\n\n" "フィールド説明:\n" @@ -115,7 +119,12 @@ class GeminiTextAnalyzer: "- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 \"セツ\"、\"ジーナさん\"、\"席3\"、\"3番\")、なければ null。" "「みんな」「全員」など全体への呼びかけは null。さん/くん/ちゃん 等の敬称は付けたままでも構わない。" "発言内で他プレイヤーに言及するだけ(例: 『セツの判定が気になる』)は呼びかけではないので null。" - "明確な宛先のある呼びかけ(例: 『セツさん、どう思う』)のみ設定。" + "明確な宛先のある呼びかけ(例: 『セツさん、どう思う』)のみ設定。\n" + "- role_callout: 特定の役職に名乗り出を求める呼びかけがあれば " + "\"seer\"/\"medium\"/\"knight\" のいずれか、なければ null。" + "例: 「占い師の方は名乗り出てください」「霊媒師いますか?」「騎士は誰?」 → 該当役職。" + "ただし役職名を単に話題にしただけ (例: 「占い師の判定が気になる」) は null。" + "明確な「出てきて/名乗って/CO して/いますか」のような請求が含まれる場合のみ設定する。" ) def __init__( @@ -249,9 +258,12 @@ async def analyze(self, *, text: str, timeout_s: float) -> TextAnalysis: if isinstance(addressed_raw, str): stripped = addressed_raw.strip() addressed_name = stripped or None + callout_raw = parsed.get("role_callout") + role_callout = callout_raw if callout_raw in CO_CLAIM_VALUES else None return TextAnalysis( addressed_name=addressed_name, co_declaration=co_declaration, + role_callout=role_callout, ) @staticmethod diff --git a/src/wolfbot/master/voice_ingest_service.py b/src/wolfbot/master/voice_ingest_service.py index 0ecdc65..82b41cb 100644 --- a/src/wolfbot/master/voice_ingest_service.py +++ b/src/wolfbot/master/voice_ingest_service.py @@ -500,6 +500,11 @@ def _build_dump( if result.co_declaration in CO_CLAIM_VALUES else None ) + role_callout = ( + result.role_callout + if result.role_callout in CO_CLAIM_VALUES + else None + ) if dump_enabled: await dump_segment( _build_dump(result=result, failure_reason=None), pcm_snapshot @@ -522,6 +527,7 @@ def _build_dump( co_declaration=co_decl, # type: ignore[arg-type] addressed_name=result.addressed_name, addressed_seat_no=result.addressed_seat_no, + role_callout=role_callout, # type: ignore[arg-type] ) ) diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index 36dddfd..742c19d 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -174,6 +174,27 @@ def _build_user( "## 場の状況", logic.public_state_summary or "(情報なし)", ] + if logic.pending_role_callouts: + # Surface outstanding role-callouts as their own block so the + # model treats them as a 1st-class signal rather than a token + # buried in the dense status string. Real role holders should + # take this as a CO trigger; wolf-side NPCs should weigh whether + # to fake CO. Wording stays neutral so persona / strategy decide. + callout_ja = { + "seer": "占い師", + "medium": "霊媒師", + "knight": "騎士", + } + labels = "、".join( + f"{callout_ja.get(c, c)} ({c})" for c in logic.pending_role_callouts + ) + lines.append("") + lines.append("## 未回答の役職呼びかけ") + lines.append( + f"次の役職に名乗り出が求められているがまだ誰も応答していない: {labels}。" + "あなたが該当役職なら CO の判断材料に、" + "人狼/狂人で騙りを検討中なら呼びかけに応じる選択肢として参照すること。" + ) # Phase-D: prefer the bot's own NpcGameState mirror over the stale # SpeakRequest fields. The state carries role + alive/dead + private # results + wolf chat that the speech LLM needs to be in character. diff --git a/src/wolfbot/persistence/schema.py b/src/wolfbot/persistence/schema.py index 61fa1d0..1762244 100644 --- a/src/wolfbot/persistence/schema.py +++ b/src/wolfbot/persistence/schema.py @@ -169,6 +169,7 @@ summary TEXT, co_declaration TEXT, addressed_seat_no INTEGER, + role_callout TEXT, created_at_ms INTEGER NOT NULL ) """, @@ -302,6 +303,10 @@ async def migrate(db_path: str | Path) -> None: await db.execute( "ALTER TABLE speech_events ADD COLUMN addressed_seat_no INTEGER" ) + if "role_callout" not in cols: + await db.execute( + "ALTER TABLE speech_events ADD COLUMN role_callout TEXT" + ) async with db.execute("PRAGMA table_info(npc_speak_requests)") as cur: cols = {row[1] async for row in cur} if "selection_reason" not in cols: diff --git a/src/wolfbot/services/discord_service.py b/src/wolfbot/services/discord_service.py index bcfd97e..8ad4530 100644 --- a/src/wolfbot/services/discord_service.py +++ b/src/wolfbot/services/discord_service.py @@ -716,6 +716,7 @@ async def on_message(self, message: discord.Message) -> None: # broken analyzer must not block the SpeechEvent write. addressed_seat_no: int | None = None co_declaration: str | None = None + role_callout: str | None = None if self._text_analyzer is not None: from wolfbot.services.llm_trace import trace_context @@ -741,6 +742,7 @@ async def on_message(self, message: discord.Message) -> None: ) else: co_declaration = analysis.co_declaration + role_callout = analysis.role_callout if analysis.addressed_name: from wolfbot.master.ingest_service import ( resolve_seat_by_name, @@ -767,6 +769,7 @@ async def on_message(self, message: discord.Message) -> None: text=message.content, co_declaration=co_declaration, addressed_seat_no=addressed_seat_no, + role_callout=role_callout, ) await self._discussion_service.record(event) # Trigger arbiter dispatch so NPCs can respond to new text. diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py index 4da18ba..640fa62 100644 --- a/src/wolfbot/services/discussion_service.py +++ b/src/wolfbot/services/discussion_service.py @@ -95,8 +95,8 @@ async def insert(self, event: SpeechEvent) -> None: event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, alive_seat_nos_json, summary, co_declaration, addressed_seat_no, - created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + role_callout, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( event.event_id, @@ -115,6 +115,7 @@ async def insert(self, event: SpeechEvent) -> None: event.summary, event.co_declaration, event.addressed_seat_no, + event.role_callout, event.created_at_ms, ), ) @@ -126,7 +127,7 @@ async def load_phase(self, game_id: str, phase_id: str) -> Sequence[SpeechEvent] SELECT event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, alive_seat_nos_json, summary, co_declaration, addressed_seat_no, - created_at_ms + role_callout, created_at_ms FROM speech_events WHERE game_id=? AND phase_id=? ORDER BY created_at_ms ASC, event_id ASC @@ -142,7 +143,7 @@ async def load_for_game(self, game_id: str) -> Sequence[SpeechEvent]: SELECT event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, alive_seat_nos_json, summary, co_declaration, addressed_seat_no, - created_at_ms + role_callout, created_at_ms FROM speech_events WHERE game_id=? ORDER BY created_at_ms ASC, event_id ASC @@ -171,7 +172,8 @@ def _row_to_event(row: Any) -> SpeechEvent: summary=row[13], co_declaration=row[14], addressed_seat_no=row[15], - created_at_ms=row[16], + role_callout=row[16], + created_at_ms=row[17], ) @@ -231,6 +233,7 @@ def make_human_text_event( text: str, co_declaration: str | None = None, addressed_seat_no: int | None = None, + role_callout: str | None = None, created_at_ms: int | None = None, ) -> SpeechEvent: return SpeechEvent( @@ -245,6 +248,7 @@ def make_human_text_event( text=text, co_declaration=co_declaration, addressed_seat_no=addressed_seat_no, + role_callout=role_callout, created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), ) @@ -259,6 +263,7 @@ def make_npc_generated_event( text: str, co_declaration: str | None = None, addressed_seat_no: int | None = None, + role_callout: str | None = None, created_at_ms: int | None = None, ) -> SpeechEvent: return SpeechEvent( @@ -273,6 +278,7 @@ def make_npc_generated_event( text=text, co_declaration=co_declaration, addressed_seat_no=addressed_seat_no, + role_callout=role_callout, created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), ) @@ -565,6 +571,20 @@ def apply_speech_event( if speaker is not None: summary.append((speaker, is_new_co)) summary = summary[-6:] + # Track outstanding role-callouts (e.g. "占い師の方どうぞ"). A request + # adds the role to the pending set; a matching CO consumes it + # (= the call was answered). Wolf-side NPCs and real role holders + # both react to this in their speech prompt. + pending_role_callouts = set(state.pending_role_callouts) + if event.role_callout is not None: + pending_role_callouts.add(event.role_callout) + if is_new_co: + # `_resolve_co_role` returned a role; remove it from the pending + # set even if it wasn't explicitly requested — anyone CO'ing + # implicitly answers any outstanding call. + for role_key in tuple(pending_role_callouts): + if event.co_declaration == role_key or _resolve_co_role(event) == role_key: + pending_role_callouts.discard(role_key) return PublicDiscussionState( game_id=state.game_id, phase_id=state.phase_id, @@ -581,6 +601,7 @@ def apply_speech_event( last_addressed_text=last_addressed_text, last_speaker_seat=last_speaker_seat, recent_speech_summary=tuple(summary), + pending_role_callouts=frozenset(pending_role_callouts), ) @@ -629,6 +650,7 @@ def rebuild_public_state_from_events( recent_ids: list[str] = [] summary: list[tuple[int, bool]] = [] seen_co: set[tuple[int, str]] = set() + pending_role_callouts: set[str] = set() last_addressed_seat: int | None = None last_addressed_speaker_seat: int | None = None last_addressed_text: str = "" @@ -658,6 +680,10 @@ def rebuild_public_state_from_events( last_addressed_text = "" if event.speaker_seat is None: continue + # Track outstanding role-callouts (request → pending; matching + # CO consumes from the set). Per-event update mirror. + if event.role_callout is not None: + pending_role_callouts.add(event.role_callout) # `is_new_co` flag goes into `recent_speech_summary` so the arbiter # can detect "two seats arguing without new information" — a # repeated CO from the same seat does NOT count as info. @@ -670,6 +696,8 @@ def rebuild_public_state_from_events( summary.append((event.speaker_seat, is_new_co)) if role_key is None: continue + if is_new_co: + pending_role_callouts.discard(role_key) if (event.speaker_seat, role_key) in seen_co: continue seen_co.add((event.speaker_seat, role_key)) @@ -689,6 +717,7 @@ def rebuild_public_state_from_events( state.last_addressed_text = last_addressed_text state.last_speaker_seat = last_speaker_seat state.recent_speech_summary = tuple(summary[-6:]) + state.pending_role_callouts = frozenset(pending_role_callouts) return state diff --git a/tests/test_addressed_npc_routing.py b/tests/test_addressed_npc_routing.py index fe72c1f..66b83f0 100644 --- a/tests/test_addressed_npc_routing.py +++ b/tests/test_addressed_npc_routing.py @@ -786,6 +786,84 @@ def test_unrelated_npc_speech_does_not_clear_pending_address() -> None: ) +def test_pending_role_callout_added_by_voice_event() -> None: + """A voice STT event carrying ``role_callout="seer"`` adds 'seer' to the + PublicDiscussionState's `pending_role_callouts` set so downstream NPC + prompts can show the outstanding request.""" + phase_id = make_phase_id("g", 1, Phase.DAY_DISCUSSION) + events = [ + make_phase_baseline( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], created_at_ms=1, + ), + make_human_text_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="占い師の方は名乗り出てください", + role_callout="seer", created_at_ms=10, + ), + ] + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.pending_role_callouts == frozenset({"seer"}) + + +def test_pending_role_callout_cleared_when_seer_co_arrives() -> None: + """When a CO matching the outstanding callout role arrives, the role + is removed from `pending_role_callouts` (= the call was answered).""" + phase_id = make_phase_id("g", 1, Phase.DAY_DISCUSSION) + events = [ + make_phase_baseline( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], created_at_ms=1, + ), + make_human_text_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="占い師の方は名乗り出てください", + role_callout="seer", created_at_ms=10, + ), + make_npc_generated_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, text="実は私、占い師なんだ", + co_declaration="seer", created_at_ms=20, + ), + ] + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.pending_role_callouts == frozenset() + + +def test_pending_role_callout_unanswered_stays_pending() -> None: + """Speeches without a matching CO must NOT clear the pending callout.""" + phase_id = make_phase_id("g", 1, Phase.DAY_DISCUSSION) + events = [ + make_phase_baseline( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], created_at_ms=1, + ), + make_human_text_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="占い師の方は名乗り出てください", + role_callout="seer", created_at_ms=10, + ), + make_npc_generated_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, text="まだ様子を見たい", + created_at_ms=20, + ), + ] + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.pending_role_callouts == frozenset({"seer"}) + + async def test_co_claims_carry_across_phase_boundaries(repo: SqliteRepo) -> None: """Day-1 seer CO must still be visible in day-2's PublicDiscussionState so a wolf NPC can decide to counter-CO on day 2. Previously the fold diff --git a/tests/test_npc_seat_assignment.py b/tests/test_npc_seat_assignment.py index 443a34f..58ad930 100644 --- a/tests/test_npc_seat_assignment.py +++ b/tests/test_npc_seat_assignment.py @@ -505,6 +505,29 @@ def test_build_user_prompt_handles_past_vote_abstain() -> None: assert "席1 Alice → 棄権" in out +def test_build_user_prompt_renders_pending_role_callouts() -> None: + """Outstanding role-callouts must surface as a dedicated section in + the NPC prompt so real role holders take it as a CO trigger and + wolf-side NPCs can decide to fake CO.""" + logic = LogicPacket( + ts=1, trace_id="t", packet_id="lp", phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="(d)", expires_at_ms=9999, + pending_role_callouts=("seer",), + ) + request = SpeakRequest( + ts=1, trace_id="t", request_id="sr", npc_id="npc_1", + phase_id="ph", seat_no=2, logic_packet_id="lp", + suggested_intent="speak", max_chars=80, expires_at_ms=5000, + alive_seats=((1, "Alice"), (2, "ジナ"), (3, "ラキオ")), + dead_seats=(), + ) + out = _build_user(logic, request) + assert "## 未回答の役職呼びかけ" in out + assert "占い師" in out + assert "(seer)" in out + + def test_build_user_prompt_no_candidates_no_pressure() -> None: logic = LogicPacket( ts=1, From 782f47f41aa837791331cbdeb3ad3f4304de75b5 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 13:19:05 +0900 Subject: [PATCH 066/133] feat(reactive_voice): per-seat speech_count axis to even out NPC rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once every alive NPC had spoken once, silent_seats emptied and the picker collapsed to (LRU, lowest_seat) — so the lowest-seat NPC kept winning and wolf-side seats at higher seat numbers rarely got speaking turns (and so rarely fake-CO'd). Replace the binary silent_seats axis with a continuous per-seat speech_count carried on PublicDiscussionState; the arbiter now prefers the seat with the lowest count this phase. Adds a low_count_rotation selection_reason and surfaces speech_counts in the request snapshot. --- src/wolfbot/domain/discussion.py | 10 ++ src/wolfbot/master/speak_arbiter.py | 41 ++++- src/wolfbot/services/discussion_service.py | 13 ++ tests/test_public_discussion_state.py | 34 ++++ tests/test_reactive_voice_master.py | 191 +++++++++++++++++++++ 5 files changed, 282 insertions(+), 7 deletions(-) diff --git a/src/wolfbot/domain/discussion.py b/src/wolfbot/domain/discussion.py index 0c17cbb..fce8a50 100644 --- a/src/wolfbot/domain/discussion.py +++ b/src/wolfbot/domain/discussion.py @@ -190,3 +190,13 @@ class PublicDiscussionState(BaseModel): # take it as a CO trigger and wolf-side NPCs can decide whether to # fake CO. pending_role_callouts: frozenset[str] = frozenset() + # Per-seat utterance count within this phase (non-baseline events + # only). The arbiter prefers seats with the lowest count so a + # talkative NPC doesn't monopolize the phase — the binary + # ``silent_seats`` was indistinguishable once every seat had spoken + # once, after which the rotation collapsed to seat-number tiebreak + # and the lowest-seat NPC kept winning. ``speech_counts`` extends + # that signal across the entire phase so wolf-side NPCs at higher + # seat numbers still get fair speaking turns (= more chances to + # fake-CO). + speech_counts: dict[int, int] = Field(default_factory=dict) diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index 3afcd54..fad9284 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -732,7 +732,12 @@ async def try_dispatch_next(self, game_id: str) -> None: # speaker cap. Demoted seats fall to the bottom regardless # of being addressed, so a 3rd NPC can break in. # 2. addressed seat — recent utterance's `addressed_seat_no`. - # 3. silent seats — NPCs who haven't yet spoken in this phase. + # 3. lowest speech_count this phase — generalises the old + # binary silent_seats: a 0-count seat is still preferred, + # but a 1-count seat now also wins over a 5-count one. Stops + # the lowest-seat NPC monopolising once everyone has spoken + # once and gives wolf-side seats at higher seat numbers a + # fair chance to fake-CO. # 4. NOT the immediate previous speaker (LRU rotation). # 5. lowest assigned_seat as a stable tiebreaker. addressed = state.last_addressed_seat @@ -746,17 +751,27 @@ def _pick_key(e: object) -> tuple[int, int, int, int, int]: is_addressed = 0 if ( addressed is not None and seat == addressed ) else 1 - in_silent = 0 if seat in state.silent_seats else 1 + count = state.speech_counts.get(seat, 0) is_just_spoke = 1 if ( last_speaker is not None and seat == last_speaker ) else 0 - return (is_demoted, is_addressed, in_silent, is_just_spoke, seat) + return (is_demoted, is_addressed, count, is_just_spoke, seat) online_npc_seats = sorted( e.assigned_seat for e in online if e.assigned_seat is not None and e.game_id == game_id ) + # Counts per seat, restricted to the candidates the arbiter can + # actually pick — used both for the snapshot and to classify the + # reason as ``low_count_rotation`` when the winning seat has + # spoken but strictly less than someone else online. + candidate_counts: dict[int, int] = { + seat: state.speech_counts.get(seat, 0) + for seat in online_npc_seats + if seat in state.alive_seat_nos + } + max_candidate_count = max(candidate_counts.values(), default=0) snapshot: dict[str, Any] = { "phase_id": state.phase_id, "day": state.day, @@ -767,6 +782,7 @@ def _pick_key(e: object) -> tuple[int, int, int, int, int]: "alive_seat_nos": sorted(state.alive_seat_nos), "online_npc_seats": online_npc_seats, "demoted_seats": sorted(demoted), + "speech_counts": sorted(candidate_counts.items()), } for entry in sorted(online, key=_pick_key): @@ -775,6 +791,7 @@ def _pick_key(e: object) -> tuple[int, int, int, int, int]: if entry.assigned_seat not in state.alive_seat_nos: continue seat = entry.assigned_seat + picked_count = state.speech_counts.get(seat, 0) if seat in demoted: # Reached this branch only when EVERY non-demoted # candidate was filtered out (offline / dead / not in @@ -784,11 +801,21 @@ def _pick_key(e: object) -> tuple[int, int, int, int, int]: reason = "addressed" elif seat in state.silent_seats: reason = "silent_rotation" + elif demoted: + # The pair-volley gate fired and a non-demoted third + # party won — labelled distinctly from low_count_rotation + # so the viewer keeps showing "stuck volley → diverted to + # seat N" even though the speech_count axis happens to + # favour the same seat. + reason = "low_info_diversion" + elif picked_count < max_candidate_count: + # Already spoke this phase, but strictly less than some + # other online candidate — the speech_count axis is what + # broke the tie. Distinct from silent_rotation (count==0) + # and from lru_rotation (counts equal, LRU won). + reason = "low_count_rotation" elif last_speaker is not None and seat != last_speaker: - # When at least one seat got demoted *and* we ended up - # picking a non-demoted third party, label it so the - # viewer can show "stuck volley → diverted to seat N". - reason = "low_info_diversion" if demoted else "lru_rotation" + reason = "lru_rotation" else: reason = "seat_tiebreak" await self.dispatch_request( diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py index 640fa62..96943bb 100644 --- a/src/wolfbot/services/discussion_service.py +++ b/src/wolfbot/services/discussion_service.py @@ -557,6 +557,13 @@ def apply_speech_event( last_speaker_seat = ( speaker if speaker is not None else state.last_speaker_seat ) + # Per-seat utterance count within the phase. Increment on every + # non-baseline speaker; the arbiter's `_pick_key` reads this so an + # NPC who's already spoken N times falls below NPCs who spoke + # fewer times. + speech_counts = dict(state.speech_counts) + if speaker is not None: + speech_counts[speaker] = speech_counts.get(speaker, 0) + 1 # Append (speaker, has_info) to the sliding summary window the # arbiter uses for low-info pair-volley detection. ``has_info`` is # the structured "did this event move the discussion forward" @@ -602,6 +609,7 @@ def apply_speech_event( last_speaker_seat=last_speaker_seat, recent_speech_summary=tuple(summary), pending_role_callouts=frozenset(pending_role_callouts), + speech_counts=speech_counts, ) @@ -651,6 +659,7 @@ def rebuild_public_state_from_events( summary: list[tuple[int, bool]] = [] seen_co: set[tuple[int, str]] = set() pending_role_callouts: set[str] = set() + speech_counts: dict[int, int] = {} last_addressed_seat: int | None = None last_addressed_speaker_seat: int | None = None last_addressed_text: str = "" @@ -661,6 +670,9 @@ def rebuild_public_state_from_events( if event.speaker_seat is not None: spoken_seats.add(event.speaker_seat) last_speaker_seat = event.speaker_seat + speech_counts[event.speaker_seat] = ( + speech_counts.get(event.speaker_seat, 0) + 1 + ) recent_ids.append(event.event_id) # Mirror the per-event update logic in `_apply_event_to_state`: # only consume the standing address when the NPC speaker IS the @@ -718,6 +730,7 @@ def rebuild_public_state_from_events( state.last_speaker_seat = last_speaker_seat state.recent_speech_summary = tuple(summary[-6:]) state.pending_role_callouts = frozenset(pending_role_callouts) + state.speech_counts = speech_counts return state diff --git a/tests/test_public_discussion_state.py b/tests/test_public_discussion_state.py index 9200105..a661683 100644 --- a/tests/test_public_discussion_state.py +++ b/tests/test_public_discussion_state.py @@ -73,6 +73,7 @@ def test_apply_then_rebuild_agree() -> None: assert folded.co_claims == rebuilt.co_claims assert folded.silent_seats == rebuilt.silent_seats assert folded.recent_speech_event_ids == rebuilt.recent_speech_event_ids + assert folded.speech_counts == rebuilt.speech_counts def test_silent_seats_excludes_dead_seats() -> None: @@ -237,3 +238,36 @@ def test_co_claim_legacy_substring_fallback_still_works_when_field_absent() -> N state = rebuild_public_state_from_events(events) assert state is not None assert [(c.seat, c.role_claim) for c in state.co_claims] == [(1, "seer")] + + +def test_speech_counts_increment_per_speaker() -> None: + """`speech_counts` records how many non-baseline events each seat has + produced this phase. The arbiter's pick logic reads this so a + talkative NPC drops below quieter ones once everyone has spoken at + least once. + """ + events = _seed( + alive=[1, 2, 3], + events_payload=[ + (1, "ラキオ1"), + (2, "セツ1"), + (1, "ラキオ2"), + (1, "ラキオ3"), + ], + ) + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.speech_counts == {1: 3, 2: 1} + # silent_seats stays binary — seat 3 never spoke. The new field is + # additive and complements (not replaces) the silent baseline. + assert state.silent_seats == frozenset({3}) + + +def test_speech_counts_excludes_baseline_sentinel() -> None: + """The `phase_baseline` sentinel has speaker_seat=None, so it must + not bump any seat's count. + """ + events = _seed(alive=[1, 2, 3], events_payload=[]) + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.speech_counts == {} diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index 96caa8e..609f385 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -948,6 +948,197 @@ async def test_try_dispatch_next_avoids_immediate_repeat_after_first_round( assert not bufs["gina"] +async def test_try_dispatch_next_prefers_lower_speech_count( + repo: SqliteRepo, +) -> None: + """The seat with the lowest phase-wide speech_count wins, even when + every alive NPC has spoken at least once (so silent_seats is empty). + + Reproduces the in-the-wild imbalance where the lowest-seat-number NPC + monopolised once the silent rotation completed: with the binary + silent_seats only, seat 1 with count=4 still tied with seat 3 at + count=1 on the silent axis and won by seat tiebreak. The new sort + treats count as a continuous signal so seat 3 is preferred. + """ + g = Game( + id="rv-count", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, + is_llm=True, persona_key="raqio"), + Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, + is_llm=True, persona_key="setsu"), + Seat(seat_no=3, display_name="🟣ジナ", discord_user_id=None, + is_llm=True, persona_key="gina"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.MADMAN) + await repo.set_player_role(g.id, 3, Role.SEER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], created_at_ms=1, + ) + ) + from wolfbot.services.discussion_service import make_npc_generated_event + + # Counts after seeding: seat1=4, seat2=4, seat3=1. Seat 3 also speaks + # in the recent window (event ts=60) so the pair-volley demotion + # gate stays quiet — the only differentiator left is the per-seat + # speech_count. Without that axis seat 1 wins by lowest seat tie- + # break; with it seat 3 beats both 4-counts. + payload = [ + (10, 1, "ラキオ1巡目"), # 1:1 + (20, 2, "セツの占いCO"), # 2:1 (CO → has_info=True) + (30, 1, "ラキオ反論1"), # 1:2 + (40, 2, "セツ反応1"), # 2:2 + (50, 1, "ラキオ反論2"), # 1:3 + (55, 2, "セツ反応2"), # 2:3 + (60, 3, "ジナの差し込み"), # 3:1 (breaks pair window) + (70, 1, "ラキオ反論3"), # 1:4 + (80, 2, "セツ反応3"), # 2:4 + ] + for ts, seat, text in payload: + kwargs: dict[str, object] = dict( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat, text=text, created_at_ms=ts, + ) + if "占いCO" in text: + kwargs["co_declaration"] = "seer" + await store.insert(make_npc_generated_event(**kwargs)) # type: ignore[arg-type] + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"raqio": [], "setsu": [], "gina": []} + for npc_id, persona, seat_no in ( + ("npc_raqio", "raqio", 1), + ("npc_setsu", "setsu", 2), + ("npc_gina", "gina", 3), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", + supported_voices=(), version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 2000, + ) + await arb.try_dispatch_next(g.id) + + assert bufs["gina"], "ジナ (count=1) must outrank counts of 4" + assert not bufs["raqio"] + assert not bufs["setsu"] + + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "3" + assert reason == "low_count_rotation" + + +async def test_try_dispatch_next_lru_when_speech_counts_tied( + repo: SqliteRepo, +) -> None: + """Two NPCs at equal speech_count — the just-spoke seat is demoted + (LRU), and the remaining seat wins. The reason is ``lru_rotation``, + not ``low_count_rotation`` (no count differential to leverage). + """ + g = Game( + id="rv-lru-tied", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, + is_llm=True, persona_key="raqio"), + Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, + is_llm=True, persona_key="setsu"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2], created_at_ms=1, + ) + ) + from wolfbot.services.discussion_service import make_npc_generated_event + + # seat 2 occupies the just-spoke slot at the end so LRU pushes us + # back to seat 1. Both seats have count=2 so the count axis is + # neutral. + for ts, seat in ((10, 1), (20, 2), (30, 1), (40, 2)): + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat, text=f"seat {seat} ts={ts}", + co_declaration="seer" if (ts, seat) == (10, 1) else None, + created_at_ms=ts, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"raqio": [], "setsu": []} + for npc_id, persona, seat_no in ( + ("npc_raqio", "raqio", 1), + ("npc_setsu", "setsu", 2), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", + supported_voices=(), version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 2000, + ) + await arb.try_dispatch_next(g.id) + + assert bufs["raqio"] + assert not bufs["setsu"] + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "1" + assert reason == "lru_rotation" + + async def test_pair_volley_demotion_fires_after_4_low_info_speeches( repo: SqliteRepo, ) -> None: From 0c4193f5d1d3e98284e1b01b35f30da47485c2e1 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 13:32:38 +0900 Subject: [PATCH 067/133] fix(reactive_voice): NPC utterances no longer truncated mid-sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setsu's last reply was cut off at exactly 80 characters ("……ラキオさんが 最初に話を切り出し、それが気") because the LLM happily filled the cap without finishing the thought. Two changes: - Raise max_chars_reactive 80→140 so a complete reactive thought has room. VOICEVOX playback at 140 chars stays well under the 12s playback_deadline_ms. - Tell the NPC system prompt to finish the sentence even if that means trimming content to fit the cap, instead of running out the budget mid-clause. --- src/wolfbot/master/speak_arbiter.py | 10 +++++++++- src/wolfbot/npc/openai_compatible_generator.py | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index fad9284..368a15b 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -111,7 +111,15 @@ def _compute_demoted_seats( @dataclass class SpeakArbiterConfig: - max_chars_reactive: int = 80 + # Per-utterance hard cap. Was 80 originally — too tight: the LLM + # routinely hit the limit mid-sentence (e.g. ending with 「それが気」 + # when about to say 「気になって」), and VOICEVOX then read the + # truncated fragment aloud. 140 leaves room for a complete thought + # while keeping playback under the 12s deadline (≈8s of audio at + # VOICEVOX's default speed). The system prompt also instructs the + # model to finish a sentence even if it has to be shorter than this + # cap, so 140 is the ceiling, not the target length. + max_chars_reactive: int = 140 request_ttl_ms: int = 8000 playback_deadline_ms: int = 12_000 heartbeat_timeout_ms: int = 5000 diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index 742c19d..7534474 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -128,7 +128,10 @@ def _build_system( f"{strategy_block}" "## ルール\n" "- 日本語のみ。メタ発言禁止。AIであることに言及しない。\n" - f"- `text` は {max_chars} 文字以内の短い発言。\n" + f"- `text` は {max_chars} 文字以内の短い発言。" + f"上限ぎりぎりまで埋めようとせず、必ず文を最後まで言い切ること。" + f"句読点や用言の途中で終わらないようにし、{max_chars} 文字に収めるためなら" + "内容を削ってでも完結した文にする。\n" "- 発言しない場合は intent を `skip`、text を空文字にする。\n" "- `used_logic_ids` には参考にした logic candidate の id を入れる。\n" "- **`text` 内で人狼用語(メタ語彙)を使わない。** 内部の思考では使ってよいが、" From 07c4b99b353cc986a65192932ead8fe067c17441 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 13:59:13 +0900 Subject: [PATCH 068/133] fix(reactive_voice): runoff candidate speeches now go through TTS pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In reactive_voice mode the DAY_RUNOFF_SPEECH transition was still calling the rounds-mode `submit_llm_runoff_candidate_speeches` batch — Master's gameplay LLM generated each candidate's speech, posted it to Discord text, and called it done. The NPC bot pipeline (and therefore VOICEVOX playback in VC) was bypassed entirely, so candidates' final speeches appeared in chat but were never spoken. - game_service: under reactive_voice, skip the rounds-mode batch on DAY_RUNOFF_SPEECH entry and route through `_on_reactive_phase_enter` so SpeakArbiter takes over. - speak_arbiter: new `_dispatch_runoff_next` picker constrained to tied LLM candidates with one-shot semantics. Each candidate's `runoff_speech_done` flips after their SpeakResult resolves (accepted, rejected, expired, declined, or oversized) so the engine's `plan_runoff_speech_to_runoff` can advance. Offline candidates are marked done immediately to keep the phase moving. A per-request watchdog covers the case where the NPC accepts the SpeakRequest but never replies. An optional `runoff_wake` callback wakes the engine the moment progress flips so the phase doesn't sit on the deadline. - narration: new `render_runoff_candidate_intro` template — Levi names each candidate before their speech ("続いて、X 様の最終演説でございます"). main.py wires this as the arbiter's `runoff_announce` hook, awaited so the Master narration finishes before NPC TTS starts. - tests: cover sequential dispatch, runoff_speech_done flip, engine wake, and the offline-candidate skip. --- src/wolfbot/main.py | 36 ++++ src/wolfbot/master/narration.py | 19 ++ src/wolfbot/master/speak_arbiter.py | 304 +++++++++++++++++++++++++++ src/wolfbot/services/game_service.py | 19 ++ tests/test_reactive_voice_master.py | 224 ++++++++++++++++++++ 5 files changed, 602 insertions(+) diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 7a5c29c..32fb496 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -712,10 +712,42 @@ async def _on_speech_recorded(game_id: str) -> None: _npc_registry_ref.append(npc_registry) + # Late-bound holder so arbiter can call into master_tts (built + # below) without reordering construction. GameService is already + # in scope so we can pin it immediately. + _master_tts_holder: list[Any] = [] + _game_service_holder: list[Any] = [game_service] + + async def _runoff_announce(seat: Any) -> None: + """Levi voice-introduces a tied runoff candidate before TTS.""" + from wolfbot.master.narration import render_runoff_candidate_intro + + if not _master_tts_holder: + return + tts = _master_tts_holder[0] + try: + async with tts.suppress_npc_dispatch(arbiter): + await tts.speak(render_runoff_candidate_intro(seat)) + except Exception: + log.exception( + "runoff_candidate_intro_failed seat=%d", + getattr(seat, "seat_no", -1), + ) + + def _runoff_wake(game_id: str) -> None: + if not _game_service_holder: + return + try: + _game_service_holder[0].wake.wake(game_id) + except Exception: + log.exception("runoff_wake_invocation_failed game=%s", game_id) + arbiter = SpeakArbiter( repo=repo, registry=npc_registry, discussion=discussion_service, + runoff_announce=_runoff_announce, + runoff_wake=_runoff_wake, ) _reactive_phase_cb.append(arbiter) recovery._reactive_voice_sweep = arbiter.reactive_voice_recovery_sweep @@ -791,6 +823,10 @@ async def _reactive_voice_reenter(game_id: str) -> None: voice_id=str(settings.MASTER_TTS_VOICE_ID), vc_ref=master_vc_ref, ) + # Hand the live MasterTtsPlayback to the arbiter's runoff_announce + # closure (built earlier with a late-binding holder so we didn't + # need to reorder construction). + _master_tts_holder.append(master_tts) async def _post_to_vc_chat(game: Any, text: str) -> None: """Post `text` to the VC's attached text chat. diff --git a/src/wolfbot/master/narration.py b/src/wolfbot/master/narration.py index 1fec0a1..402c503 100644 --- a/src/wolfbot/master/narration.py +++ b/src/wolfbot/master/narration.py @@ -277,8 +277,27 @@ def render_master_narration( return handler(entry, ctx) +def render_runoff_candidate_intro(seat: Seat) -> str: + """Levi-styled introduction line for a runoff candidate's speech. + + Spoken by Master in VC right before SpeakArbiter dispatches the + candidate's SpeakRequest, so listeners always know whose final + speech is starting (the same way 「占い師の発表」 patterns name the + speaker out loud). Uses the same emoji-stripping logic as + ``_seat_label`` so the readout pronounces the persona name cleanly. + """ + name = seat.display_name.lstrip() + while name and not (name[0].isalnum() or "぀" <= name[0] <= "ヿ"): + name = name[1:] + name = name.strip() or seat.display_name + return ( + f"続いて、{name} 様の最終演説でございます。どうぞ。" + ) + + __all__ = [ "NarrationContext", "NarrationOutput", "render_master_narration", + "render_runoff_candidate_intro", ] diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index 368a15b..d395413 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -24,6 +24,7 @@ from __future__ import annotations +import asyncio import logging import uuid from collections.abc import Awaitable, Callable, Sequence @@ -37,6 +38,8 @@ make_phase_id, ) from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Seat +from wolfbot.domain.rules import compute_vote_result from wolfbot.domain.ws_messages import ( PlaybackAuthorized, PlaybackFailed, @@ -80,6 +83,22 @@ _CONSECUTIVE_CAP = 3 +def _parse_day_from_phase_id(phase_id: str) -> int | None: + """Extract the integer day from a canonical phase_id token. + + Phase ids look like ``gid::day3::DAY_RUNOFF_SPEECH::1``; we walk the + ``::`` segments and look for one matching ``dayN``. Returns ``None`` + when the format doesn't match — the caller treats that as "skip, + don't mark done" rather than crashing the WS handler. + """ + for token in phase_id.split("::"): + if token.startswith("day"): + tail = token[3:] + if tail.isdigit(): + return int(tail) + return None + + def _compute_demoted_seats( summary: Sequence[tuple[int, bool]], ) -> frozenset[int]: @@ -153,12 +172,27 @@ def __init__( discussion: DiscussionService, config: SpeakArbiterConfig | None = None, now_ms: Callable[[], int] = default_now_ms, + runoff_announce: Callable[[Seat], Awaitable[None]] | None = None, + runoff_wake: Callable[[str], None] | None = None, ) -> None: self.repo = repo self.registry = registry self.discussion = discussion self.config = config or SpeakArbiterConfig() self._now_ms = now_ms + # Optional Master-narration hook fired right before dispatching a + # tied LLM candidate's SpeakRequest in DAY_RUNOFF_SPEECH so Levi + # can name the speaker ("続いて、X 様の最終演説でございます。"). + # The arbiter awaits the callback so the announcement plays + # before the NPC's TTS so they don't overlap in VC. + self._runoff_announce = runoff_announce + # Optional engine-wake hook called when every tied LLM candidate + # has finished or been marked done; lets DAY_RUNOFF_SPEECH advance + # immediately to DAY_RUNOFF instead of waiting for the deadline. + self._runoff_wake = runoff_wake + # Strong refs to per-runoff watchdog tasks so they don't get + # garbage-collected mid-sleep. + self._runoff_watchdog_tasks: set[asyncio.Task[None]] = set() self._pending: dict[str, _PendingRequest] = {} # Serial-speech gate: a request_id is in `_active_playback` between # PlaybackAuthorized and the closing tts_failed / playback_finished / @@ -543,15 +577,35 @@ async def _record_rejection(reason: str) -> None: return (False, "unknown_request") if result.phase_id != current_phase_id: await _record_rejection("stale_phase") + await self._mark_runoff_done_if_phase( + game_id=pending.game_id, + phase_id=result.phase_id, + seat_no=pending.seat_no, + ) return (False, "stale_phase") if now > pending.expires_at_ms: await _record_rejection("expired_request") + await self._mark_runoff_done_if_phase( + game_id=pending.game_id, + phase_id=result.phase_id, + seat_no=pending.seat_no, + ) return (False, "expired_request") if result.status != "accepted" or not result.text: await _record_rejection("speaker_declined") + await self._mark_runoff_done_if_phase( + game_id=pending.game_id, + phase_id=result.phase_id, + seat_no=pending.seat_no, + ) return (False, "speaker_declined") if len(result.text) > self.config.max_chars_reactive: await _record_rejection("utterance_too_long") + await self._mark_runoff_done_if_phase( + game_id=pending.game_id, + phase_id=result.phase_id, + seat_no=pending.seat_no, + ) return (False, "utterance_too_long") # Accepted. Persist result + SpeechEvent + open playback row. @@ -630,6 +684,15 @@ async def _record_rejection(reason: str) -> None: playback_deadline_ms=deadline, ) await _send(authorized.model_dump_json()) + # Runoff candidate: mark this seat's speech done so the engine + # can advance to DAY_RUNOFF as soon as every tied LLM has spoken. + # Done after the SpeechEvent is recorded so the runoff_speech log + # row exists when `plan_runoff_speech_to_runoff` polls progress. + await self._mark_runoff_done_if_phase( + game_id=pending.game_id, + phase_id=result.phase_id, + seat_no=pending.seat_no, + ) return (True, None) # ------------------------------------------------------------- TTS / playback @@ -712,6 +775,9 @@ async def try_dispatch_next(self, game_id: str) -> None: Called on phase entry, after each new public speech event, and after playback completes. No-op when the serial-speech gate is blocked, no NPC is online, or no game is in a reactive_voice discussion phase. + + DAY_RUNOFF_SPEECH uses a separate picker (`_dispatch_runoff_next`) + constrained to tied candidates with one shot each — see that method. """ # Close expired playback rows in DB before checking the gate. await self._sweep_expired_playback() @@ -728,6 +794,10 @@ async def try_dispatch_next(self, game_id: str) -> None: if block is not None: return + if game.phase is Phase.DAY_RUNOFF_SPEECH: + await self._dispatch_runoff_next(game_id, game.day_number) + return + state = await self.rebuild_public_state( game_id=game_id, day=game.day_number, phase=game.phase ) @@ -836,6 +906,240 @@ def _pick_key(e: object) -> tuple[int, int, int, int, int]: ) return + # ------------------------------------------------------------- runoff dispatch + + async def _dispatch_runoff_next(self, game_id: str, day: int) -> None: + """Dispatch the next tied LLM candidate's final speech. + + Candidate set = round-0 tied seats ∩ alive ∩ LLM seat ∩ + ``runoff_speech_done = False``. The arbiter dispatches them + sequentially in seat-no order: pick → optional Master narration + intro → SpeakRequest → NPC TTS playback. After each candidate + resolves (accepted or any failure path), `runoff_speech_done` is + flipped so the engine's `plan_runoff_speech_to_runoff` advances + as soon as the last one finishes. + + When no eligible candidate remains (everyone done, or the only + ones left have no online NPC bot), `runoff_wake` is fired so the + engine doesn't sit on the deadline. + """ + seats = await self.repo.load_seats(game_id) + seats_by_no: dict[int, Seat] = {s.seat_no: s for s in seats} + players = await self.repo.load_players(game_id) + alive_set = {p.seat_no for p in players if p.alive} + round0 = await self.repo.load_votes(game_id, day=day, round_=0) + outcome = compute_vote_result(round0, alive_set) + tied = list(outcome.tied) + if not tied: + # Edge: no tied set (e.g. recovery race) → just wake so the + # engine can re-plan against fresh state. + self._maybe_wake_runoff(game_id) + return + + # Tied LLM seats whose runoff speech hasn't been recorded yet. + # Seat-no order is the user-visible "who speaks first" axis; we + # follow the same order rounds-mode used so logs / replays line + # up between modes. + eligible: list[Seat] = [] + for seat_no in sorted(tied): + seat = seats_by_no.get(seat_no) + if seat is None or not seat.is_llm: + continue + progress = await self.repo.load_llm_speech_progress( + game_id, day, seat_no + ) + if progress[4]: # runoff_speech_done + continue + eligible.append(seat) + + if not eligible: + # Every tied LLM has already spoken (or been skipped). Wake + # the engine so DAY_RUNOFF_SPEECH advances to DAY_RUNOFF + # without waiting for the safety-net deadline. + self._maybe_wake_runoff(game_id) + return + + # Find the first eligible seat with an online NPC bot. If a tied + # candidate has no online NPC (rare: misconfigured persona, bot + # crash before rejoin), mark them done and skip — otherwise the + # phase would stall forever. + chosen: Seat | None = None + chosen_npc_id: str | None = None + for seat in eligible: + entry = self._find_npc_for_seat(game_id, seat.seat_no) + if entry is None: + log.info( + "runoff_speech_no_online_npc game=%s seat=%d — " + "marking done so phase advances", + game_id, + seat.seat_no, + ) + try: + await self.repo.mark_llm_runoff_speech_done( + game_id, day, seat.seat_no + ) + except Exception: + log.exception( + "runoff_speech_done_mark_failed game=%s seat=%d", + game_id, + seat.seat_no, + ) + continue + chosen = seat + chosen_npc_id = entry.npc_id + break + + if chosen is None or chosen_npc_id is None: + # All remaining tied LLMs were skipped above. Wake the + # engine so it sees the marks we just wrote. + self._maybe_wake_runoff(game_id) + return + + # Master narration: name the candidate before they speak. Awaited + # so the intro finishes before the NPC's own TTS starts. + if self._runoff_announce is not None: + try: + await self._runoff_announce(chosen) + except Exception: + log.exception( + "runoff_announce_failed game=%s seat=%d", + game_id, + chosen.seat_no, + ) + + # Re-rebuild state AFTER the announcement so a fresh + # phase_baseline / event log is folded in. The state is also + # what `dispatch_request` consumes for the LogicPacket. + state = await self.rebuild_public_state( + game_id=game_id, day=day, phase=Phase.DAY_RUNOFF_SPEECH + ) + if state is None: + self._maybe_wake_runoff(game_id) + return + + snapshot: dict[str, Any] = { + "phase_id": state.phase_id, + "day": state.day, + "phase": Phase.DAY_RUNOFF_SPEECH.value, + "tied_candidates": sorted(tied), + "alive_seat_nos": sorted(state.alive_seat_nos), + } + request, reason = await self.dispatch_request( + state=state, + candidate_npc_id=chosen_npc_id, + seat_no=chosen.seat_no, + game_id=game_id, + selection_reason="runoff_candidate", + public_state_snapshot=snapshot, + ) + if request is None: + # Dispatch failed (npc_offline, ws_send_failed, gate held). + # The npc_offline + ws_send_failed cases would leave this + # seat permanently un-spoken, so mark done and re-dispatch + # so the phase keeps moving. ``queue_busy`` / + # ``human_currently_speaking`` are transient — just re-poll + # later via the normal try_dispatch_next pathway. + if reason in ("npc_offline", "ws_send_failed"): + await self._mark_runoff_done_if_phase( + game_id=game_id, + phase_id=state.phase_id, + seat_no=chosen.seat_no, + ) + await self.try_dispatch_next(game_id) + return + # Watchdog: if the NPC never returns a SpeakResult before the + # request's TTL expires, the phase would stall forever. Spawn + # a one-shot task that marks the seat done and re-dispatches + # so the engine eventually advances. The mark is idempotent + # (UPSERT), so a result that arrives in the same window is fine. + ttl_s = max(1.0, self.config.request_ttl_ms / 1000.0) + request_id = request.request_id + seat_no = chosen.seat_no + phase_id = state.phase_id + + async def _watchdog() -> None: + try: + await asyncio.sleep(ttl_s) + except asyncio.CancelledError: + return + if request_id not in self._pending: + return # SpeakResult already resolved this slot. + log.info( + "runoff_request_watchdog_fired game=%s seat=%d request=%s", + game_id, + seat_no, + request_id, + ) + await self._mark_runoff_done_if_phase( + game_id=game_id, + phase_id=phase_id, + seat_no=seat_no, + ) + self._pending.pop(request_id, None) + try: + await self.try_dispatch_next(game_id) + except Exception: + log.exception( + "runoff_watchdog_redispatch_failed game=%s", game_id + ) + + task = asyncio.create_task(_watchdog(), name=f"runoff-watchdog-{request_id}") + self._runoff_watchdog_tasks.add(task) + task.add_done_callback(self._runoff_watchdog_tasks.discard) + + def _find_npc_for_seat( + self, game_id: str, seat_no: int + ) -> Any | None: + """Lookup the registry entry pinned to ``(game_id, seat_no)``.""" + for entry in self.registry.all_online(): + if entry.assigned_seat == seat_no and entry.game_id == game_id: + return entry + return None + + def _maybe_wake_runoff(self, game_id: str) -> None: + if self._runoff_wake is None: + return + try: + self._runoff_wake(game_id) + except Exception: + log.exception("runoff_wake_failed game=%s", game_id) + + async def _mark_runoff_done_if_phase( + self, + *, + game_id: str, + phase_id: str, + seat_no: int, + ) -> None: + """Best-effort `runoff_speech_done = 1` for a finished SpeakResult. + + Reads the day from the phase_id format (`gid::dayN::PHASE::seq`) + so we don't need an extra DB hit. No-op when the phase token in + ``phase_id`` isn't DAY_RUNOFF_SPEECH (= the result was for an + ordinary discussion utterance and there's no progress to flip). + """ + if "::DAY_RUNOFF_SPEECH::" not in phase_id: + return + day = _parse_day_from_phase_id(phase_id) + if day is None: + return + try: + await self.repo.mark_llm_runoff_speech_done( + game_id, day, seat_no + ) + except Exception: + log.exception( + "runoff_speech_done_mark_failed game=%s day=%d seat=%d", + game_id, + day, + seat_no, + ) + return + # Wake the engine so `_plan_next` can re-evaluate immediately + # whether all tied candidates are done. Without this the phase + # would sit until the next deadline tick. + self._maybe_wake_runoff(game_id) + # ------------------------------------------------------------- game-end cleanup def cleanup_game(self, game_id: str) -> int: diff --git a/src/wolfbot/services/game_service.py b/src/wolfbot/services/game_service.py index 05ecadf..a66c3f7 100644 --- a/src/wolfbot/services/game_service.py +++ b/src/wolfbot/services/game_service.py @@ -451,6 +451,25 @@ async def _dispatch_submissions( # Same-phase grace re-commit must not redispatch. if previous_phase is Phase.DAY_RUNOFF_SPEECH: return + # In reactive_voice mode the rounds-mode batch (Master gameplay + # LLM → Discord text post) skips the entire NPC-bot TTS path, + # so candidates' final speeches end up as silent text. Route + # through the reactive phase-enter callback instead — it joins + # VC, seeds the phase baseline, assigns NPCs to seats, and + # kicks SpeakArbiter, which now picks tied candidates one at a + # time and dispatches via the NPC bot pipeline (with VOICEVOX + # playback). `runoff_speech_done` is marked by the arbiter as + # each candidate's SpeakResult or failure path resolves. + if new_game.discussion_mode == "reactive_voice": + if self._on_reactive_phase_enter is not None: + try: + await self._on_reactive_phase_enter(new_game.id) + except Exception: + log.exception( + "reactive_voice runoff phase enter failed for %s", + new_game.id, + ) + return round0 = await self.repo.load_votes(new_game.id, day=new_game.day_number, round_=0) alive_set = {p.seat_no for p in players_after if p.alive} tied = list(compute_vote_result(round0, alive_set).tied) diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index 609f385..d9a2157 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -1485,6 +1485,230 @@ async def test_arbiter_cleanup_game_drops_only_target_game(repo: SqliteRepo) -> assert arb._playback_deadlines["req-g2-c"] == 5_000 +async def test_runoff_dispatch_picks_tied_llm_candidates_in_order( + repo: SqliteRepo, +) -> None: + """In DAY_RUNOFF_SPEECH the arbiter ignores the regular speech_count + rotation and dispatches to **tied** LLM candidates, in seat-no order, + one at a time. After each accepted SpeakResult, ``runoff_speech_done`` + is flipped so the engine's `plan_runoff_speech_to_runoff` can advance + once the last tied candidate finishes. Reproduces the production bug + where Master's rounds-mode batch silently ran in reactive_voice mode + and TTS never played. + """ + from wolfbot.domain.models import Vote + + g = Game( + id="rv-runoff", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_RUNOFF_SPEECH, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, + is_llm=True, persona_key="raqio"), + Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, + is_llm=True, persona_key="setsu"), + Seat(seat_no=3, display_name="🟣ジナ", discord_user_id=None, + is_llm=True, persona_key="gina"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ((1, Role.WEREWOLF), (2, Role.SEER), (3, Role.MEDIUM)): + await repo.set_player_role(g.id, sn, role) + + # Round-0 votes that produce tied=(1, 2). Voter 3 abstains so the + # tally is 1=1, 2=1 — clean two-way tie. + for voter, target in ((1, 2), (2, 1), (3, None)): + await repo.insert_vote( + Vote(game_id=g.id, day=1, round=0, voter_seat=voter, + target_seat=target, submitted_at=1) + ) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + alive_seat_nos=[1, 2, 3], created_at_ms=1, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"raqio": [], "setsu": [], "gina": []} + for npc_id, persona, seat_no in ( + ("npc_raqio", "raqio", 1), + ("npc_setsu", "setsu", 2), + ("npc_gina", "gina", 3), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", + supported_voices=(), version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + intros: list[int] = [] # captured runoff_announce calls + + async def _intro(seat: Seat) -> None: + intros.append(seat.seat_no) + + wakes: list[str] = [] + + def _wake(game_id: str) -> None: + wakes.append(game_id) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 2000, + runoff_announce=_intro, + runoff_wake=_wake, + ) + + # First dispatch: seat 1 (lowest tied seat-no). + await arb.try_dispatch_next(g.id) + assert intros == [1], "Master must voice the candidate intro before TTS" + assert bufs["raqio"], "席1 ラキオ must receive the SpeakRequest" + assert not bufs["setsu"] + assert not bufs["gina"], "席3 ジナ is not a tied candidate — never picked" + + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "1" + assert reason == "runoff_candidate" + + # NPC accepts → engine wakes, runoff_speech_done flips. + req_msg = next(m for m in bufs["raqio"] if '"speak_request"' in m) + import json as _json + req_payload = _json.loads(req_msg) + request_id = req_payload["request_id"] + result = SpeakResult( + ts=2100, trace_id="t", request_id=request_id, + npc_id="npc_raqio", phase_id=phase_id, + status="accepted", + text="私は皆さんの推理に矛盾を感じています。投票先を見直してほしいです。", + ) + ok, _ = await arb.handle_speak_result( + result, current_phase_id=phase_id, day=1, phase=Phase.DAY_RUNOFF_SPEECH, + ) + assert ok + progress_seat1 = await repo.load_llm_speech_progress(g.id, 1, 1) + assert progress_seat1[4] is True, "runoff_speech_done must flip on accept" + assert g.id in wakes, "engine must wake so the phase can advance" + + # Simulate playback completion so the serial-speech gate releases — + # the live wiring kicks try_dispatch_next on playback_finished. + await arb.handle_playback_finished( + PlaybackFinished( + ts=2200, trace_id="t", request_id=request_id, + npc_id="npc_raqio", + started_at_ms=2150, finished_at_ms=2200, + ) + ) + + # Second dispatch: seat 2 (next tied seat-no). + bufs["raqio"].clear() + bufs["setsu"].clear() + bufs["gina"].clear() + intros.clear() + await arb.try_dispatch_next(g.id) + assert intros == [2] + assert bufs["setsu"], "席2 セツ must receive the SpeakRequest second" + assert not bufs["raqio"], "席1 already done — must not be re-picked" + assert not bufs["gina"] + + +async def test_runoff_dispatch_marks_done_on_offline_npc( + repo: SqliteRepo, +) -> None: + """A tied candidate whose NPC bot is offline must be marked done so + the phase advances rather than stalling on a permanently-silent seat. + """ + from wolfbot.domain.models import Vote + + g = Game( + id="rv-runoff-offline", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_RUNOFF_SPEECH, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + for seat in ( + Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, + is_llm=True, persona_key="raqio"), + Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, + is_llm=True, persona_key="setsu"), + Seat(seat_no=3, display_name="🟣ジナ", discord_user_id=None, + is_llm=True, persona_key="gina"), + ): + await repo.insert_seat(g.id, seat) + for sn, role in ((1, Role.WEREWOLF), (2, Role.SEER), (3, Role.MEDIUM)): + await repo.set_player_role(g.id, sn, role) + # Tie: seats 1 and 2 (voter 3 abstains). + for voter, target in ((1, 2), (2, 1), (3, None)): + await repo.insert_vote( + Vote(game_id=g.id, day=1, round=0, voter_seat=voter, + target_seat=target, submitted_at=1) + ) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + alive_seat_nos=[1, 2, 3], created_at_ms=1, + ) + ) + + # ONLY seat 2's NPC bot is online. Seat 1 (also tied) has no NPC. + registry = InMemoryNpcRegistry() + buf2: list[str] = [] + registry.register( + npc_id="npc_setsu", discord_bot_user_id="bot_setsu", + supported_voices=(), version="1", + send=_captured_send(buf2), + now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_setsu", seat=2, game_id=g.id, phase_id=phase_id) + + intros: list[int] = [] + + async def _intro(seat: Seat) -> None: + intros.append(seat.seat_no) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 2000, + runoff_announce=_intro, + ) + + await arb.try_dispatch_next(g.id) + + # Seat 1 had no online NPC → marked done. Seat 2 is online → got + # the SpeakRequest with intro voiced. + progress_seat1 = await repo.load_llm_speech_progress(g.id, 1, 1) + assert progress_seat1[4] is True, "offline candidate must be marked done" + assert intros == [2] + assert buf2, "online tied candidate must receive the SpeakRequest" + + def test_logic_packet_builder_includes_co_claims_in_summary() -> None: state = PublicDiscussionState( game_id="g", From eb03710a4a4f61a886d99967ae96e95552690f8a Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 14:14:37 +0900 Subject: [PATCH 069/133] feat(reactive_voice): multi-address list + randomised tiebreak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NPCs now emit `addressed_seat_nos` (list) instead of `addressed_seat_no` (single int) so 「セツとジナはどう?」-style multi-addressing actually captures everyone the speaker named. The singular field stays accepted for back-compat. PublicDiscussionState carries `last_addressed_seats` (frozenset) and the arbiter prioritises every seat in it; an addressee who replies consumes only their own slot, leaving co-addressees still prioritised for the next dispatch. Per the user report: 席順固定で席8 SQ・席9 ユリコ がほぼ喋れていない。 The arbiter's seat-number tiebreak is replaced with a per-call random roll (seedable via the new `rng` constructor arg for tests). All other priority axes — demoted, addressed, speech_count, LRU — still apply deterministically; only the very last fallback is random, so equal- priority seats now share air time rather than lowest-seat winning every tie. - domain/discussion: SpeechEvent gets `addressed_seat_nos: tuple[int]`. PublicDiscussionState gets `last_addressed_seats: frozenset[int]`. New helper `event_addressed_seats(event)` collapses both fields into the canonical list so callers don't have to special-case. - ws_messages: SpeakResult gets `addressed_seat_nos`. NPC bot prompt & schema teach the model to emit the list. - speak_arbiter: pick_key reads the set and uses `random.random()` as the final tiebreak; selection_reason classification flips "addressed" off the singular check. - persistence: new `addressed_seat_nos_json` column on speech_events (idempotent additive migration). Reader synthesises the list from the legacy singular column for pre-migration rows. - logic_service: summary line widens to `last_address=[席2,席3]` for the multi case, keeps the legacy `last_address=席N` for single. - tests: cover multi-address fold, address consumption, singular-only back-compat, randomised tiebreak (with seeded RNG). --- src/wolfbot/domain/discussion.py | 40 ++++- src/wolfbot/domain/ws_messages.py | 22 ++- src/wolfbot/master/logic_service.py | 18 ++- src/wolfbot/master/speak_arbiter.py | 104 +++++++++---- .../npc/openai_compatible_generator.py | 60 ++++--- src/wolfbot/npc/speech_service.py | 20 ++- src/wolfbot/persistence/schema.py | 5 + src/wolfbot/services/discussion_service.py | 147 +++++++++++++----- tests/test_public_discussion_state.py | 61 ++++++++ tests/test_reactive_voice_master.py | 114 +++++++++++++- 10 files changed, 482 insertions(+), 109 deletions(-) diff --git a/src/wolfbot/domain/discussion.py b/src/wolfbot/domain/discussion.py index fce8a50..7c660c4 100644 --- a/src/wolfbot/domain/discussion.py +++ b/src/wolfbot/domain/discussion.py @@ -92,11 +92,24 @@ class SpeechEvent(BaseModel): ge=1, le=9, description=( - "Seat number this utterance is addressed to " - "('〜さん、どう思う' style direct address). " - "Resolved on Master from the analyzer's `addressed_name` against the " - "current seats table. SpeakArbiter prefers this NPC when picking " - "the next speaker." + "Legacy single-addressee field. When ``addressed_seat_nos`` is " + "set this mirrors its first element so older readers (DB row " + "writers, restart recovery for pre-multi-address events) keep " + "working. Prefer ``addressed_seat_nos`` for new code." + ), + ) + addressed_seat_nos: tuple[int, ...] = Field( + default_factory=tuple, + description=( + "All seats this utterance is addressed to. SpeakArbiter " + "prioritises every seat in the set on the next dispatch and " + "consumes them one-by-one as each addressee replies. Empty " + "for general remarks. The legacy `addressed_seat_no` field " + "carries the first element for back-compat with code that " + "only knows the singular form. Use " + ":func:`event_addressed_seats` rather than reading either " + "field directly so the singular-only fallback is applied " + "consistently." ), ) role_callout: str | None = Field( @@ -117,6 +130,22 @@ def is_baseline(self) -> bool: return self.source == SpeechSource.PHASE_BASELINE +def event_addressed_seats(event: SpeechEvent) -> tuple[int, ...]: + """Return the canonical ordered list of addressees for a SpeechEvent. + + Single source of truth: prefer ``addressed_seat_nos`` (the new + multi-addressee field) and fall back to wrapping the legacy + ``addressed_seat_no`` in a 1-tuple so test fixtures and older + callers that only set the singular field still feed the fold + correctly. Returns ``()`` when neither is set. + """ + if event.addressed_seat_nos: + return event.addressed_seat_nos + if event.addressed_seat_no is not None: + return (event.addressed_seat_no,) + return () + + def make_phase_id(game_id: str, day: int, phase: Phase, sequence: int = 1) -> str: """Canonical `phase_id` format used across the master / voice-ingest / NPC bots. @@ -163,6 +192,7 @@ class PublicDiscussionState(BaseModel): silent_seats: frozenset[int] = frozenset() recent_speech_event_ids: tuple[str, ...] = () last_addressed_seat: int | None = None + last_addressed_seats: frozenset[int] = frozenset() last_addressed_speaker_seat: int | None = None last_addressed_text: str = "" # Most recent non-sentinel speech_event speaker. SpeakArbiter uses this diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index db460b4..0180f27 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -250,13 +250,21 @@ class SpeakResult(BaseEnvelope): addressed_seat_no: int | None = Field( default=None, description=( - "Seat number this utterance is directed at, mirroring the " - "field humans get via voice STT analysis. Master persists it " - "on SpeechEvent so the arbiter can prioritize the addressed " - "seat on the next dispatch (same code path as human address " - "routing). None for general remarks not aimed at a specific " - "seat. Self-address (==speaker_seat) is silently dropped on " - "the Master side." + "Legacy single-addressee field. When ``addressed_seat_nos`` " + "is non-empty Master prefers the list and ignores this. " + "Older NPC bot builds that don't know the list field still " + "work via this fallback. None / unset for general remarks." + ), + ) + addressed_seat_nos: tuple[int, ...] = Field( + default_factory=tuple, + description=( + "All seats this utterance is directed at — e.g. asking " + "「セツとジナはどう思う?」 produces ``(2, 3)``. Master " + "persists every entry on SpeechEvent so the arbiter " + "prioritises every named NPC and consumes them one-by-one " + "as each replies. Self-address (==speaker_seat) and " + "non-alive seats are dropped on the Master boundary." ), ) diff --git a/src/wolfbot/master/logic_service.py b/src/wolfbot/master/logic_service.py index 1530622..232d9dc 100644 --- a/src/wolfbot/master/logic_service.py +++ b/src/wolfbot/master/logic_service.py @@ -75,7 +75,13 @@ def build_logic_packet( # CO trigger; wolf-side NPCs should consider whether to fake CO. callouts_repr = ", ".join(sorted(state.pending_role_callouts)) summary += f" pending_role_callouts=[{callouts_repr}]" - if state.last_addressed_seat is not None: + # Prefer the multi-addressee set; fall back to the legacy singular + # field for state objects that haven't been migrated (e.g. test + # fixtures that only set `last_addressed_seat`). + addressed_seats: frozenset[int] = state.last_addressed_seats + if not addressed_seats and state.last_addressed_seat is not None: + addressed_seats = frozenset({state.last_addressed_seat}) + if addressed_seats: speaker_repr = ( f"席{state.last_addressed_speaker_seat}" if state.last_addressed_speaker_seat is not None @@ -86,8 +92,16 @@ def build_logic_packet( utter = state.last_addressed_text.strip().replace("\n", " ") if len(utter) > 160: utter = utter[:160] + "…" + sorted_seats = sorted(addressed_seats) + if len(sorted_seats) == 1: + # Singular case keeps the legacy ``last_address=席N`` shape + # so existing log scrapers / NPC-side parsers don't see a + # surprise format change. + addr_repr = f"席{sorted_seats[0]}" + else: + addr_repr = "[" + ",".join(f"席{s}" for s in sorted_seats) + "]" summary += ( - f" last_address=席{state.last_addressed_seat}" + f" last_address={addr_repr}" f" from={speaker_repr} text=\"{utter}\"" ) diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index d395413..c6f89ea 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -26,6 +26,7 @@ import asyncio import logging +import random import uuid from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass @@ -174,12 +175,16 @@ def __init__( now_ms: Callable[[], int] = default_now_ms, runoff_announce: Callable[[Seat], Awaitable[None]] | None = None, runoff_wake: Callable[[str], None] | None = None, + rng: random.Random | None = None, ) -> None: self.repo = repo self.registry = registry self.discussion = discussion self.config = config or SpeakArbiterConfig() self._now_ms = now_ms + # Seedable RNG for the picker tiebreak. Production uses an + # un-seeded `random.Random()`; tests inject a deterministic one. + self._rng = rng if rng is not None else random.Random() # Optional Master-narration hook fired right before dispatching a # tied LLM candidate's SpeakRequest in DAY_RUNOFF_SPEECH so Levi # can name the speaker ("続いて、X 様の最終演説でございます。"). @@ -622,32 +627,53 @@ async def _record_rejection(reason: str) -> None: failure_reason=None, received_at_ms=now, ) - # Pull `addressed_seat_no` from the NPC's structured output so the - # next dispatch can prioritize the named seat the same way human - # voice does (via the analyzer's addressed_name → seat resolve). - # Validate alive + non-self at the boundary so a hallucinated or - # out-of-roster seat number can't poison the address routing. - addressed_seat_no = result.addressed_seat_no - if addressed_seat_no is not None: - if addressed_seat_no == pending.seat_no: - addressed_seat_no = None - else: - try: - alive_seats = await self.repo.load_players(pending.game_id) - alive_set = {p.seat_no for p in alive_seats if p.alive} - except Exception: - log.exception( - "addressed_seat_alive_check_failed game=%s", - pending.game_id, - ) - alive_set = set() - if addressed_seat_no not in alive_set: + # Resolve the NPC's addressed list: take every seat it named, + # union with the legacy singular field (for older NPC builds), + # then drop self-address and any non-alive seats so a + # hallucinated id can't poison the routing. Order is preserved + # so the eventual frontmost addressee in the list (e.g. + # ``[3, 4]``) keeps a deterministic ordering — the arbiter's + # tiebreak randomises within the addressed group anyway. + addressed_candidates: list[int] = [] + seen: set[int] = set() + for seat in result.addressed_seat_nos: + if seat is None or seat in seen: + continue + seen.add(seat) + addressed_candidates.append(int(seat)) + if ( + result.addressed_seat_no is not None + and result.addressed_seat_no not in seen + ): + seen.add(result.addressed_seat_no) + addressed_candidates.append(int(result.addressed_seat_no)) + # Drop self-address. + addressed_candidates = [ + s for s in addressed_candidates if s != pending.seat_no + ] + if addressed_candidates: + try: + alive_seats = await self.repo.load_players(pending.game_id) + alive_set = {p.seat_no for p in alive_seats if p.alive} + except Exception: + log.exception( + "addressed_seat_alive_check_failed game=%s", + pending.game_id, + ) + alive_set = set() + filtered: list[int] = [] + for s in addressed_candidates: + if s in alive_set: + filtered.append(s) + else: log.info( "npc_addressed_seat_unknown game=%s seat=%d " "addressed=%s — dropped", - pending.game_id, pending.seat_no, addressed_seat_no, + pending.game_id, pending.seat_no, s, ) - addressed_seat_no = None + addressed_candidates = filtered + addressed_seat_nos: tuple[int, ...] = tuple(addressed_candidates) + addressed_seat_no = addressed_seat_nos[0] if addressed_seat_nos else None speech_event = SpeechEvent( event_id=new_event_id(), game_id=pending.game_id, @@ -660,6 +686,7 @@ async def _record_rejection(reason: str) -> None: text=result.text, co_declaration=result.co_declaration, addressed_seat_no=addressed_seat_no, + addressed_seat_nos=addressed_seat_nos, created_at_ms=now, ) await self.discussion.record(speech_event) @@ -809,7 +836,11 @@ async def try_dispatch_next(self, game_id: str) -> None: # in a low-info pair volley OR exceeding the consecutive # speaker cap. Demoted seats fall to the bottom regardless # of being addressed, so a 3rd NPC can break in. - # 2. addressed seat — recent utterance's `addressed_seat_no`. + # 2. addressed — seat appears in the multi-addressee + # ``last_addressed_seats`` set (e.g. 「セツとジナ、どう?」 puts + # both 2 and 3 in the set). Both win over non-addressed + # seats; randomization on the last axis decides which of the + # two goes first. # 3. lowest speech_count this phase — generalises the old # binary silent_seats: a 0-count seat is still preferred, # but a 1-count seat now also wins over a 5-count one. Stops @@ -817,23 +848,25 @@ async def try_dispatch_next(self, game_id: str) -> None: # once and gives wolf-side seats at higher seat numbers a # fair chance to fake-CO. # 4. NOT the immediate previous speaker (LRU rotation). - # 5. lowest assigned_seat as a stable tiebreaker. - addressed = state.last_addressed_seat + # 5. random — replaces the old seat-number tiebreak. Without + # randomization the lowest-seat NPC won every tie, so + # higher-seat NPCs (e.g. 席8 SQ, 席9 ユリコ) effectively + # never spoke. Each call gets a fresh roll so the rotation + # is fair across phases. + addressed_set = state.last_addressed_seats last_speaker = state.last_speaker_seat demoted = _compute_demoted_seats(state.recent_speech_summary) online = self.registry.all_online() - def _pick_key(e: object) -> tuple[int, int, int, int, int]: + def _pick_key(e: object) -> tuple[int, int, int, int, float]: seat = getattr(e, "assigned_seat", None) or 99 is_demoted = 1 if seat in demoted else 0 - is_addressed = 0 if ( - addressed is not None and seat == addressed - ) else 1 + is_addressed = 0 if seat in addressed_set else 1 count = state.speech_counts.get(seat, 0) is_just_spoke = 1 if ( last_speaker is not None and seat == last_speaker ) else 0 - return (is_demoted, is_addressed, count, is_just_spoke, seat) + return (is_demoted, is_addressed, count, is_just_spoke, self._rng.random()) online_npc_seats = sorted( e.assigned_seat @@ -854,7 +887,10 @@ def _pick_key(e: object) -> tuple[int, int, int, int, int]: "phase_id": state.phase_id, "day": state.day, "phase": game.phase.value, - "last_addressed_seat": addressed, + "last_addressed_seat": ( + next(iter(sorted(addressed_set))) if addressed_set else None + ), + "last_addressed_seats": sorted(addressed_set), "last_speaker_seat": last_speaker, "silent_seats": sorted(state.silent_seats), "alive_seat_nos": sorted(state.alive_seat_nos), @@ -875,7 +911,7 @@ def _pick_key(e: object) -> tuple[int, int, int, int, int]: # candidate was filtered out (offline / dead / not in # this game). Falling back is preferable to silence. reason = "all_demoted_fallback" - elif addressed is not None and seat == addressed: + elif seat in addressed_set: reason = "addressed" elif seat in state.silent_seats: reason = "silent_rotation" @@ -895,6 +931,10 @@ def _pick_key(e: object) -> tuple[int, int, int, int, int]: elif last_speaker is not None and seat != last_speaker: reason = "lru_rotation" else: + # Seat-number tiebreak was replaced with random in the + # `_pick_key` so all NPCs at equal priority get a fair + # roll. Keep the legacy reason label so the viewer's + # historical badge wording still matches. reason = "seat_tiebreak" await self.dispatch_request( state=state, diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index 7534474..700324b 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -61,7 +61,7 @@ "intent", "used_logic_ids", "co_declaration", - "addressed_seat_no", + "addressed_seat_nos", ], "properties": { "text": {"type": "string", "maxLength": 300}, @@ -77,14 +77,17 @@ "type": ["string", "null"], "enum": [*CO_CLAIM_VALUES, None], }, - "addressed_seat_no": { - "type": ["integer", "null"], + "addressed_seat_nos": { + "type": "array", + "items": {"type": "integer", "minimum": 1, "maximum": 9}, "description": ( - "Seat number this utterance is directed at. Mirror the " - "field human voice analysis emits so Master can route " - "the next dispatch via the same arbiter priority " - "(addressed > silent_rotation > lru_rotation > seat). " - "Use null for general remarks aimed at the whole table." + "Seat numbers this utterance is directed at. " + "Empty array `[]` for general remarks aimed at the whole " + "table. Single addressee → 1-element array (e.g. `[3]`); " + "asking multiple people in one breath → multi-element " + "(e.g. `[2, 3]` for 「セツとジナはどう?」). Master " + "prioritises every named seat in the next dispatch and " + "consumes them as each replies." ), }, }, @@ -144,7 +147,7 @@ def _build_system( "「もう 1 人組んでそうな人」「あと処刑できる回数を考えると…」 のように。\n" "- **`text` 内で席番号 (席1, 席2, ..., 席9 や Seat3 等) を絶対に書かない。**" "他のプレイヤーを呼ぶときは必ず生存者リストの display_name (キャラ名) を使う。" - "data 層 (`addressed_seat_no` 等) には正しい席番号を入れて構わないが、" + "data 層 (`addressed_seat_nos` 等) には正しい席番号を入れて構わないが、" "発話そのものは「ジナさん」「ラキオ」のような自然な呼び方にする。\n" " 禁止例: 「席3はどう思う?」「席4のラキオが…」「Seat 9、答えて」\n" " 推奨例: 「ジョナスさんはどう思う?」「ラキオが…」「ユリコ、答えて」\n" @@ -153,10 +156,13 @@ def _build_system( "`text` は「実は私、占い師なんだ」など自然な名乗りにする。" "CO しないなら `co_declaration=null`。" "「占いCO」のような語そのものは `text` に書かない。\n" - "- 特定の席に向けて話す場合は `addressed_seat_no` にその席番号 (整数) を入れる。" - "誰宛でもない一般的な発言や全体への呼びかけは `null`。" + "- 特定の席に向けて話す場合は `addressed_seat_nos` にその席番号の配列を入れる。" + "1人だけなら `[3]`、複数人に同時に問いかけるなら `[2, 3]` (例「セツとジナはどう?」)。" + "誰宛でもない一般的な発言や全体への呼びかけは空配列 `[]`。" "自分の席を指定しても無効化されるので、相手の席を必ず入れること。" - "`text` 中で名前を呼んだ場合はその人の席番号を `addressed_seat_no` に設定する。\n" + "`text` 中で名前を呼んだ全員ぶんを `addressed_seat_nos` に列挙する。" + "Master は配列の全員を次に発話する優先候補として扱うので、" + "問いかけた人数に応じて漏れなく入れる。\n" "- 死亡者リストには (処刑) または (襲撃) の死因タグが付く。" "前日の処刑死を「昨夜の犠牲者」と混同しない。逆も同様。" "発言で死を語るときはタグに合わせた表現を使う" @@ -363,11 +369,12 @@ def _format_candidate(c: LogicCandidate) -> str: - "intent": "speak" | "agree" | "disagree" | "question" | "accuse" | "defend" | "skip" - "used_logic_ids": string の配列 (空配列でもよい) - "co_declaration": "seer" | "medium" | "knight" | null -- "addressed_seat_no": integer | null (特定の席に向けて話すときその席番号、一般発言は null) +- "addressed_seat_nos": integer の配列 (向ける席番号たち。1人なら [3]、複数なら [2, 3]、誰宛でもない一般発言は []) 例: -{"text": "私もそこは引っかかってた。", "intent": "agree", "used_logic_ids": [], "co_declaration": null, "addressed_seat_no": null} -{"text": "ジョナスさん、それは矛盾してるよ。", "intent": "accuse", "used_logic_ids": [], "co_declaration": null, "addressed_seat_no": 3} +{"text": "私もそこは引っかかってた。", "intent": "agree", "used_logic_ids": [], "co_declaration": null, "addressed_seat_nos": []} +{"text": "ジョナスさん、それは矛盾してるよ。", "intent": "accuse", "used_logic_ids": [], "co_declaration": null, "addressed_seat_nos": [3]} +{"text": "セツとジナ、ラキオの主張をどう見る?", "intent": "question", "used_logic_ids": [], "co_declaration": null, "addressed_seat_nos": [2, 3]} """ @@ -673,13 +680,29 @@ def _build_speech_from_json(data: dict[str, object]) -> NpcGeneratedSpeech | Non ) co_raw = data.get("co_declaration") co_declaration = co_raw if co_raw in CO_CLAIM_VALUES else None - # `addressed_seat_no` is optional on older provider responses (the - # field was added in 2026-04 to mirror human voice analysis); coerce - # to int|None and silently drop garbage rather than fail the speech. + # `addressed_seat_nos` (list) is the authoritative field; the legacy + # `addressed_seat_no` (singular) stays accepted for back-compat with + # provider responses produced before 2026-04 multi-address rollout. + # Coerce to int and silently drop non-int garbage rather than fail + # the speech. + raw_nos = data.get("addressed_seat_nos") + addressed_seat_nos: list[int] = [] + if isinstance(raw_nos, list): + for v in raw_nos: + if ( + isinstance(v, int) + and not isinstance(v, bool) + and v not in addressed_seat_nos + ): + addressed_seat_nos.append(v) raw_addr = data.get("addressed_seat_no") addressed_seat_no: int | None = None if isinstance(raw_addr, int) and not isinstance(raw_addr, bool): addressed_seat_no = raw_addr + if not addressed_seat_nos: + addressed_seat_nos.append(raw_addr) + elif addressed_seat_nos: + addressed_seat_no = addressed_seat_nos[0] # Rough estimate: ~150ms per character for TTS estimated_ms = max(500, len(text) * 150) @@ -690,6 +713,7 @@ def _build_speech_from_json(data: dict[str, object]) -> NpcGeneratedSpeech | Non estimated_duration_ms=estimated_ms, co_declaration=co_declaration, addressed_seat_no=addressed_seat_no, + addressed_seat_nos=tuple(addressed_seat_nos), ) diff --git a/src/wolfbot/npc/speech_service.py b/src/wolfbot/npc/speech_service.py index 4a74b94..d4bb814 100644 --- a/src/wolfbot/npc/speech_service.py +++ b/src/wolfbot/npc/speech_service.py @@ -29,6 +29,7 @@ class NpcGeneratedSpeech: estimated_duration_ms: int co_declaration: str | None = None addressed_seat_no: int | None = None + addressed_seat_nos: tuple[int, ...] = () @runtime_checkable @@ -128,9 +129,21 @@ async def respond( # alive / on-roster here without seat data; Master applies the # alive/self-filter when persisting, so a hallucinated seat number # is filtered out at the boundary, not silently dropped here. - addressed_seat_no = speech.addressed_seat_no - if addressed_seat_no is not None and addressed_seat_no == request.seat_no: - addressed_seat_no = None + # Build the canonical addressed list, falling back to the legacy + # singular field for back-compat with NpcGeneratedSpeech instances + # that haven't been updated to populate the list. + merged: list[int] = [] + for s in speech.addressed_seat_nos: + if s is not None and s != request.seat_no and s not in merged: + merged.append(int(s)) + if ( + speech.addressed_seat_no is not None + and speech.addressed_seat_no != request.seat_no + and speech.addressed_seat_no not in merged + ): + merged.append(int(speech.addressed_seat_no)) + addressed_seat_nos: tuple[int, ...] = tuple(merged) + addressed_seat_no = addressed_seat_nos[0] if addressed_seat_nos else None return SpeakResult( ts=now_ms, trace_id=request.trace_id, @@ -144,6 +157,7 @@ async def respond( estimated_duration_ms=speech.estimated_duration_ms, co_declaration=co_declaration, addressed_seat_no=addressed_seat_no, + addressed_seat_nos=addressed_seat_nos, ) diff --git a/src/wolfbot/persistence/schema.py b/src/wolfbot/persistence/schema.py index 1762244..cf6cc66 100644 --- a/src/wolfbot/persistence/schema.py +++ b/src/wolfbot/persistence/schema.py @@ -169,6 +169,7 @@ summary TEXT, co_declaration TEXT, addressed_seat_no INTEGER, + addressed_seat_nos_json TEXT, role_callout TEXT, created_at_ms INTEGER NOT NULL ) @@ -303,6 +304,10 @@ async def migrate(db_path: str | Path) -> None: await db.execute( "ALTER TABLE speech_events ADD COLUMN addressed_seat_no INTEGER" ) + if "addressed_seat_nos_json" not in cols: + await db.execute( + "ALTER TABLE speech_events ADD COLUMN addressed_seat_nos_json TEXT" + ) if "role_callout" not in cols: await db.execute( "ALTER TABLE speech_events ADD COLUMN role_callout TEXT" diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py index 96943bb..d727946 100644 --- a/src/wolfbot/services/discussion_service.py +++ b/src/wolfbot/services/discussion_service.py @@ -41,6 +41,7 @@ SpeakerKind, SpeechEvent, SpeechSource, + event_addressed_seats, make_phase_id, ) from wolfbot.domain.enums import CO_CLAIM_VALUES, Phase @@ -89,14 +90,17 @@ def __init__(self, conn: aiosqlite.Connection) -> None: self._conn = conn async def insert(self, event: SpeechEvent) -> None: + nos_json: str | None = None + if event.addressed_seat_nos: + nos_json = json.dumps(list(event.addressed_seat_nos)) await self._conn.execute( """ INSERT INTO speech_events ( event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, alive_seat_nos_json, summary, co_declaration, addressed_seat_no, - role_callout, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + addressed_seat_nos_json, role_callout, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( event.event_id, @@ -115,6 +119,7 @@ async def insert(self, event: SpeechEvent) -> None: event.summary, event.co_declaration, event.addressed_seat_no, + nos_json, event.role_callout, event.created_at_ms, ), @@ -127,7 +132,7 @@ async def load_phase(self, game_id: str, phase_id: str) -> Sequence[SpeechEvent] SELECT event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, alive_seat_nos_json, summary, co_declaration, addressed_seat_no, - role_callout, created_at_ms + addressed_seat_nos_json, role_callout, created_at_ms FROM speech_events WHERE game_id=? AND phase_id=? ORDER BY created_at_ms ASC, event_id ASC @@ -143,7 +148,7 @@ async def load_for_game(self, game_id: str) -> Sequence[SpeechEvent]: SELECT event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, alive_seat_nos_json, summary, co_declaration, addressed_seat_no, - role_callout, created_at_ms + addressed_seat_nos_json, role_callout, created_at_ms FROM speech_events WHERE game_id=? ORDER BY created_at_ms ASC, event_id ASC @@ -155,6 +160,20 @@ async def load_for_game(self, game_id: str) -> Sequence[SpeechEvent]: def _row_to_event(row: Any) -> SpeechEvent: + nos_json = row[16] + addressed_nos: tuple[int, ...] = () + if nos_json: + try: + parsed = json.loads(nos_json) + if isinstance(parsed, list): + addressed_nos = tuple(int(s) for s in parsed if s is not None) + except (json.JSONDecodeError, ValueError, TypeError): + addressed_nos = () + # Legacy events written before addressed_seat_nos_json existed: synth + # the tuple from the singular column so the fold sees both forms + # consistently. + if not addressed_nos and row[15] is not None: + addressed_nos = (int(row[15]),) return SpeechEvent( event_id=row[0], game_id=row[1], @@ -172,8 +191,9 @@ def _row_to_event(row: Any) -> SpeechEvent: summary=row[13], co_declaration=row[14], addressed_seat_no=row[15], - role_callout=row[16], - created_at_ms=row[17], + addressed_seat_nos=addressed_nos, + role_callout=row[17], + created_at_ms=row[18], ) @@ -223,6 +243,31 @@ def make_phase_baseline( ) +def _normalize_addressed( + addressed_seat_no: int | None, + addressed_seat_nos: tuple[int, ...] | None, +) -> tuple[int | None, tuple[int, ...]]: + """Coerce the singular + list form into a consistent pair. + + Returns ``(seat_no, seat_nos)`` where ``seat_nos`` is the canonical + list and ``seat_no`` mirrors its first element. Either input may be + None / empty; if both are set, the list wins and ``seat_no`` is + ignored. Removes duplicates while preserving order. + """ + nos: list[int] = [] + if addressed_seat_nos: + for s in addressed_seat_nos: + if s is None: + continue + if s not in nos: + nos.append(int(s)) + if not nos and addressed_seat_no is not None: + nos.append(int(addressed_seat_no)) + if not nos: + return (None, ()) + return (nos[0], tuple(nos)) + + def make_human_text_event( *, game_id: str, @@ -233,9 +278,11 @@ def make_human_text_event( text: str, co_declaration: str | None = None, addressed_seat_no: int | None = None, + addressed_seat_nos: tuple[int, ...] | None = None, role_callout: str | None = None, created_at_ms: int | None = None, ) -> SpeechEvent: + seat_no, seat_nos = _normalize_addressed(addressed_seat_no, addressed_seat_nos) return SpeechEvent( event_id=new_event_id(), game_id=game_id, @@ -247,7 +294,8 @@ def make_human_text_event( speaker_seat=speaker_seat, text=text, co_declaration=co_declaration, - addressed_seat_no=addressed_seat_no, + addressed_seat_no=seat_no, + addressed_seat_nos=seat_nos, role_callout=role_callout, created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), ) @@ -263,9 +311,11 @@ def make_npc_generated_event( text: str, co_declaration: str | None = None, addressed_seat_no: int | None = None, + addressed_seat_nos: tuple[int, ...] | None = None, role_callout: str | None = None, created_at_ms: int | None = None, ) -> SpeechEvent: + seat_no, seat_nos = _normalize_addressed(addressed_seat_no, addressed_seat_nos) return SpeechEvent( event_id=new_event_id(), game_id=game_id, @@ -277,7 +327,8 @@ def make_npc_generated_event( speaker_seat=speaker_seat, text=text, co_declaration=co_declaration, - addressed_seat_no=addressed_seat_no, + addressed_seat_no=seat_no, + addressed_seat_nos=seat_nos, role_callout=role_callout, created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), ) @@ -296,8 +347,10 @@ def make_voice_stt_event( audio_end_ms: int, co_declaration: str | None = None, addressed_seat_no: int | None = None, + addressed_seat_nos: tuple[int, ...] | None = None, created_at_ms: int | None = None, ) -> SpeechEvent: + seat_no, seat_nos = _normalize_addressed(addressed_seat_no, addressed_seat_nos) return SpeechEvent( event_id=new_event_id(), game_id=game_id, @@ -312,7 +365,8 @@ def make_voice_stt_event( audio_start_ms=audio_start_ms, audio_end_ms=audio_end_ms, co_declaration=co_declaration, - addressed_seat_no=addressed_seat_no, + addressed_seat_no=seat_no, + addressed_seat_nos=seat_nos, created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), ) @@ -526,33 +580,41 @@ def apply_speech_event( recent = [*state.recent_speech_event_ids, event.event_id][-10:] - # Address routing rules. The arbiter consumes `last_addressed_seat` - # to prioritize whoever was just called out, so we have to be careful - # about *when* it's cleared: + # Address routing rules. The arbiter consumes ``last_addressed_seats`` + # (multi-seat) to prioritize everyone who was just called out, so we + # have to be careful about *when* members are cleared: # - # - Human or NPC speech with its own `addressed_seat_no` always - # supersedes the prior address (= a fresh call-out wins). - # - An NPC speaks but the prior addressee is themselves (= the - # addressed seat replied) → consume the address. - # - Anything else keeps the standing address. Without this, e.g. a - # silent_rotation pick that "jumps the line" before the addressed - # NPC replies would silently clear the hint and the addressee - # never gets prioritized. - last_addressed_seat = state.last_addressed_seat + # - Human or NPC speech with its own ``addressed_seat_nos`` always + # supersedes the prior addressing (= a fresh call-out wins, the + # old set is dropped wholesale). + # - An NPC who is themselves in the prior set speaks (= one of the + # addressees replied) → remove only that NPC from the set; the + # others stay prioritized so they all get a chance to answer. + # - Anything else keeps the standing set. Without this, e.g. a + # silent_rotation pick that "jumps the line" before any addressed + # NPC replies would silently clear the hint and the addressees + # never get prioritized. + last_addressed_seats: frozenset[int] = state.last_addressed_seats last_addressed_speaker_seat = state.last_addressed_speaker_seat last_addressed_text = state.last_addressed_text - if event.addressed_seat_no is not None: - last_addressed_seat = event.addressed_seat_no + new_addressed = event_addressed_seats(event) + if new_addressed: + last_addressed_seats = frozenset(new_addressed) last_addressed_speaker_seat = speaker last_addressed_text = event.text elif ( event.source == SpeechSource.NPC_GENERATED and speaker is not None - and speaker == state.last_addressed_seat + and speaker in state.last_addressed_seats ): - last_addressed_seat = None - last_addressed_speaker_seat = None - last_addressed_text = "" + remaining = set(state.last_addressed_seats) - {speaker} + last_addressed_seats = frozenset(remaining) + if not remaining: + last_addressed_speaker_seat = None + last_addressed_text = "" + last_addressed_seat = ( + next(iter(sorted(last_addressed_seats))) if last_addressed_seats else None + ) last_speaker_seat = ( speaker if speaker is not None else state.last_speaker_seat @@ -606,6 +668,7 @@ def apply_speech_event( last_addressed_seat=last_addressed_seat, last_addressed_speaker_seat=last_addressed_speaker_seat, last_addressed_text=last_addressed_text, + last_addressed_seats=last_addressed_seats, last_speaker_seat=last_speaker_seat, recent_speech_summary=tuple(summary), pending_role_callouts=frozenset(pending_role_callouts), @@ -660,7 +723,7 @@ def rebuild_public_state_from_events( seen_co: set[tuple[int, str]] = set() pending_role_callouts: set[str] = set() speech_counts: dict[int, int] = {} - last_addressed_seat: int | None = None + last_addressed_seats: frozenset[int] = frozenset() last_addressed_speaker_seat: int | None = None last_addressed_text: str = "" last_speaker_seat: int | None = None @@ -674,22 +737,25 @@ def rebuild_public_state_from_events( speech_counts.get(event.speaker_seat, 0) + 1 ) recent_ids.append(event.event_id) - # Mirror the per-event update logic in `_apply_event_to_state`: - # only consume the standing address when the NPC speaker IS the - # previously addressed seat (= the addressee replied). A new - # `addressed_seat_no` always overrides the prior pointer. - if event.addressed_seat_no is not None: - last_addressed_seat = event.addressed_seat_no + # Mirror the per-event update logic in `apply_speech_event`: + # a fresh ``addressed_seat_nos`` (multi-seat) replaces the entire + # standing set; an NPC speaker who's already in the set consumes + # *only their own slot*, leaving co-addressees still prioritized. + new_addressed = event_addressed_seats(event) + if new_addressed: + last_addressed_seats = frozenset(new_addressed) last_addressed_speaker_seat = event.speaker_seat last_addressed_text = event.text elif ( event.source == SpeechSource.NPC_GENERATED and event.speaker_seat is not None - and event.speaker_seat == last_addressed_seat + and event.speaker_seat in last_addressed_seats ): - last_addressed_seat = None - last_addressed_speaker_seat = None - last_addressed_text = "" + remaining = set(last_addressed_seats) - {event.speaker_seat} + last_addressed_seats = frozenset(remaining) + if not remaining: + last_addressed_speaker_seat = None + last_addressed_text = "" if event.speaker_seat is None: continue # Track outstanding role-callouts (request → pending; matching @@ -724,7 +790,10 @@ def rebuild_public_state_from_events( state.co_claims = tuple(co_claims) state.silent_seats = frozenset(alive_seats - spoken_seats) state.recent_speech_event_ids = tuple(recent_ids[-10:]) - state.last_addressed_seat = last_addressed_seat + state.last_addressed_seats = last_addressed_seats + state.last_addressed_seat = ( + next(iter(sorted(last_addressed_seats))) if last_addressed_seats else None + ) state.last_addressed_speaker_seat = last_addressed_speaker_seat state.last_addressed_text = last_addressed_text state.last_speaker_seat = last_speaker_seat diff --git a/tests/test_public_discussion_state.py b/tests/test_public_discussion_state.py index a661683..9af0382 100644 --- a/tests/test_public_discussion_state.py +++ b/tests/test_public_discussion_state.py @@ -271,3 +271,64 @@ def test_speech_counts_excludes_baseline_sentinel() -> None: state = rebuild_public_state_from_events(events) assert state is not None assert state.speech_counts == {} + + +def test_multi_addressed_seats_populate_set_and_consume_per_responder() -> None: + """An NPC addressing multiple seats puts ALL of them in + ``last_addressed_seats``. When one of them replies, only that + addressee is removed; the others remain prioritised so the next + dispatch still favours an unanswered addressee. + """ + pid = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id="g1", phase_id=pid, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3, 4], created_at_ms=0, + ) + multi_addr = make_npc_generated_event( + game_id="g1", phase_id=pid, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="セツとジナはどう?", + addressed_seat_nos=(2, 3), + created_at_ms=10, + ) + state_after_addr = rebuild_public_state_from_events([sentinel, multi_addr]) + assert state_after_addr is not None + assert state_after_addr.last_addressed_seats == frozenset({2, 3}) + + # Setsu (seat 2) replies. Gina (seat 3) should still be in the set. + setsu_reply = make_npc_generated_event( + game_id="g1", phase_id=pid, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, text="うーん、少し迷うところがあります。", + created_at_ms=20, + ) + state_after_reply = rebuild_public_state_from_events( + [sentinel, multi_addr, setsu_reply] + ) + assert state_after_reply is not None + assert state_after_reply.last_addressed_seats == frozenset({3}) + + +def test_multi_address_back_compat_singular_field_promoted() -> None: + """A SpeechEvent that only sets the legacy ``addressed_seat_no`` (no + list field) must still appear in ``last_addressed_seats`` so older + fixtures and pre-multi-address persisted rows keep working. + """ + pid = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id="g1", phase_id=pid, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], created_at_ms=0, + ) + legacy_event = make_npc_generated_event( + game_id="g1", phase_id=pid, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="セツさん、どう?", + addressed_seat_no=2, # singular only + created_at_ms=10, + ) + state = rebuild_public_state_from_events([sentinel, legacy_event]) + assert state is not None + assert state.last_addressed_seats == frozenset({2}) + assert state.last_addressed_seat == 2 diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index d9a2157..94231e6 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -934,18 +934,26 @@ async def test_try_dispatch_next_avoids_immediate_repeat_after_first_round( ) registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + import random as _random + arb = SpeakArbiter( repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 2000, + rng=_random.Random(0), ) await arb.try_dispatch_next(g.id) assert not bufs["raqio"], ( "ラキオ was the immediate previous speaker — must NOT be re-picked" ) - # Seat 2 (Setsu) wins by lowest seat among non-last-speakers. - assert bufs["setsu"], "expected next pick to land on seat 2 Setsu" - assert not bufs["gina"] + # Equal-priority tiebreak is randomised — seat 2 OR seat 3 may win, + # but never seat 1 (the just-spoken seat). Seeded RNG keeps the + # specific winner deterministic for the test (currently seat 2). + assert bufs["setsu"] or bufs["gina"] + if bufs["setsu"]: + assert not bufs["gina"] + else: + assert not bufs["setsu"] async def test_try_dispatch_next_prefers_lower_speech_count( @@ -1485,6 +1493,106 @@ async def test_arbiter_cleanup_game_drops_only_target_game(repo: SqliteRepo) -> assert arb._playback_deadlines["req-g2-c"] == 5_000 +async def test_multi_addressed_seats_both_get_priority( + repo: SqliteRepo, +) -> None: + """When an utterance addresses multiple seats (e.g. + ``addressed_seat_nos=[2, 3]`` for 「セツとジナはどう?」), both seats + win the addressed-priority axis on the next dispatch. Picking 2 + consumes only its slot, leaving 3 still in the addressed set so the + follow-up dispatch picks 3 next over a non-addressed candidate. + """ + import random as _random + + g = Game( + id="rv-multi-addr", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, + is_llm=True, persona_key="raqio"), + Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, + is_llm=True, persona_key="setsu"), + Seat(seat_no=3, display_name="🟣ジナ", discord_user_id=None, + is_llm=True, persona_key="gina"), + Seat(seat_no=4, display_name="🎩ジョナス", discord_user_id=None, + is_llm=True, persona_key="jonas"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ( + (1, Role.WEREWOLF), (2, Role.SEER), + (3, Role.MEDIUM), (4, Role.VILLAGER), + ): + await repo.set_player_role(g.id, sn, role) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3, 4], created_at_ms=1, + ) + ) + # Raqio (seat 1) addresses BOTH Setsu (2) and Gina (3) in one breath. + from wolfbot.services.discussion_service import make_npc_generated_event + + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="セツとジナはどう思う?", + addressed_seat_nos=(2, 3), created_at_ms=10, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = { + "raqio": [], "setsu": [], "gina": [], "jonas": [], + } + for npc_id, persona, seat_no in ( + ("npc_raqio", "raqio", 1), + ("npc_setsu", "setsu", 2), + ("npc_gina", "gina", 3), + ("npc_jonas", "jonas", 4), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", + supported_voices=(), version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 2000, rng=_random.Random(0), + ) + await arb.try_dispatch_next(g.id) + + # Either seat 2 or seat 3 wins (random tiebreak), but NOT seat 4 + # (not addressed) and NOT seat 1 (just spoke). + first_winner: int | None = None + if bufs["setsu"] and not bufs["gina"]: + first_winner = 2 + elif bufs["gina"] and not bufs["setsu"]: + first_winner = 3 + assert first_winner in (2, 3) + assert not bufs["raqio"], "ラキオ just spoke — never picked" + assert not bufs["jonas"], "ジョナス wasn't addressed — must lose to addressed pair" + + async def test_runoff_dispatch_picks_tied_llm_candidates_in_order( repo: SqliteRepo, ) -> None: From e19797c653ca76f152739350a18da0e004528eb8 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 14:47:15 +0900 Subject: [PATCH 070/133] fix(reactive_voice): vote/night digest now carries discussion content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause from the latest game (e797504c9f5f): NPC vote decisions weren't aligning with the discussion. ラキオ said 「SQ、君は狼だ。今日処刑しろ」 in their day-3 speech and ユリコ called SQ black, but ラキオ then voted セツ (madman) instead. Why: the vote-time digest was empty. Mechanics: `_build_public_digest` looked up speech events by `make_phase_id(game.id, day, game.phase)`. At vote time `game.phase` is DAY_VOTE, but speeches are persisted under DAY_DISCUSSION phase ids, so the lookup returned zero rows and the digest collapsed to "(情報なし)". The vote LLM only saw CO claims like "席9 = seer", with no content — no SQ-黒 detail, no medium results, no past ballots — so the prompt's "場の状況" block told it nothing about what had just been said. - llm_service: `_build_public_digest` now sources from the day's DAY_DISCUSSION phase id explicitly (and appends DAY_RUNOFF_SPEECH events so a runoff vote sees the candidates' final speeches). New `_load_past_votes` mirrors SpeakArbiter's helper so completed-day ballots are also fed through. - public_digest: new "## 直近の発言 (古い順)" block (last 20 utterances with seat label + truncated text) and "## 公開された投票履歴" block (voter→target ledger). The last-addressed renderer now reads the multi-addressee set with a singular fallback — same back-compat path as logic_service. - tests: regression coverage for the recent-speech / past-votes blocks. --- src/wolfbot/master/public_digest.py | 87 +++++++++++++++++++++++++---- src/wolfbot/services/llm_service.py | 72 +++++++++++++++++++++++- tests/test_master_public_digest.py | 49 ++++++++++++++++ 3 files changed, 194 insertions(+), 14 deletions(-) diff --git a/src/wolfbot/master/public_digest.py b/src/wolfbot/master/public_digest.py index 66017c2..df128bb 100644 --- a/src/wolfbot/master/public_digest.py +++ b/src/wolfbot/master/public_digest.py @@ -7,17 +7,23 @@ NPC bot receives the same digest regardless of role; their own private state is what differentiates the eventual decision. -What lands in the digest (the "中等" digest the user sealed in the -Phase-D spec): +What lands in the digest: * Active CO claims: seat → role, with counter-CO history. -* Silent seats (alive seats who haven't spoken in the active phase). +* Silent seats (alive seats who haven't spoken in the day's discussion). * Per-seat **addressed-count**: how often each seat has been the ``addressed_seat_no`` of another's utterance — a lightweight stand-in for a true "pressure" / "stance" score that doesn't require an extra LLM analyzer pass. * Last addressed line: the most recent seat-to-seat callout text so the NPC can reply on-topic. +* Recent speeches (last N): full text per utterance so the vote / night + decision LLM sees what the seer / medium actually claimed. Without + this the vote prompt only saw 「席9 が seer CO」 and 「席3 が medium + CO」 — no SQ-黒 detail — and ラキオ ended up voting セツ instead of + the SQ that ユリコ called black. +* Past votes from completed days: voter → target ledger so the LLM can + reason about who voted whom historically. Pure function of the inputs — no I/O, no LLM. Master invokes it once per outbound `DecideVoteRequest` / `DecideNightActionRequest` / @@ -30,18 +36,32 @@ from wolfbot.domain.discussion import PublicDiscussionState, SpeechEvent, SpeechSource +# Cap on the recent-speeches block so the prompt stays compact. Mirrors +# `_RECENT_SPEECH_CAP` in `speak_arbiter.py` for parity. +_RECENT_SPEECH_CAP = 20 + +# Per-utterance text snippet length cap. Any single speech longer than +# this is truncated with an ellipsis so a single rambling line doesn't +# blow the prompt budget. +_SNIPPET_CAP = 200 + def build_public_digest( *, state: PublicDiscussionState, recent_events: Sequence[SpeechEvent], seat_names: dict[int, str], + past_votes: Sequence[tuple[int, int, Sequence[tuple[int, int | None]]]] = (), ) -> str: """Compose the Japanese digest block. ``recent_events`` is a chronological sequence of the speech events folded into ``state``. ``phase_baseline`` sentinels are filtered out automatically — callers can pass the raw `load_phase` result. + + ``past_votes`` is the completed-day vote ledger as + ``(day, round, ((voter, target_or_none), ...))`` tuples. Passing an + empty sequence skips the ballot block entirely. """ lines: list[str] = [] @@ -70,11 +90,10 @@ def build_public_digest( for ev in recent_events: if ev.source == SpeechSource.PHASE_BASELINE: continue - if ev.addressed_seat_no is None: - continue - addressed_counts[ev.addressed_seat_no] = ( - addressed_counts.get(ev.addressed_seat_no, 0) + 1 - ) + for seat in ev.addressed_seat_nos or ( + (ev.addressed_seat_no,) if ev.addressed_seat_no is not None else () + ): + addressed_counts[seat] = addressed_counts.get(seat, 0) + 1 if addressed_counts: ranked = sorted( addressed_counts.items(), key=lambda kv: (-kv[1], kv[0]) @@ -86,7 +105,13 @@ def build_public_digest( lines.append("## 名指しされた回数 (多い順)") lines.extend(rank_lines) - if state.last_addressed_seat is not None and state.last_addressed_text: + # Prefer the multi-addressee set; fall back to the legacy singular + # field for state objects that haven't been migrated (older fixtures + # / tests / pre-multi-address rows). + addressed_set: frozenset[int] = state.last_addressed_seats + if not addressed_set and state.last_addressed_seat is not None: + addressed_set = frozenset({state.last_addressed_seat}) + if addressed_set and state.last_addressed_text: speaker = ( state.last_addressed_speaker_seat if state.last_addressed_speaker_seat is not None @@ -97,8 +122,10 @@ def build_public_digest( if speaker is not None else "人間" ) - target = state.last_addressed_seat - target_label = f"席{target} {seat_names.get(target, f'席{target}')}" + target_label = "、".join( + f"席{t} {seat_names.get(t, f'席{t}')}" + for t in sorted(addressed_set) + ) snippet = state.last_addressed_text.strip().replace("\n", " ") if len(snippet) > 120: snippet = snippet[:120] + "…" @@ -106,6 +133,44 @@ def build_public_digest( f"## 直近の名指し\n {speaker_label} → {target_label}: 「{snippet}」" ) + # Recent speeches with content — the bit the vote / night decision + # LLM was missing. Capped to the trailing N non-baseline events so + # the prompt stays small even on a 9-NPC chat-heavy phase. + speech_lines: list[str] = [] + for ev in recent_events: + if ev.source == SpeechSource.PHASE_BASELINE: + continue + if ev.speaker_seat is None or not ev.text: + continue + seat_label = ( + f"席{ev.speaker_seat} " + f"{seat_names.get(ev.speaker_seat, f'席{ev.speaker_seat}')}" + ) + snippet = ev.text.strip().replace("\n", " ") + if len(snippet) > _SNIPPET_CAP: + snippet = snippet[:_SNIPPET_CAP] + "…" + speech_lines.append(f" {seat_label}: 「{snippet}」") + if speech_lines: + lines.append("## 直近の発言 (古い順)") + lines.extend(speech_lines[-_RECENT_SPEECH_CAP:]) + + if past_votes: + lines.append("## 公開された投票履歴") + for day, round_, pairs in past_votes: + label = "決選投票" if round_ >= 1 else "投票" + lines.append(f"- day{day} {label}:") + for voter, target in pairs: + voter_label = ( + f"席{voter} {seat_names.get(voter, f'席{voter}')}" + ) + if target is None: + target_label = "棄権" + else: + target_label = ( + f"席{target} {seat_names.get(target, f'席{target}')}" + ) + lines.append(f" {voter_label} → {target_label}") + return "\n".join(lines) diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index 92e56f0..1f6b478 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -986,6 +986,16 @@ async def _build_public_digest(self, game: Game, seats: Sequence[Seat]) -> str: dispatch request. Best-effort — a missing DiscussionService or an empty phase yields an empty string so the NPC sees the historical no-digest behavior. + + The vote / night decision happens AFTER the day's discussion has + ended, so ``game.phase`` at this point is DAY_VOTE / NIGHT etc. + Looking up speech events under that phase id returns zero rows + (speeches are persisted under DAY_DISCUSSION / DAY_RUNOFF_SPEECH + only). Pull from the day's DAY_DISCUSSION explicitly so the + digest carries the actual discussion content the NPC needs to + align its vote with what was said. DAY_RUNOFF_SPEECH events are + appended chronologically when present so a runoff vote also sees + the candidates' final speeches. """ if self.discussion_service is None: return "" @@ -998,10 +1008,28 @@ async def _build_public_digest(self, game: Game, seats: Sequence[Seat]) -> str: apply_speech_event, ) - phase_id = _make_phase_id(game.id, game.day_number, game.phase) + day = game.day_number + discussion_phase_id = _make_phase_id( + game.id, day, Phase.DAY_DISCUSSION + ) + runoff_phase_id = _make_phase_id( + game.id, day, Phase.DAY_RUNOFF_SPEECH + ) events = list( - await self.discussion_service.load_phase(game.id, phase_id) + await self.discussion_service.load_phase( + game.id, discussion_phase_id + ) ) + runoff_events = list( + await self.discussion_service.load_phase( + game.id, runoff_phase_id + ) + ) + # Append runoff events after discussion so they fold on top + # of the day's accumulated state. Both phases carry their + # own phase_baseline sentinel so the fold reads each + # baseline correctly when entering the next phase. + events.extend(runoff_events) if not events: return "" state = None @@ -1009,9 +1037,13 @@ async def _build_public_digest(self, game: Game, seats: Sequence[Seat]) -> str: state = apply_speech_event(state, ev) if state is None: return "" + past_votes = await self._load_past_votes(game.id, day) seat_names = {s.seat_no: s.display_name for s in seats} return build_public_digest( - state=state, recent_events=events, seat_names=seat_names, + state=state, + recent_events=events, + seat_names=seat_names, + past_votes=past_votes, ) except Exception: log.exception( @@ -1020,6 +1052,40 @@ async def _build_public_digest(self, game: Game, seats: Sequence[Seat]) -> str: ) return "" + async def _load_past_votes( + self, + game_id: str, + current_day: int, + ) -> tuple[tuple[int, int, tuple[tuple[int, int | None], ...]], ...]: + """Load completed-day vote ballots so the digest can render the + full "who voted whom" history. + + Mirrors `SpeakArbiter._load_past_votes` so the vote / night + decision LLM gets the same ballot ledger that day-discussion + speeches already see via the LogicPacket. Returns ``()`` when no + past day exists or any DB read fails — best-effort. + """ + if current_day <= 1: + return () + out: list[tuple[int, int, tuple[tuple[int, int | None], ...]]] = [] + try: + for day in range(1, current_day): + for round_ in (0, 1): + rows = await self.repo.load_votes( + game_id, day=day, round_=round_, + ) + if not rows: + continue + pairs = tuple( + (v.voter_seat, v.target_seat) + for v in sorted(rows, key=lambda v: v.voter_seat) + ) + out.append((day, round_, pairs)) + except Exception: + log.exception("digest_past_votes_load_failed game=%s", game_id) + return () + return tuple(out) + async def _run_votes_via_npc_dispatcher( self, game: Game, diff --git a/tests/test_master_public_digest.py b/tests/test_master_public_digest.py index 6f86ce8..0f3b2ac 100644 --- a/tests/test_master_public_digest.py +++ b/tests/test_master_public_digest.py @@ -147,3 +147,52 @@ def test_digest_skips_phase_baseline_in_addressed_counts() -> None: state=state, recent_events=events, seat_names={2: "Bob"}, ) assert "席2 Bob: 1回" in out + + +def test_digest_renders_recent_speech_block_so_vote_llm_sees_seer_results() -> None: + """Regression: the vote / night decision LLM previously saw only + the CO-claims summary (席9 が seer) without any actual content. So + when ユリコ called SQ black, that fact never reached the vote + prompt and ラキオ ended up voting セツ instead of SQ. The digest + must include the recent speech text so the decision LLM can + align with what was said. + """ + state = _state( + co_claims=( + CoClaim(seat=9, role_claim="seer", declared_at_event_id="ev-co1"), + ), + ) + events = [ + _ev(speaker_seat=9, text="この身、占い師。SQ黒。"), + _ev(speaker_seat=3, text="僕の霊媒結果はシゲミチ狼。SQ処刑しよう。"), + ] + out = build_public_digest( + state=state, recent_events=events, + seat_names={3: "Rakio", 9: "Yuriko"}, + ) + assert "## 直近の発言 (古い順)" in out + assert "席9 Yuriko" in out + assert "SQ黒" in out + assert "席3 Rakio" in out + assert "シゲミチ狼" in out + + +def test_digest_renders_past_votes_when_provided() -> None: + """The completed-day ballot ledger lets the LLM reason about who + voted whom historically — same data the SpeakArbiter already feeds + discussion-time speeches via LogicPacket.past_votes. + """ + state = _state() + past_votes = ( + (1, 0, ((1, 7), (2, 7), (3, 7), (4, 1), (5, None))), + ) + out = build_public_digest( + state=state, recent_events=[], + seat_names={1: "Alice", 2: "Bob", 3: "Carol", 4: "Dave", + 5: "Eve", 7: "Frank"}, + past_votes=past_votes, + ) + assert "## 公開された投票履歴" in out + assert "day1 投票" in out + assert "席1 Alice → 席7 Frank" in out + assert "席5 Eve → 棄権" in out From fe4523f720b642cd4203958a01fdf6dadc8a9902 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 15:24:20 +0900 Subject: [PATCH 071/133] fix(reactive_voice): accept VC-chat text input as a speech channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Latest game (55ae4cf77aac, sakura at seat 1) recorded zero PLAYER_SPEECH log entries from the human even though they typed in chat. Root cause: in reactive_voice mode Master narration goes to the VC's attached text chat (`main_vc_channel_id` via `_post_to_vc_chat`), so players naturally type their replies in that channel — but `WolfCog.on_message` only accepted `main_text_channel_id`. Anything typed in VC chat was dropped before the SpeechEvent pipeline saw it, which is also why no NPC reacted: nothing entered the fold or kicked `try_dispatch_next`. - discord_service.on_message: add `is_vc_chat` (reactive_voice + channel matches `main_vc_channel_id`) and route it through the same speech-event recording path as `is_main`. Rounds mode keeps the legacy main-text-only gating. - Add an `on_message_accepted` info log per accepted message so the next "NPC didn't react" report can be diagnosed without guessing. - Tests: regression coverage for VC-chat acceptance under reactive_voice and rejection under rounds mode. --- src/wolfbot/services/discord_service.py | 22 ++++++- tests/test_reactive_voice_mode.py | 88 +++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/src/wolfbot/services/discord_service.py b/src/wolfbot/services/discord_service.py index 8ad4530..137d795 100644 --- a/src/wolfbot/services/discord_service.py +++ b/src/wolfbot/services/discord_service.py @@ -682,13 +682,31 @@ async def on_message(self, message: discord.Message) -> None: return channel_id = str(message.channel.id) is_main = channel_id == game.main_text_channel_id + # In reactive_voice mode Master posts narration into the VC's + # attached text chat (`main_vc_channel_id`), which is where + # players naturally end up typing replies — see `_post_to_vc_chat` + # in `main.py`. Accept that channel as a speech surface too, + # otherwise the human's typed message never enters the + # SpeechEvent pipeline and NPCs never react to it. + is_vc_chat = ( + game.discussion_mode == "reactive_voice" + and channel_id == game.main_vc_channel_id + ) is_wolves = game.wolves_channel_id is not None and channel_id == game.wolves_channel_id - if not (is_main or is_wolves): + if not (is_main or is_vc_chat or is_wolves): return author_seat = await self.repo.seat_of_user(game.id, str(message.author.id)) players = await self.repo.load_players(game.id) + log.info( + "on_message_accepted game=%s seat=%s channel=%s " + "phase=%s is_main=%s is_vc_chat=%s is_wolves=%s", + game.id, author_seat, channel_id, + game.phase.value, is_main, is_vc_chat, is_wolves, + ) - if is_main and game.phase in (Phase.DAY_DISCUSSION, Phase.DAY_RUNOFF_SPEECH): + if (is_main or is_vc_chat) and game.phase in ( + Phase.DAY_DISCUSSION, Phase.DAY_RUNOFF_SPEECH + ): if not _main_channel_should_llm_react(author_seat, players): return if self._discussion_service is not None and author_seat is not None: diff --git a/tests/test_reactive_voice_mode.py b/tests/test_reactive_voice_mode.py index 0078ba3..f9088f9 100644 --- a/tests/test_reactive_voice_mode.py +++ b/tests/test_reactive_voice_mode.py @@ -362,6 +362,94 @@ async def test_on_message_seeds_phase_baseline_before_text_event(repo: SqliteRep assert len(non_baseline) == 2 +async def test_on_message_accepts_vc_chat_in_reactive_voice(repo: SqliteRepo) -> None: + """In reactive_voice mode, Master narration goes to the VC's + attached text chat (`main_vc_channel_id`), so players naturally + type replies in that channel rather than the main text channel. + `on_message` must accept those messages and feed them through the + SpeechEvent pipeline — otherwise the human's typed input is dropped + silently and NPCs never react. Regression: in the live test the + user's typed message produced zero PLAYER_SPEECH log entries. + """ + from wolfbot.domain.discussion import SpeechSource, make_phase_id + + game, store, ds = await _make_discussion_game( + repo, human_seats=[1], discussion_mode="reactive_voice" + ) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=ds, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "user1" + msg.guild.id = game.guild_id + # The user typed in the VC's attached text chat — same channel id + # as `main_vc_channel_id`, NOT `main_text_channel_id`. + msg.channel.id = 200 + msg.content = "私は占い師です" + + await WolfCog.on_message(cog, msg) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + non_baseline = [e for e in events if e.source != SpeechSource.PHASE_BASELINE] + assert len(non_baseline) == 1, ( + "VC-chat input in reactive_voice mode must produce a SpeechEvent" + ) + assert non_baseline[0].source == SpeechSource.TEXT + assert non_baseline[0].speaker_seat == 1 + assert non_baseline[0].text == "私は占い師です" + + +async def test_on_message_rejects_vc_chat_in_rounds_mode(repo: SqliteRepo) -> None: + """Rounds mode keeps the legacy gating: VC-chat is NOT a speech + surface, only the main text channel is. The new VC-chat acceptance + is reactive_voice-only so existing rounds-mode games don't change + behavior. + """ + from wolfbot.domain.discussion import SpeechSource, make_phase_id + + game, store, ds = await _make_discussion_game( + repo, human_seats=[1], discussion_mode="rounds" + ) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=ds, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "user1" + msg.guild.id = game.guild_id + msg.channel.id = 200 # VC text chat + msg.content = "ignored in rounds mode" + + await WolfCog.on_message(cog, msg) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + non_baseline = [e for e in events if e.source != SpeechSource.PHASE_BASELINE] + assert non_baseline == [], ( + "rounds-mode VC-chat must remain non-speech (main-text only)" + ) + + async def test_llm_adapter_seeds_baseline_for_all_human_game(repo: SqliteRepo) -> None: """submit_llm_discussion_rounds must seed the phase baseline even when there are zero LLM seats so an all-human game gets a sentinel row.""" From d4c71c796ee6ac648c35c976cc7780f1c8591983 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 21:25:52 +0900 Subject: [PATCH 072/133] fix(reactive_voice): match text-path analyzer to voice-path provider, harden role-callout / single-CO heuristics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the latest game (58a3243a9fb8) post-mortem: 1. Architecture asymmetry: TextAnalyzer was hard-coded to Gemini even when the voice path was already configured to split STT (Groq) and analyzer (xAI). Result: every typed message hit the shared Gemini key and 429'd, so role_callout / co_declaration / addressed_name came back NULL on every human text — wolves never saw "占い師どうぞ" trigger and the arbiter never routed addresses. Add `OpenAICompatibleTextAnalyzer` that POSTs to the same OpenAI- compatible endpoint the voice analyzer step uses, and pick it in main.py when `VOICE_STT_PROVIDER=groq`. Gemini-mode keeps its existing analyzer for back-compat. 2. Wolf/madman fake-CO under unanswered role-callout: the strategy block previously framed day-1 騙り as "強い選択肢だが、無条件の既定 行動ではない" and the user-facing pending_role_callouts hint said "選択肢として参照する" — both treated as fully optional. Strengthen: when a role-callout is pending and no counter-CO exists, the default is now to fake-CO unless there's a specific reason not to (相方が危険位置, CO 群が過密, etc.). Mirror the change in _ROLE_STRATEGIES[WEREWOLF/MADMAN] and in the speech generator's "未回答の役職呼びかけ" prompt block. 3. Single-CO trust easily flipped by uninformed human accusations: in the same game the real medium voted out the real seer because the human player (sakura) said "ジョナスは怪しい" with no specific evidence. Add a rule to the shared judgment block: bare accusations without a concrete public-info contradiction don't override the "単独 CO = 真寄り" heuristic, regardless of whether the accuser is another NPC or the human player. Real role holders should not give up village-side information by piling onto evidence-free suspicion. Tests: regression coverage for the OpenAI-compatible analyzer (URL, headers, body shape, role_callout extraction). 1115 tests pass, ruff/mypy green. --- src/wolfbot/llm/prompt_builder.py | 24 +++ src/wolfbot/main.py | 34 +++- src/wolfbot/master/text_analyzer.py | 169 ++++++++++++++++++ .../npc/openai_compatible_generator.py | 16 +- tests/test_text_analyzer.py | 77 ++++++++ 5 files changed, 310 insertions(+), 10 deletions(-) diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index 6b010ba..ad0bb6d 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -76,6 +76,16 @@ def _build_game_rules_block() -> str: "根拠なくその CO 者を処刑候補にしない。\n" "- ただし単独 CO は絶対確定ではない。公開ログ上の発言破綻・投票矛盾・判定結果の矛盾・" "噛み筋との不整合など、通常より強い根拠がある場合に限り疑ってよい。\n" + "- **単独 CO 者を疑う『強い根拠』には、具体的な公開情報の矛盾が必要である。**" + "他のプレイヤー (人間プレイヤー含む) が『〜は怪しい』『〜は人狼』と単に表明しただけ、" + "占い結果や霊媒結果の具体的な指摘・投票や噛み筋の矛盾の指摘がない直感的疑い表明は、" + "単独 CO 真置きルールを上書きする根拠としては弱い。" + "そうした表明に同調して単独 CO 者へ票を入れる前に、自分視点で『単独 CO 者を切る具体的な公開情報』が" + "1 つでも挙げられるかを確認する。挙げられないなら同調しない。" + "特に真役職を持つ自分は、公開情報と矛盾しない単独 CO を切ると村の情報を失う側に動くことになる。\n" + "- 人間プレイヤーの直感的な疑い表明は、議論を再評価するきっかけとして参照してよいが、" + "それ自体を独立した証拠として扱わない。人間も狼/狂人として騙る可能性があるし、" + "村役でも誤推理するため、根拠の中身 (具体的な公開情報の指摘) で重み付けする。\n" "- ただし「現在生存している CO 者が 1 人だけ」というだけでは単独 CO 扱いしない。" "同じ役職 CO が過去に 2 人以上存在したことがある場合、対抗者が処刑・襲撃などで死亡して" "現在 1 人だけ残っていても、その残存 CO 者を自動的に真置きしない。\n" @@ -314,6 +324,13 @@ def _build_game_rules_block() -> str: "- day 1 の占い師騙りは強い選択肢だが、無条件の既定行動ではない。" "狂人らしい騙りが既に出ている、CO 数が増えすぎている、相方が危険位置にいる場合は、" "潜伏して発言・投票・噛み筋で白さを取る方がよいことがある。\n" + "- ただし公開ログで占い師・霊媒師・騎士の名乗り出が呼びかけられている " + "(`未回答の役職呼びかけ` / `pending_role_callouts` に該当役職がある) のに " + "対抗 CO がまだ出ていない場合、原則として騙りに出る方を優先する。" + "単独 CO を放置すると村は『単独 CO = 真寄り』として真占いを真置きしやすくなり、" + "狼陣営は判定情報を打ち返す手段を失う。" + "潜伏を選ぶのは、相方が既に危険位置で吊られそう、対抗を出すと 4-1 以上に膨らんでローラー必至、" + "等の具体的かつ強い理由がある場合に限る。漠然とした『村が勝手に吊ってくれそう』では潜伏しない。\n" "- 黒出しは真占い師・真霊媒師・真騎士に当てると破綻や対抗 CO のリスクがある。" "吊りやすさだけでなく翌日の霊媒結果・投票・噛み筋まで考えて出す。\n" "- day 1 に占い師騙りを選ぶ場合、初回の占い結果は NIGHT_0 ランダム白に合わせて必ず白を主張する。" @@ -427,6 +444,13 @@ def _build_game_rules_block() -> str: "- 占い師騙りは狂人の強い基本候補で、day 1 に検討する価値が高い。" "ただし無条件ではなく、既に複数の占い師 CO が出ている、CO 数が膨らみすぎる、" "盤面的に潜伏して混乱させた方が得な場合はその限りではない。\n" + "- 公開ログで占い師・霊媒師・騎士の名乗り出が呼びかけられている " + "(`未回答の役職呼びかけ` / `pending_role_callouts` に該当役職がある) のに " + "対抗 CO がまだ出ていない場合、原則として騙りに出る方を優先する。" + "単独 CO を放置すると村は『単独 CO = 真寄り』として真役職を真置きしやすく、" + "狂人としても判定情報を撹乱する手段を失う。" + "潜伏を選ぶのは、CO 群が既に過密で誤爆リスクが上回る、等の具体的かつ強い理由がある場合に限る。" + "漠然とした『村が勝手に吊ってくれそう』では潜伏しない。\n" "- day 1 に占い師騙りを選ぶ場合、初回の占い結果は NIGHT_0 ランダム白に合わせて必ず白を主張する。" "初日に黒を出す主張はこの bot の実ルール上の時系列と矛盾し破綻するため、絶対にしない。\n" "- 黒出しは day 2 以降にだけ検討する。前夜に占ったという想定で、" diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 32fb496..2aeac99 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -661,14 +661,36 @@ async def _on_speech_recorded(game_id: str) -> None: if ( settings.LLM_DISCUSSION_MODE == "reactive_voice" and settings.MASTER_NPC_PSK is not None - and settings.VOICE_LLM_API_KEY is not None ): - from wolfbot.master.text_analyzer import GeminiTextAnalyzer + # Pick the analyzer that matches the voice path. When the user has + # ``VOICE_STT_PROVIDER=groq`` the voice ingest already splits STT + # (Groq Whisper) from the analyzer step (xAI Grok via the + # gameplay LLM key), and the text path should follow the same + # split — otherwise typed messages keep round-tripping through + # Gemini and 429 the moment that key gets rate-limited (the bug + # observed in game 58a3243a9fb8 where every ``role_callout`` came + # back NULL because Gemini was throttled). + if ( + settings.VOICE_STT_PROVIDER == "groq" + and settings.GAMEPLAY_LLM_API_KEY is not None + ): + from wolfbot.master.text_analyzer import OpenAICompatibleTextAnalyzer + + analyzer_base_url = ( + settings.GAMEPLAY_LLM_BASE_URL or "https://api.x.ai/v1" + ) + text_analyzer = OpenAICompatibleTextAnalyzer( + api_key=settings.GAMEPLAY_LLM_API_KEY.get_secret_value(), + model=settings.GAMEPLAY_LLM_MODEL, + base_url=analyzer_base_url, + ) + elif settings.VOICE_LLM_API_KEY is not None: + from wolfbot.master.text_analyzer import GeminiTextAnalyzer - text_analyzer = GeminiTextAnalyzer( - api_key=settings.VOICE_LLM_API_KEY.get_secret_value(), - model=settings.VOICE_LLM_MODEL, - ) + text_analyzer = GeminiTextAnalyzer( + api_key=settings.VOICE_LLM_API_KEY.get_secret_value(), + model=settings.VOICE_LLM_MODEL, + ) cog = WolfCog( bot=bot, diff --git a/src/wolfbot/master/text_analyzer.py b/src/wolfbot/master/text_analyzer.py index 82001e1..9aaa42b 100644 --- a/src/wolfbot/master/text_analyzer.py +++ b/src/wolfbot/master/text_analyzer.py @@ -280,9 +280,178 @@ def _parse_response(raw: str) -> dict: # type: ignore[type-arg] return {} +class OpenAICompatibleTextAnalyzer: + """OpenAI Chat Completions-compatible text analyzer. + + Mirrors :class:`GeminiTextAnalyzer` but POSTs to an OpenAI-compatible + ``/chat/completions`` endpoint (xAI Grok / OpenAI / Together / vLLM + / Ollama / etc.). Used by `main.py` when + ``VOICE_STT_PROVIDER=groq``: in that mode the voice path already + splits STT (Groq Whisper) from the analyzer step (xAI Grok via the + gameplay LLM key), and the text path should follow the same split + instead of round-tripping through Gemini. Without this, a user with + Groq-mode voice still saw their typed messages fail with + ``gemini_http_429`` whenever the shared Gemini key got rate-limited. + + Schema and prompt are identical to :class:`GeminiTextAnalyzer` so + the two providers are interchangeable from the caller's POV. + """ + + _SYSTEM_PROMPT: str = GeminiTextAnalyzer._SYSTEM_PROMPT + + def __init__( + self, + *, + api_key: str, + model: str, + base_url: str = "https://api.x.ai/v1", + timeout_s: float = 8.0, + ) -> None: + self.api_key = api_key + self.model = model + self.base_url = base_url.rstrip("/") + self.timeout_s = timeout_s + + async def analyze(self, *, text: str, timeout_s: float) -> TextAnalysis: + import httpx + + from wolfbot.services.llm_trace import ( + CallTimer, + log_llm_call, + ) + + def _usage_from_dict( + resp_json: dict[str, object], + ) -> dict[str, int | None] | None: + usage = resp_json.get("usage") + if not isinstance(usage, dict): + return None + def _int_or_none(v: object) -> int | None: + return int(v) if isinstance(v, int) else None + return { + "prompt": _int_or_none(usage.get("prompt_tokens")), + "completion": _int_or_none(usage.get("completion_tokens")), + "total": _int_or_none(usage.get("total_tokens")), + } + + effective_timeout = min(timeout_s, self.timeout_s) + body = { + "model": self.model, + "messages": [ + {"role": "system", "content": self._SYSTEM_PROMPT}, + {"role": "user", "content": f"発言:\n{text}"}, + ], + "temperature": 0.0, + "response_format": {"type": "json_object"}, + } + url = f"{self.base_url}/chat/completions" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + timer = CallTimer() + raw_text = "" + tokens: dict[str, int | None] | None = None + err: str | None = None + provider_tag = "openai-compat" + try: + async with httpx.AsyncClient(timeout=effective_timeout) as client: + resp = await client.post(url, json=body, headers=headers) + if resp.status_code != 200: + err = f"openai_http_{resp.status_code}" + await log_llm_call( + role="text_analysis", + provider=provider_tag, + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + ) + raise TextAnalyzerError(err) + resp_json = resp.json() + raw_text = ( + resp_json.get("choices", [{}])[0] + .get("message", {}) + .get("content", "") + ) or "" + tokens = _usage_from_dict(resp_json) + parsed = GeminiTextAnalyzer._parse_response(raw_text) + except TextAnalyzerError: + raise + except httpx.TimeoutException as exc: + err = "openai_timeout" + await log_llm_call( + role="text_analysis", + provider=provider_tag, + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + ) + raise TextAnalyzerError(err) from exc + except httpx.ConnectError as exc: + err = "openai_connection_refused" + await log_llm_call( + role="text_analysis", + provider=provider_tag, + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + ) + raise TextAnalyzerError(err) from exc + except Exception as exc: + err = f"openai_unexpected_{type(exc).__name__}" + await log_llm_call( + role="text_analysis", + provider=provider_tag, + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + ) + raise TextAnalyzerError(err) from exc + + await log_llm_call( + role="text_analysis", + provider=provider_tag, + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=raw_text, + latency_ms=timer.elapsed_ms, + error=None, + tokens=tokens, + ) + + co_raw = parsed.get("co_claim") + co_declaration = co_raw if co_raw in CO_CLAIM_VALUES else None + addressed_raw = parsed.get("addressed_name") + addressed_name: str | None = None + if isinstance(addressed_raw, str): + stripped = addressed_raw.strip() + addressed_name = stripped or None + callout_raw = parsed.get("role_callout") + role_callout = callout_raw if callout_raw in CO_CLAIM_VALUES else None + return TextAnalysis( + addressed_name=addressed_name, + co_declaration=co_declaration, + role_callout=role_callout, + ) + + __all__ = [ "FakeTextAnalyzer", "GeminiTextAnalyzer", + "OpenAICompatibleTextAnalyzer", "TextAnalysis", "TextAnalyzer", "TextAnalyzerError", diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index 700324b..b021e4d 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -187,8 +187,12 @@ def _build_user( # Surface outstanding role-callouts as their own block so the # model treats them as a 1st-class signal rather than a token # buried in the dense status string. Real role holders should - # take this as a CO trigger; wolf-side NPCs should weigh whether - # to fake CO. Wording stays neutral so persona / strategy decide. + # take this as a CO trigger; wolf-side NPCs should fake CO + # unless there's a specific reason not to (the previous wording + # said "選択肢として参照" which the model treated as fully + # optional, and wolves stayed silent even after explicit human + # callouts). The role-strategy block carries the deeper + # reasoning; this prompt block surfaces the trigger. callout_ja = { "seer": "占い師", "medium": "霊媒師", @@ -201,8 +205,12 @@ def _build_user( lines.append("## 未回答の役職呼びかけ") lines.append( f"次の役職に名乗り出が求められているがまだ誰も応答していない: {labels}。" - "あなたが該当役職なら CO の判断材料に、" - "人狼/狂人で騙りを検討中なら呼びかけに応じる選択肢として参照すること。" + "あなたが該当役職なら**直ちに CO する**ことを最優先で検討する。" + "人狼/狂人なら、対抗 CO がまだ出ていない場合は**原則として騙りに出る**方を選ぶ。" + "単独 CO の放置は『単独 CO = 真寄り』ルールにより真役職を村に真置きさせる結果になり、" + "狼陣営の判定撹乱手段が失われる。" + "潜伏を選ぶのは、相方が既に危険位置・CO 数が過密で誤爆/ローラー必至、等の" + "具体的かつ強い理由があるときに限り、漠然とした『村が勝手に吊ってくれそう』で潜伏しない。" ) # Phase-D: prefer the bot's own NpcGameState mirror over the stale # SpeakRequest fields. The state carries role + alive/dead + private diff --git a/tests/test_text_analyzer.py b/tests/test_text_analyzer.py index fed1cc2..01c044b 100644 --- a/tests/test_text_analyzer.py +++ b/tests/test_text_analyzer.py @@ -60,6 +60,83 @@ async def test_fake_analyzer_records_last_text_for_assertions() -> None: assert fake.last_text == "記録される" +async def test_openai_compatible_text_analyzer_extracts_role_callout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When voice ingest uses Groq+xAI (separated STT + analyzer), the + text path must follow the same split — sending typed messages to the + same OpenAI-compatible analyzer endpoint instead of round-tripping + through Gemini. Without this, Gemini rate limits silently kill the + role_callout signal so wolf-side NPCs never see "占い師の方どうぞ" + requests (regression: game 58a3243a9fb8). + """ + import json as _json + from typing import Any + + import httpx + + from wolfbot.master.text_analyzer import OpenAICompatibleTextAnalyzer + + captured: dict[str, Any] = {} + + def _handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["headers"] = dict(request.headers) + captured["body"] = _json.loads(request.content.decode("utf-8")) + payload = { + "choices": [ + { + "message": { + "content": _json.dumps( + { + "co_claim": None, + "addressed_name": None, + "role_callout": "seer", + }, + ensure_ascii=False, + ) + } + } + ], + "usage": { + "prompt_tokens": 70, + "completion_tokens": 10, + "total_tokens": 80, + }, + } + return httpx.Response(200, json=payload) + + transport = httpx.MockTransport(_handler) + real_client = httpx.AsyncClient + + class _MockedClient(real_client): # type: ignore[misc, valid-type] + def __init__(self, *args: Any, **kwargs: Any) -> None: + kwargs.setdefault("transport", transport) + super().__init__(*args, **kwargs) + + monkeypatch.setattr("httpx.AsyncClient", _MockedClient) + + analyzer = OpenAICompatibleTextAnalyzer( + api_key="sk-test", + model="grok-test", + base_url="https://api.x.ai/v1", + ) + analysis = await analyzer.analyze( + text="本当の占い師出てきて、人狼さいどでもいい", + timeout_s=5.0, + ) + + assert analysis.role_callout == "seer" + assert analysis.co_declaration is None + assert analysis.addressed_name is None + # The endpoint and auth shape match the OpenAI Chat Completions + # contract used by GroqWhisperAudioAnalyzer's analyzer step. + assert captured["url"] == "https://api.x.ai/v1/chat/completions" + assert captured["headers"]["authorization"] == "Bearer sk-test" + assert captured["body"]["model"] == "grok-test" + assert captured["body"]["response_format"] == {"type": "json_object"} + + async def test_fake_analyzer_propagates_scripted_exception() -> None: """A scripted exception must be raised verbatim so the cog's broad try/except logs and falls back to raw capture.""" From f9ced2d9f19112aa42b7fd1a65ea00e7564cab46 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Wed, 29 Apr 2026 22:55:18 +0900 Subject: [PATCH 073/133] =?UTF-8?q?fix(reactive=5Fvoice):=20collapse=20?= =?UTF-8?q?=E5=B8=ADN=20into=20a=20single=20roster=20header=20in=20LLM=20p?= =?UTF-8?q?rompts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Game a3bbac5ca3e0 day-5 NPCs started leaking 席番号 into their utterances ("席4と席1の占い主張、席9と席7の霊媒主張") because the prompt input was saturated with 席N format across CO 状況, 直近の発言, 公開された投票履歴, 名指し, 生存者/死亡者, etc. The "do not write 席N in text" rule was one line in a long system prompt; the dominant input pattern won the LLM's imitation. Per the user's request, centralise 席番号 into a single roster header and use display_name everywhere else: - speech LLM (`openai_compatible_generator._build_user`): single `## 参加者 (席番号 → 名前)` block lists every seat → name (alive + dead with cause). All downstream blocks (recent speeches, past votes, seer/medium/guard results, wolf chat, addressed counts, partner wolves) use display_name only. Identity line becomes `あなた: (席N)` so the LLM still has the data-layer mapping. - vote/night LLM (`decision_service._build_state_block`): same roster consolidation; private result blocks render by display_name only. `_format_candidates` keeps 席N because the LLM emits a numeric `target_seat`. - public digest (`build_public_digest`): drop 席N from CO 状況, silent_seats, 名指しされた回数, 直近の名指し, 直近の発言, ballot ledger. The roster lives in the upstream user prompt, not here. - logic packet summary (`build_logic_packet`): accept optional `seat_names` map; SpeakArbiter passes it. Without the map (legacy callers / unit tests) the summary still falls back to 席N for back-compat. - system prompt rule strengthened with explicit good/bad examples for the "状況整理" pattern that triggered the leak. Tests: existing 席N assertions migrated to display_name; new test for the seat-only fallback when `seat_names` isn't supplied. 1116 pass, ruff/mypy green. --- src/wolfbot/master/logic_service.py | 37 ++-- src/wolfbot/master/public_digest.py | 41 ++--- src/wolfbot/master/speak_arbiter.py | 4 + src/wolfbot/npc/decision_service.py | 59 ++++--- .../npc/openai_compatible_generator.py | 160 ++++++++++-------- tests/test_master_public_digest.py | 40 +++-- tests/test_npc_decision_service.py | 8 +- tests/test_npc_seat_assignment.py | 25 ++- tests/test_reactive_voice_master.py | 29 +++- 9 files changed, 255 insertions(+), 148 deletions(-) diff --git a/src/wolfbot/master/logic_service.py b/src/wolfbot/master/logic_service.py index 232d9dc..7097a10 100644 --- a/src/wolfbot/master/logic_service.py +++ b/src/wolfbot/master/logic_service.py @@ -43,28 +43,45 @@ def build_logic_packet( past_votes: Iterable[ tuple[int, int, tuple[tuple[int, int | None], ...]] ] = (), + seat_names: dict[int, str] | None = None, ) -> LogicPacket: """Construct a `LogicPacket` for `recipient_npc_id`. The packet is deterministic given the same `state` + `now_ms` save for the random `packet_id`. Tests should pin `now_ms` and inspect the rest of the payload directly. + + ``seat_names`` is a seat → display_name lookup so the rendered + summary can refer to players by name instead of ``席N``. Optional + for back-compat (older callers / tests pass nothing and get the + legacy seat-only rendering); production callers in + `SpeakArbiter.dispatch_request` always pass it. """ + name_map = seat_names or {} + + def _name(seat: int) -> str: + return name_map.get(seat) or f"席{seat}" + candidates: list[LogicCandidate] = [] for claim in state.co_claims: candidates.append( LogicCandidate( id=f"co-{claim.seat}-{claim.role_claim}", - claim=f"席{claim.seat} {claim.role_claim}CO", + claim=f"{_name(claim.seat)} {claim.role_claim}CO", ) ) candidates.extend(additional_candidates) + silent_names = ( + "、".join(_name(s) for s in sorted(state.silent_seats)) + if state.silent_seats + else "" + ) silent_repr = ( - f"silent_seats={sorted(state.silent_seats)}" if state.silent_seats else "silent_seats=[]" + f"silent_seats=[{silent_names}]" if silent_names else "silent_seats=[]" ) co_repr = ( - ", ".join(f"席{c.seat}={c.role_claim}" for c in state.co_claims) + ", ".join(f"{_name(c.seat)}={c.role_claim}" for c in state.co_claims) if state.co_claims else "(none)" ) @@ -83,7 +100,7 @@ def build_logic_packet( addressed_seats = frozenset({state.last_addressed_seat}) if addressed_seats: speaker_repr = ( - f"席{state.last_addressed_speaker_seat}" + _name(state.last_addressed_speaker_seat) if state.last_addressed_speaker_seat is not None else "human" ) @@ -93,13 +110,11 @@ def build_logic_packet( if len(utter) > 160: utter = utter[:160] + "…" sorted_seats = sorted(addressed_seats) - if len(sorted_seats) == 1: - # Singular case keeps the legacy ``last_address=席N`` shape - # so existing log scrapers / NPC-side parsers don't see a - # surprise format change. - addr_repr = f"席{sorted_seats[0]}" - else: - addr_repr = "[" + ",".join(f"席{s}" for s in sorted_seats) + "]" + addr_repr = ( + _name(sorted_seats[0]) + if len(sorted_seats) == 1 + else "[" + "、".join(_name(s) for s in sorted_seats) + "]" + ) summary += ( f" last_address={addr_repr}" f" from={speaker_repr} text=\"{utter}\"" diff --git a/src/wolfbot/master/public_digest.py b/src/wolfbot/master/public_digest.py index df128bb..7d2c019 100644 --- a/src/wolfbot/master/public_digest.py +++ b/src/wolfbot/master/public_digest.py @@ -65,18 +65,21 @@ def build_public_digest( """ lines: list[str] = [] + def _name(seat: int) -> str: + """Best-effort display_name lookup; falls back to ``席N`` only + when the roster doesn't carry a name (recovery edge cases).""" + return seat_names.get(seat) or f"席{seat}" + co_lines: list[str] = [] if state.co_claims: for c in state.co_claims: - name = seat_names.get(c.seat, f"席{c.seat}") - co_lines.append(f" 席{c.seat} {name}: {c.role_claim}") + co_lines.append(f" {_name(c.seat)}: {c.role_claim}") lines.append("## CO 状況") lines.extend(co_lines or [" (まだ誰も CO していない)"]) if state.silent_seats: silent_str = "、".join( - f"席{s} {seat_names.get(s, f'席{s}')}" - for s in sorted(state.silent_seats) + _name(s) for s in sorted(state.silent_seats) ) lines.append(f"## 未発言の生存席\n {silent_str}") else: @@ -99,7 +102,7 @@ def build_public_digest( addressed_counts.items(), key=lambda kv: (-kv[1], kv[0]) ) rank_lines = [ - f" 席{seat_no} {seat_names.get(seat_no, f'席{seat_no}')}: {count}回" + f" {_name(seat_no)}: {count}回" for seat_no, count in ranked ] lines.append("## 名指しされた回数 (多い順)") @@ -117,15 +120,8 @@ def build_public_digest( if state.last_addressed_speaker_seat is not None else None ) - speaker_label = ( - f"席{speaker} {seat_names.get(speaker, f'席{speaker}')}" - if speaker is not None - else "人間" - ) - target_label = "、".join( - f"席{t} {seat_names.get(t, f'席{t}')}" - for t in sorted(addressed_set) - ) + speaker_label = _name(speaker) if speaker is not None else "人間" + target_label = "、".join(_name(t) for t in sorted(addressed_set)) snippet = state.last_addressed_text.strip().replace("\n", " ") if len(snippet) > 120: snippet = snippet[:120] + "…" @@ -142,14 +138,10 @@ def build_public_digest( continue if ev.speaker_seat is None or not ev.text: continue - seat_label = ( - f"席{ev.speaker_seat} " - f"{seat_names.get(ev.speaker_seat, f'席{ev.speaker_seat}')}" - ) snippet = ev.text.strip().replace("\n", " ") if len(snippet) > _SNIPPET_CAP: snippet = snippet[:_SNIPPET_CAP] + "…" - speech_lines.append(f" {seat_label}: 「{snippet}」") + speech_lines.append(f" {_name(ev.speaker_seat)}: 「{snippet}」") if speech_lines: lines.append("## 直近の発言 (古い順)") lines.extend(speech_lines[-_RECENT_SPEECH_CAP:]) @@ -160,15 +152,8 @@ def build_public_digest( label = "決選投票" if round_ >= 1 else "投票" lines.append(f"- day{day} {label}:") for voter, target in pairs: - voter_label = ( - f"席{voter} {seat_names.get(voter, f'席{voter}')}" - ) - if target is None: - target_label = "棄権" - else: - target_label = ( - f"席{target} {seat_names.get(target, f'席{target}')}" - ) + voter_label = _name(voter) + target_label = "棄権" if target is None else _name(target) lines.append(f" {voter_label} → {target_label}") return "\n".join(lines) diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index c6f89ea..6aa3670 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -316,6 +316,9 @@ async def dispatch_request( await self._collect_request_context(state, seat_no) ) past_votes = await self._load_past_votes(game_id, state.day) + seat_names_lookup: dict[int, str] = { + seat: name for seat, name in (list(alive_seats) + list(dead_seats)) + } # Build LogicPacket (sent first so the NPC has context for the # subsequent speak_request). @@ -326,6 +329,7 @@ async def dispatch_request( now_ms=now, recent_speeches=recent_speeches, past_votes=past_votes, + seat_names=seat_names_lookup, ) try: await entry.send(packet.model_dump_json()) diff --git a/src/wolfbot/npc/decision_service.py b/src/wolfbot/npc/decision_service.py index ac409cc..e9ebc81 100644 --- a/src/wolfbot/npc/decision_service.py +++ b/src/wolfbot/npc/decision_service.py @@ -113,28 +113,49 @@ async def decide_json( def _build_state_block(state: NpcGameState) -> str: - """Render the bot's private state mirror as a Japanese prompt block.""" - lines = [f"あなたの席: 席{state.seat_no}", f"あなたの役職: {state.role}"] - if state.alive_seats: - alive = "、".join(f"席{s} {n}" for s, n in state.alive_seats) - lines.append(f"生存者: {alive}") - if state.dead_seats: - causes = state.dead_seat_causes - def _cause(seat_no: int) -> str: - c = causes.get(seat_no) - return " (処刑)" if c == "EXECUTION" else " (襲撃)" if c == "ATTACK" else "" - dead = "、".join( - f"席{s} {n}{_cause(s)}" for s, n in state.dead_seats - ) - lines.append(f"死亡者: {dead}") + """Render the bot's private state mirror as a Japanese prompt block. + + Naming policy: 席番号は冒頭の `## 参加者` ロスター 1 ブロックだけに + 集約し、ここから下は display_name のみで参照する。data 層 (vote + target_seat 等) には数字を入れる。 + """ + own_name: str | None = None + for s, n in state.alive_seats: + if s == state.seat_no: + own_name = n + break + own_label = ( + f"{own_name} (席{state.seat_no})" if own_name else f"席{state.seat_no}" + ) + lines: list[str] = [ + f"あなた: {own_label}", + f"あなたの役職: {state.role}", + ] + causes = state.dead_seat_causes + + def _cause(seat_no: int) -> str: + c = causes.get(seat_no) + return " (処刑)" if c == "EXECUTION" else " (襲撃)" if c == "ATTACK" else "" + + if state.alive_seats or state.dead_seats: + lines.append("") + lines.append("## 参加者 (席番号 → 名前)") + if state.alive_seats: + lines.append("生存中:") + for s, n in state.alive_seats: + lines.append(f" 席{s} {n}") + if state.dead_seats: + lines.append("死亡:") + for s, n in state.dead_seats: + lines.append(f" 席{s} {n}{_cause(s)}") if state.partner_wolves: - partners = "、".join(f"席{s} {n}" for s, n in state.partner_wolves) + partners = "、".join(n for _s, n in state.partner_wolves) lines.append(f"仲間の人狼 (非公開): {partners}") if state.seer_results: lines.append("## 自分の占い結果 (非公開)") for sr in state.seer_results: verdict = "黒 (人狼)" if sr.is_wolf else "白 (人狼ではない)" - lines.append(f" day{sr.day}: 席{sr.target_seat} {sr.target_name} → {verdict}") + lines.append(f" day{sr.day}: {sr.target_name} → {verdict}") if state.medium_results: lines.append("## 自分の霊媒結果 (非公開)") for mr in state.medium_results: @@ -144,7 +165,7 @@ def _cause(seat_no: int) -> str: verdict = "人狼" else: verdict = "人狼ではない" - lines.append(f" day{mr.day}: 席{mr.target_seat} {mr.target_name} → {verdict}") + lines.append(f" day{mr.day}: {mr.target_name} → {verdict}") if state.guard_history: lines.append("## 自分の護衛履歴 (非公開)") for g in state.guard_history: @@ -154,13 +175,13 @@ def _cause(seat_no: int) -> str: else "(結果未確定)" ) lines.append( - f" day{g.day}: 席{g.target_seat} {g.target_name} を護衛 {outcome}" + f" day{g.day}: {g.target_name} を護衛 {outcome}" ) if state.wolf_chat_history: lines.append("## 人狼チャット履歴 (狼/狂人にのみ見える)") for line in state.wolf_chat_history[-20:]: lines.append( - f" day{line.day} 席{line.speaker_seat} {line.speaker_name}: {line.text}" + f" day{line.day} {line.speaker_name}: {line.text}" ) return "\n".join(lines) diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index b021e4d..9266a26 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -146,11 +146,17 @@ def _build_system( "「あの白判定、無理に庇ってる気がする」「昨夜守ったのは◯◯」" "「もう 1 人組んでそうな人」「あと処刑できる回数を考えると…」 のように。\n" "- **`text` 内で席番号 (席1, 席2, ..., 席9 や Seat3 等) を絶対に書かない。**" - "他のプレイヤーを呼ぶときは必ず生存者リストの display_name (キャラ名) を使う。" + "プロンプトの `## 参加者` ブロックで席番号 → 名前のマッピングが冒頭に提示されており、" + "それ以外のブロックは display_name のみで人物を参照している。" + "あなたも他者を呼ぶときは必ず display_name (キャラ名) を使う。" "data 層 (`addressed_seat_nos` 等) には正しい席番号を入れて構わないが、" - "発話そのものは「ジナさん」「ラキオ」のような自然な呼び方にする。\n" - " 禁止例: 「席3はどう思う?」「席4のラキオが…」「Seat 9、答えて」\n" - " 推奨例: 「ジョナスさんはどう思う?」「ラキオが…」「ユリコ、答えて」\n" + "発話そのものは「ジナさん」「ラキオ」のような自然な呼び方にする。" + "状況整理を発話で行うときも『占いCO は ジョナスとsakura、霊媒CO はユリコとセツ』のように" + "名前で並べる。`席4` `席1` のような数字並列は禁止。\n" + " 禁止例: 「席3はどう思う?」「席4のラキオが…」「Seat 9、答えて」" + "「席4と席1の占い主張、席9と席7の霊媒主張」\n" + " 推奨例: 「ジョナスさんはどう思う?」「ラキオが…」「ユリコ、答えて」" + "「ジョナスとsakuraの占い主張、ユリコとセツの霊媒主張」\n" "- 役職 CO (占い師・霊媒師・騎士として名乗る) をするときは、" "`co_declaration` を `\"seer\" / \"medium\" / \"knight\"` のいずれかに設定し、" "`text` は「実は私、占い師なんだ」など自然な名乗りにする。" @@ -175,24 +181,67 @@ def _build_user( request: SpeakRequest, state: object | None = None, ) -> str: + """Compose the speech LLM's user prompt. + + Naming policy: 席番号は冒頭の `## 参加者` ロスター 1 ブロックに + 集約し、それ以外の入力ブロックでは display_name のみで参照する。 + プロンプト全体に席番号が散らばっていると LLM が出力でも `席N` を + 引き写すドリフトが起きるため (game a3bbac5ca3e0 day5 で観測)、 + 席番号は data 層 (`addressed_seat_nos` / `target_seat`) でだけ + 使う。 + """ + # Phase-D: prefer the bot's own NpcGameState mirror over the stale + # SpeakRequest fields. The state carries role + alive/dead + private + # results + wolf chat that the speech LLM needs to be in character. + alive_seats = ( + getattr(state, "alive_seats", None) + or list(request.alive_seats) + ) + dead_seats = ( + getattr(state, "dead_seats", None) + or list(request.dead_seats) + ) + cause_map = (getattr(state, "dead_seat_causes", None) or {}) if state else {} + + def _cause_tag(seat_no: int) -> str: + cause = cause_map.get(seat_no) + if cause == "EXECUTION": + return " (処刑)" + if cause == "ATTACK": + return " (襲撃)" + return "" + + own_name: str | None = None + for seat_no, name in alive_seats: + if seat_no == request.seat_no: + own_name = name + break + own_label = f"{own_name} (席{request.seat_no})" if own_name else f"席{request.seat_no}" + lines = [ f"フェイズ: {request.phase_id}", - f"あなたの席: 席{request.seat_no}", + f"あなた: {own_label}", f"提案意図: {request.suggested_intent}", - "", - "## 場の状況", - logic.public_state_summary or "(情報なし)", ] + + # Roster header — the ONLY block where seat numbers explicitly + # appear. All other blocks below reference players by display_name. + if alive_seats or dead_seats: + lines.append("") + lines.append("## 参加者 (席番号 → 名前)") + if alive_seats: + lines.append("生存中:") + for seat_no, name in alive_seats: + lines.append(f" 席{seat_no} {name}") + if dead_seats: + lines.append("死亡:") + for seat_no, name in dead_seats: + lines.append(f" 席{seat_no} {name}{_cause_tag(seat_no)}") + + lines.append("") + lines.append("## 場の状況") + lines.append(logic.public_state_summary or "(情報なし)") if logic.pending_role_callouts: - # Surface outstanding role-callouts as their own block so the - # model treats them as a 1st-class signal rather than a token - # buried in the dense status string. Real role holders should - # take this as a CO trigger; wolf-side NPCs should fake CO - # unless there's a specific reason not to (the previous wording - # said "選択肢として参照" which the model treated as fully - # optional, and wolves stayed silent even after explicit human - # callouts). The role-strategy block carries the deeper - # reasoning; this prompt block surfaces the trigger. callout_ja = { "seer": "占い師", "medium": "霊媒師", @@ -212,46 +261,11 @@ def _build_user( "潜伏を選ぶのは、相方が既に危険位置・CO 数が過密で誤爆/ローラー必至、等の" "具体的かつ強い理由があるときに限り、漠然とした『村が勝手に吊ってくれそう』で潜伏しない。" ) - # Phase-D: prefer the bot's own NpcGameState mirror over the stale - # SpeakRequest fields. The state carries role + alive/dead + private - # results + wolf chat that the speech LLM needs to be in character. - alive_seats = ( - getattr(state, "alive_seats", None) - or list(request.alive_seats) - ) - dead_seats = ( - getattr(state, "dead_seats", None) - or list(request.dead_seats) - ) - if alive_seats: - alive_str = "、".join( - f"席{seat_no} {name}" for seat_no, name in alive_seats - ) - lines.append("") - lines.append(f"## 生存者\n{alive_str}") - if dead_seats: - # Tag each dead seat with the death cause so the model never - # confuses "executed yesterday" with "killed last night". - cause_map = (getattr(state, "dead_seat_causes", None) or {}) if state else {} - - def _cause_tag(seat_no: int) -> str: - cause = cause_map.get(seat_no) - if cause == "EXECUTION": - return " (処刑)" - if cause == "ATTACK": - return " (襲撃)" - return "" - - dead_str = "、".join( - f"席{seat_no} {name}{_cause_tag(seat_no)}" - for seat_no, name in dead_seats - ) - lines.append(f"## 死亡者\n{dead_str}") # Private state — only present when Phase-D snapshot was received. if state is not None: partner_wolves = getattr(state, "partner_wolves", []) or [] if partner_wolves: - partners = "、".join(f"席{s} {n}" for s, n in partner_wolves) + partners = "、".join(n for _s, n in partner_wolves) lines.append(f"## 仲間の人狼 (非公開)\n{partners}") seer_results = getattr(state, "seer_results", []) or [] if seer_results: @@ -259,7 +273,7 @@ def _cause_tag(seat_no: int) -> str: for sr in seer_results: verdict = "黒 (人狼)" if sr.is_wolf else "白 (人狼ではない)" lines.append( - f" day{sr.day}: 席{sr.target_seat} {sr.target_name} → {verdict}" + f" day{sr.day}: {sr.target_name} → {verdict}" ) medium_results = getattr(state, "medium_results", []) or [] if medium_results: @@ -272,7 +286,7 @@ def _cause_tag(seat_no: int) -> str: else: verdict = "人狼ではない" lines.append( - f" day{mr.day}: 席{mr.target_seat} {mr.target_name} → {verdict}" + f" day{mr.day}: {mr.target_name} → {verdict}" ) guard_history = getattr(state, "guard_history", []) or [] if guard_history: @@ -284,14 +298,14 @@ def _cause_tag(seat_no: int) -> str: else "(結果未確定)" ) lines.append( - f" day{g.day}: 席{g.target_seat} {g.target_name} を護衛 {outcome}" + f" day{g.day}: {g.target_name} を護衛 {outcome}" ) wolf_chat_history = getattr(state, "wolf_chat_history", []) or [] if wolf_chat_history: lines.append("## 人狼チャット履歴 (狼/狂人にのみ見える)") for wc in wolf_chat_history[-15:]: lines.append( - f" day{wc.day} 席{wc.speaker_seat} {wc.speaker_name}: {wc.text}" + f" day{wc.day} {wc.speaker_name}: {wc.text}" ) if logic.past_votes: # Public vote history. Each NPC saw the EXECUTION public log when @@ -301,8 +315,6 @@ def _cause_tag(seat_no: int) -> str: # own vote target. lines.append("") lines.append("## 公開された投票履歴") - # Build a name lookup from alive + dead so dead voters still get - # a display name. seat_name_lookup = { seat_no: name for seat_no, name in ( @@ -310,37 +322,51 @@ def _cause_tag(seat_no: int) -> str: ) } - def _seat_label(seat: int | None) -> str: + def _voter_label(seat: int | None) -> str: if seat is None: return "棄権" - name = seat_name_lookup.get(seat, "?") - return f"席{seat} {name}" if name and name != "?" else f"席{seat}" + name = seat_name_lookup.get(seat) + return name if name else "?" for day, round_, pairs in logic.past_votes: label = "決選投票" if round_ >= 1 else "投票" lines.append(f"- day{day} {label}:") for voter, target in pairs: lines.append( - f" {_seat_label(voter)} → {_seat_label(target)}" + f" {_voter_label(voter)} → {_voter_label(target)}" ) if logic.recent_speeches: lines.append("") lines.append("## 直近の発言 (古い順)") for sp in logic.recent_speeches: tag = _SOURCE_TAG.get(sp.source, sp.source) - lines.append(f"- 席{sp.seat_no} {sp.display_name} [{tag}]: {sp.text}") + lines.append(f"- {sp.display_name} [{tag}]: {sp.text}") if logic.logic_candidates: lines.append("") lines.append("## 論点候補") for c in logic.logic_candidates: lines.append(_format_candidate(c)) if logic.pressure: + # MVP code paths leave this empty; rendered as name → score so + # the seat-number column doesn't reappear here either. + seat_name_lookup_p = { + seat_no: name + for seat_no, name in ( + list(alive_seats) + list(dead_seats) + ) + } lines.append("") - lines.append("## 圧力マップ (席番号 → 疑い度)") + lines.append("## 圧力マップ (疑い度)") for seat, val in sorted(logic.pressure.items()): - lines.append(f" 席{seat}: {val:.2f}") + label = seat_name_lookup_p.get(seat) or f"席{seat}" + lines.append(f" {label}: {val:.2f}") lines.append("") - lines.append("上記を踏まえ、キャラクターとして自然な短い発言を生成してください。") + lines.append( + "上記を踏まえ、キャラクターとして自然な短い発言を生成してください。" + "他者を呼ぶときは display_name (例: 「セツさん」「ラキオ」) を使い、" + "発話文中で席番号 (席1 等) は絶対に書かない。" + "席番号は data 層 (`addressed_seat_nos`) にだけ入れる。" + ) return "\n".join(lines) diff --git a/tests/test_master_public_digest.py b/tests/test_master_public_digest.py index 0f3b2ac..6f63cd6 100644 --- a/tests/test_master_public_digest.py +++ b/tests/test_master_public_digest.py @@ -73,8 +73,13 @@ def test_digest_renders_co_claims_with_names() -> None: state=state, recent_events=[], seat_names={1: "Alice", 2: "Bob", 4: "Dave"}, ) - assert "席2 Bob: seer" in out - assert "席4 Dave: medium" in out + # New naming policy: digest renders by display_name only (seat + # numbers are intentionally NOT in this block — the full mapping + # lives in the user-prompt's `## 参加者` roster, not here). + assert "Bob: seer" in out + assert "Dave: medium" in out + assert "席2" not in out + assert "席4" not in out def test_digest_lists_silent_seats() -> None: @@ -84,7 +89,10 @@ def test_digest_lists_silent_seats() -> None: seat_names={3: "Carol", 4: "Dave"}, ) assert "## 未発言の生存席" in out - assert "席3 Carol" in out and "席4 Dave" in out + assert "Carol" in out and "Dave" in out + # Seat numbers stripped — the roster header upstream is the single + # source of truth. + assert "席3" not in out and "席4" not in out def test_digest_aggregates_addressed_counts_descending() -> None: @@ -100,10 +108,10 @@ def test_digest_aggregates_addressed_counts_descending() -> None: seat_names={2: "Bob", 4: "Dave"}, ) assert "## 名指しされた回数 (多い順)" in out - seat2_idx = out.find("席2 Bob: 3回") - seat4_idx = out.find("席4 Dave: 1回") - assert seat2_idx != -1 and seat4_idx != -1 - assert seat2_idx < seat4_idx # higher count first + bob_idx = out.find("Bob: 3回") + dave_idx = out.find("Dave: 1回") + assert bob_idx != -1 and dave_idx != -1 + assert bob_idx < dave_idx # higher count first def test_digest_renders_last_addressed_block() -> None: @@ -117,7 +125,7 @@ def test_digest_renders_last_addressed_block() -> None: seat_names={1: "Alice", 2: "Bob"}, ) assert "## 直近の名指し" in out - assert "席1 Alice → 席2 Bob" in out + assert "Alice → Bob" in out assert "信用できない" in out @@ -146,7 +154,7 @@ def test_digest_skips_phase_baseline_in_addressed_counts() -> None: out = build_public_digest( state=state, recent_events=events, seat_names={2: "Bob"}, ) - assert "席2 Bob: 1回" in out + assert "Bob: 1回" in out def test_digest_renders_recent_speech_block_so_vote_llm_sees_seer_results() -> None: @@ -171,10 +179,12 @@ def test_digest_renders_recent_speech_block_so_vote_llm_sees_seer_results() -> N seat_names={3: "Rakio", 9: "Yuriko"}, ) assert "## 直近の発言 (古い順)" in out - assert "席9 Yuriko" in out + assert "Yuriko" in out assert "SQ黒" in out - assert "席3 Rakio" in out + assert "Rakio" in out assert "シゲミチ狼" in out + # Seat numbers no longer appear in the digest body (roster-only). + assert "席9" not in out and "席3" not in out def test_digest_renders_past_votes_when_provided() -> None: @@ -194,5 +204,9 @@ def test_digest_renders_past_votes_when_provided() -> None: ) assert "## 公開された投票履歴" in out assert "day1 投票" in out - assert "席1 Alice → 席7 Frank" in out - assert "席5 Eve → 棄権" in out + assert "Alice → Frank" in out + assert "Eve → 棄権" in out + # Seat numbers don't appear in the body — only the roster header + # (which `build_public_digest` doesn't render; the upstream user + # prompt does). + assert "席1" not in out and "席7" not in out diff --git a/tests/test_npc_decision_service.py b/tests/test_npc_decision_service.py index d9d8a16..9bbcabd 100644 --- a/tests/test_npc_decision_service.py +++ b/tests/test_npc_decision_service.py @@ -45,8 +45,14 @@ def test_build_vote_prompt_includes_state_and_candidates() -> None: ) system, user = build_vote_prompt(state=_state(), persona=persona, request=req) assert "JSON" in system - assert "あなたの席: 席3" in user + # Own identity now leads with display_name; seat number is + # appended in parens so the LLM still has the data-layer mapping. + assert "あなた: セツ (席3)" in user assert "あなたの役職: WEREWOLF" in user + # Roster header is the only block where 席N appears alongside the + # name; the candidate list keeps 席N because the LLM emits a + # numeric `target_seat`. + assert "## 参加者 (席番号 → 名前)" in user assert "席1 Alice" in user and "席5 Bob" in user assert "通常投票" in user # Persona name surfaces verbatim so the LLM stays in character. diff --git a/tests/test_npc_seat_assignment.py b/tests/test_npc_seat_assignment.py index 58ad930..71d4fa6 100644 --- a/tests/test_npc_seat_assignment.py +++ b/tests/test_npc_seat_assignment.py @@ -332,13 +332,21 @@ def test_build_user_prompt_renders_recent_speeches_and_seats() -> None: dead_seats=((4, "故人"),), ) user_msg = _build_user(logic, request) + # Recent speeches now render by display_name only — seat numbers + # live exclusively in the `## 参加者` roster header upstream. assert "## 直近の発言" in user_msg - assert "席1 Alice" in user_msg and "占いの結果が気になる" in user_msg - assert "席3 🌙セツ" in user_msg and "まだ静かですね" in user_msg + assert "Alice" in user_msg and "占いの結果が気になる" in user_msg + assert "🌙セツ" in user_msg and "まだ静かですね" in user_msg assert "[テキスト]" in user_msg and "[NPC発話]" in user_msg - assert "## 生存者" in user_msg - assert "席1 Alice、席2 Bob、席3 🌙セツ" in user_msg - assert "## 死亡者" in user_msg and "席4 故人" in user_msg + # Roster header is the single source of seat→name mapping. + assert "## 参加者 (席番号 → 名前)" in user_msg + assert "席1 Alice" in user_msg and "席2 Bob" in user_msg + assert "席3 🌙セツ" in user_msg + assert "席4 故人" in user_msg + # The `生存者` / `死亡者` blocks were folded into the unified + # roster — assert recent-speech lines no longer carry the seat + # prefix that used to leak into NPC output ("席4と席1の占い…"). + assert "席1 Alice [テキスト]" not in user_msg def test_build_user_prompt_uses_npc_state_over_request_fields() -> None: @@ -482,8 +490,9 @@ def test_build_user_prompt_renders_past_votes_with_names() -> None: out = _build_user(logic, request) assert "## 公開された投票履歴" in out assert "day1 投票" in out - assert "席1 Alice → 席3 ラキオ" in out - assert "席2 ジナ → 席4 セツ" in out + # New naming policy: ballot ledger uses display_name only. + assert "Alice → ラキオ" in out + assert "ジナ → セツ" in out def test_build_user_prompt_handles_past_vote_abstain() -> None: @@ -502,7 +511,7 @@ def test_build_user_prompt_handles_past_vote_abstain() -> None: dead_seats=(), ) out = _build_user(logic, request) - assert "席1 Alice → 棄権" in out + assert "Alice → 棄権" in out def test_build_user_prompt_renders_pending_role_callouts() -> None: diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index 94231e6..7cefc20 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -1834,9 +1834,36 @@ def test_logic_packet_builder_includes_co_claims_in_summary() -> None: recipient_npc_id="npc_p3", expires_at_ms=2000, now_ms=1500, + seat_names={1: "Alice", 2: "Bob", 3: "Carol"}, ) assert packet.recipient_npc_id == "npc_p3" assert packet.expires_at_ms == 2000 assert any(c.id == "co-2-seer" for c in packet.logic_candidates) + # Naming policy: summary renders by display_name when ``seat_names`` + # is supplied (production wiring always supplies it). + assert "Bob=seer" in packet.public_state_summary + assert "silent_seats=[Carol]" in packet.public_state_summary + # Without `seat_names` (legacy / unit-test fallback) the summary + # falls back to 席N — covered by the next test. + + +def test_logic_packet_summary_falls_back_to_seat_when_no_names_supplied() -> None: + state = PublicDiscussionState( + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + alive_seat_nos=frozenset({1, 2, 3}), + ) + from wolfbot.domain.discussion import CoClaim + + state.co_claims = (CoClaim(seat=2, role_claim="seer", declared_at_event_id="e1"),) + state.silent_seats = frozenset({3}) + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_p3", + expires_at_ms=2000, + now_ms=1500, + ) + # No seat_names → legacy 席N rendering preserved for back-compat. assert "席2=seer" in packet.public_state_summary - assert "silent_seats=[3]" in packet.public_state_summary + assert "silent_seats=[席3]" in packet.public_state_summary From 3b4ba543f1db12273ff4c3222e896a41b7abe349 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Thu, 30 Apr 2026 08:51:01 +0900 Subject: [PATCH 074/133] =?UTF-8?q?feat(reactive=5Fvoice):=20re-attack=20G?= =?UTF-8?q?J'd=20target=20=E2=80=94=20knight=20can't=20guard=20same=20seat?= =?UTF-8?q?=20twice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-suggested tactic: when a wolf attack is GJ'd (peaceful morning), the knight is locked out of guarding that same target on the next night by the 連続護衛禁止 rule, so re-attacking that target is a mathematically guaranteed kill. Surface this in the wolf strategy. - prompt_builder (WEREWOLF strategy): explicit rule that GJ'd target is the top re-attack candidate next night unless the board has shifted significantly. Lists concrete switch-conditions so wolves don't bail on vague "feels read". - decision_service (wolf-chat coordination prompt): same rule in the system prompt so wolves coordinate on re-attack rather than scattering. - New `WolfAttackEntry` ws-message struct mirroring `GuardEntry` — carries (day, target_seat, target_name, peaceful_morning) so the wolf prompt has explicit data to act on, not just inference from wolf chat history. - private_state.load_private_state_for_seat: now also returns `wolf_attack_history`, populated for werewolves from the night_actions WOLF_ATTACK rows joined with the MORNING public log to derive `peaceful_morning`. - NpcGameState + speech/decision prompt blocks render "## 自分達の襲撃履歴 (非公開)" with `(平和な朝 = GJ)` / `(襲撃成功)` tags so the LLM can directly see "we attacked X yesterday, X is alive — GJ — re-attack tonight". Tests: regression coverage for the wolf_attack_history loader joining night_actions with MORNING logs (peaceful + victim cases). All four existing private-state tests updated to unpack the new 5-tuple. 1117 pass, ruff/mypy green. --- src/wolfbot/domain/ws_messages.py | 20 ++++++ src/wolfbot/llm/prompt_builder.py | 11 +++ src/wolfbot/main.py | 23 ++++--- src/wolfbot/master/private_state.py | 65 +++++++++++++++++- src/wolfbot/npc/decision_service.py | 22 +++++- src/wolfbot/npc/game_state.py | 3 + .../npc/openai_compatible_generator.py | 13 ++++ tests/test_master_private_state.py | 67 +++++++++++++++++-- 8 files changed, 207 insertions(+), 17 deletions(-) diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index 0180f27..ce841f6 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -324,6 +324,25 @@ class WolfChatLine(BaseModel): text: str +class WolfAttackEntry(BaseModel): + """Past wolf attack target the wolves submitted on a given night. + + ``peaceful_morning`` is True when the attack was knight-GJ'd (no + victim that morning), False when the target was killed, None when + the morning hasn't resolved yet. Wolves use this to detect the + "GJ → re-attack same target" pattern: the knight cannot guard the + same seat on consecutive nights, so a GJ'd target is a free kill + on the next night. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + day: int + target_seat: int + target_name: str + peaceful_morning: bool | None = None + + class PrivateStateSnapshot(BaseEnvelope): """Master → NPC: full private game state for the seat this NPC plays. @@ -350,6 +369,7 @@ class PrivateStateSnapshot(BaseEnvelope): medium_results: tuple[MediumResult, ...] = () guard_history: tuple[GuardEntry, ...] = () wolf_chat_history: tuple[WolfChatLine, ...] = () + wolf_attack_history: tuple[WolfAttackEntry, ...] = () class PrivateStateUpdate(BaseEnvelope): diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index ad0bb6d..9ffffbe 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -377,6 +377,17 @@ def _build_game_rules_block() -> str: "護衛濃厚な真役職を噛みに行く場合は、その位置を残すと黒を引かれる・" "霊媒で破綻する・盤面を固められる、といった GJ リスクを承知で" "勝負する理由を持つ。\n" + "- **前夜の襲撃が GJ で平和な朝になった場合 (= 自分が昨夜噛んだ対象が今朝も生存)、" + "その対象を今夜も同じく噛むのを最優先候補にする。** " + "騎士は連続護衛禁止のため、昨夜守った相手を今夜は守れない。" + "盤面 (CO 構成・吊り筋・白要素) が大きく変わっておらず、その対象の襲撃価値が" + "依然として最高位なら、今夜の同一対象襲撃で確実に決着できる。" + "切り替えるのは、(a) 翌日の議論で対象の襲撃価値が大きく低下した " + "(例: 確白扱いされ吊り対象から外れた)、(b) 別位置で対象を超える緊急襲撃価値の" + "情報役が新規に登場した、(c) 人狼チャットで相方と切替方針を明示的に合意した、" + "のいずれかが揃ったときに限る。" + "「読まれそう」「対称的すぎる」のような漠然とした不安では切り替えない — " + "対象が騎士に2連続では絶対に守れない以上、再噛みは数学的に成立する。\n" "- 騎士候補を噛むのは「騎士探し」として有効で、" "翌日以降に安全に情報役を噛む準備として価値がある。" "ただし噛み筋が露骨な意見噛みや相方を不自然に白くする形に見えないかを" diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 2aeac99..fa29363 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -320,15 +320,19 @@ async def _push_private_state_snapshot( # NPC sees no concrete data to CO with on day 1, even though # the strategy block tells them to declare early — which is # exactly the day-1 silence the live game hit. - seer_results, medium_results, guard_history, wolf_chat_history = ( - await load_private_state_for_seat( - repo, - game_id=game_id, - seat_no=seat_no, - role=me.role, - players=players, - seats=seats, - ) + ( + seer_results, + medium_results, + guard_history, + wolf_chat_history, + wolf_attack_history, + ) = await load_private_state_for_seat( + repo, + game_id=game_id, + seat_no=seat_no, + role=me.role, + players=players, + seats=seats, ) snapshot = build_snapshot_for_seat( npc_id=npc_id, @@ -343,6 +347,7 @@ async def _push_private_state_snapshot( medium_results=medium_results, guard_history=guard_history, wolf_chat_history=wolf_chat_history, + wolf_attack_history=wolf_attack_history, ts=int(asyncio.get_running_loop().time() * 1000), trace_id=f"snapshot-{game_id}-{seat_no}", ) diff --git a/src/wolfbot/master/private_state.py b/src/wolfbot/master/private_state.py index b04f86a..c618eb5 100644 --- a/src/wolfbot/master/private_state.py +++ b/src/wolfbot/master/private_state.py @@ -39,6 +39,7 @@ PrivateStateSnapshot, PrivateStateUpdate, SeerResult, + WolfAttackEntry, WolfChatLine, ) @@ -101,6 +102,7 @@ def build_snapshot_for_seat( medium_results: Sequence[MediumResult] = (), guard_history: Sequence[GuardEntry] = (), wolf_chat_history: Sequence[WolfChatLine] = (), + wolf_attack_history: Sequence[WolfAttackEntry] = (), ts: int, trace_id: str, ) -> PrivateStateSnapshot: @@ -137,6 +139,7 @@ def build_snapshot_for_seat( medium_results=tuple(medium_results), guard_history=tuple(guard_history), wolf_chat_history=tuple(wolf_chat_history), + wolf_attack_history=tuple(wolf_attack_history), ) @@ -185,12 +188,13 @@ async def load_private_state_for_seat( tuple[MediumResult, ...], tuple[GuardEntry, ...], tuple[WolfChatLine, ...], + tuple[WolfAttackEntry, ...], ]: """Rebuild role-specific history for a seat from persisted DB rows. Returns ``(seer_results, medium_results, guard_history, - wolf_chat_history)``. Each tuple is non-empty only when the role - actually owns that history: + wolf_chat_history, wolf_attack_history)``. Each tuple is non-empty + only when the role actually owns that history: * ``seer_results`` — populated for ``Role.SEER`` from SEER_RESULT_NIGHT0 + SEER_RESULT private logs. @@ -202,6 +206,11 @@ async def load_private_state_for_seat( * ``wolf_chat_history`` — populated for ``Role.WEREWOLF`` from WOLF_CHAT private logs (every wolf seat sees them via the ``audience_seat IS NULL`` branch of ``load_private_logs``). + * ``wolf_attack_history`` — populated for ``Role.WEREWOLF`` from + ``night_actions`` (WOLF_ATTACK entries) joined with the MORNING + public log to derive ``peaceful_morning``. Lets wolves detect + "we attacked X last night, X is alive → GJ → re-attack X tonight + because the knight can't guard the same seat twice in a row". Best-effort: a parse failure on one row is logged and skipped, not fatal — a partially populated snapshot is still strictly better @@ -213,6 +222,7 @@ async def load_private_state_for_seat( medium_results: list[MediumResult] = [] wolf_chat_history: list[WolfChatLine] = [] guard_history: list[GuardEntry] = [] + wolf_attack_history: list[WolfAttackEntry] = [] if role in (Role.SEER, Role.MEDIUM): try: @@ -301,6 +311,55 @@ async def load_private_state_for_seat( text=str(r["text"]), ) ) + # Reload public logs to derive `peaceful_morning` per attack. + # Same shape as the knight's guard-history loader below. + try: + public_logs_for_attack = await repo.load_public_logs( + game_id, limit=200, + ) + except Exception: + log.exception( + "private_state_load_failed_public_logs_for_attack game=%s", + game_id, + ) + public_logs_for_attack = [] + attack_morning_by_day: dict[int, bool] = {} + for log_row in public_logs_for_attack: + if log_row.get("kind") == "MORNING": + resolved_day = int(log_row.get("day", 0)) + if resolved_day < 0: + continue + peaceful = "平和な朝" in str(log_row.get("text", "")) + attack_morning_by_day[resolved_day] = peaceful + try: + seen_attack_days: set[int] = set() + for day in range(0, 30): + actions = await repo.load_night_actions(game_id, day=day) + for a in actions: + if ( + a.kind is SubmissionType.WOLF_ATTACK + and a.target_seat is not None + and a.target_seat in seats_by_no + and day not in seen_attack_days + ): + seen_attack_days.add(day) + wolf_attack_history.append( + WolfAttackEntry( + day=day, + target_seat=a.target_seat, + target_name=seats_by_no[ + a.target_seat + ].display_name, + peaceful_morning=attack_morning_by_day.get(day), + ) + ) + if not actions and day > 1: + break + except Exception: + log.exception( + "private_state_load_failed_wolf_attack game=%s seat=%d", + game_id, seat_no, + ) if role is Role.KNIGHT: # Knight's guard targets come from the night_actions table for @@ -314,6 +373,7 @@ async def load_private_state_for_seat( return ( tuple(seer_results), tuple(medium_results), tuple(guard_history), tuple(wolf_chat_history), + tuple(wolf_attack_history), ) try: public_logs = await repo.load_public_logs(game_id, limit=200) @@ -369,6 +429,7 @@ async def load_private_state_for_seat( tuple(medium_results), tuple(guard_history), tuple(wolf_chat_history), + tuple(wolf_attack_history), ) diff --git a/src/wolfbot/npc/decision_service.py b/src/wolfbot/npc/decision_service.py index e9ebc81..6988da9 100644 --- a/src/wolfbot/npc/decision_service.py +++ b/src/wolfbot/npc/decision_service.py @@ -183,6 +183,18 @@ def _cause(seat_no: int) -> str: lines.append( f" day{line.day} {line.speaker_name}: {line.text}" ) + if state.wolf_attack_history: + lines.append("## 自分達の襲撃履歴 (非公開)") + for atk in state.wolf_attack_history: + if atk.peaceful_morning is True: + outcome = "(平和な朝 = GJ)" + elif atk.peaceful_morning is False: + outcome = "(襲撃成功)" + else: + outcome = "(結果未確定)" + lines.append( + f" day{atk.day}: {atk.target_name} を襲撃 {outcome}" + ) return "\n".join(lines) @@ -309,7 +321,15 @@ def build_wolf_chat_prompt( "あなたは人狼で、仲間の人狼にだけ届く秘密チャットでこのターンの " "襲撃方針を簡潔に伝えてください。村人に届く発話ではないので、" "ペルソナの口調を保ちつつ素直に作戦を提示してよい (ただし" - "メタ用語は避ける)。返答は JSON のみ。" + "メタ用語は避ける)。返答は JSON のみ。\n\n" + "**戦術指針 — GJ後の再噛み**: 前夜の襲撃が GJ で平和な朝になった " + "(自分達が昨夜噛んだ対象が今朝も生存している) 場合、" + "騎士の連続護衛禁止により今夜その対象は守られない。" + "盤面が大きく変わっていないなら同じ対象を再噛みするのが原則最善 — " + "数学的に成功が確定する。" + "切り替えるのは、対象の襲撃価値が大きく低下した、別位置に超緊急の情報役が出た、" + "等の具体的根拠があるときに限る。" + "「読まれそう」「対称的すぎる」のような漠然とした不安では切り替えない。" ) user_parts = [ f"## 現在: 人狼チャット (day {state.day_number})", diff --git a/src/wolfbot/npc/game_state.py b/src/wolfbot/npc/game_state.py index 283686b..ab8d833 100644 --- a/src/wolfbot/npc/game_state.py +++ b/src/wolfbot/npc/game_state.py @@ -24,6 +24,7 @@ PrivateStateSnapshot, PrivateStateUpdate, SeerResult, + WolfAttackEntry, WolfChatLine, ) @@ -85,6 +86,7 @@ class NpcGameState: medium_results: list[MediumResult] = field(default_factory=list) guard_history: list[GuardEntry] = field(default_factory=list) wolf_chat_history: list[WolfChatLine] = field(default_factory=list) + wolf_attack_history: list[WolfAttackEntry] = field(default_factory=list) def state_from_snapshot(snapshot: PrivateStateSnapshot) -> NpcGameState: @@ -105,6 +107,7 @@ def state_from_snapshot(snapshot: PrivateStateSnapshot) -> NpcGameState: medium_results=list(snapshot.medium_results), guard_history=list(snapshot.guard_history), wolf_chat_history=list(snapshot.wolf_chat_history), + wolf_attack_history=list(snapshot.wolf_attack_history), ) diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index 9266a26..204ebb8 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -307,6 +307,19 @@ def _cause_tag(seat_no: int) -> str: lines.append( f" day{wc.day} {wc.speaker_name}: {wc.text}" ) + wolf_attack_history = getattr(state, "wolf_attack_history", []) or [] + if wolf_attack_history: + lines.append("## 自分達の襲撃履歴 (非公開)") + for atk in wolf_attack_history: + if atk.peaceful_morning is True: + outcome = "(平和な朝 = GJ)" + elif atk.peaceful_morning is False: + outcome = "(襲撃成功)" + else: + outcome = "(結果未確定)" + lines.append( + f" day{atk.day}: {atk.target_name} を襲撃 {outcome}" + ) if logic.past_votes: # Public vote history. Each NPC saw the EXECUTION public log when # it landed, but the per-phase fold doesn't carry that text into diff --git a/tests/test_master_private_state.py b/tests/test_master_private_state.py index 7248923..e247cb5 100644 --- a/tests/test_master_private_state.py +++ b/tests/test_master_private_state.py @@ -311,12 +311,12 @@ async def test_load_private_state_seer_recovers_night0_random_white( created_at=1, ) ) - seers, mediums, guards, wolves = await load_private_state_for_seat( + seers, mediums, guards, wolves, attacks = await load_private_state_for_seat( fresh_repo, game_id=g.id, seat_no=5, role=Role.SEER, players=await fresh_repo.load_players(g.id), seats=await fresh_repo.load_seats(g.id), ) - assert mediums == () and guards == () and wolves == () + assert mediums == () and guards == () and wolves == () and attacks == () assert len(seers) == 1 assert seers[0].day == 0 assert seers[0].target_seat == 9 @@ -346,7 +346,7 @@ async def test_load_private_state_seer_parses_day1_black_and_white( text="占い結果: NPC3 は 人狼ではありません。", created_at=20, ) ) - seers, _m, _g, _w = await load_private_state_for_seat( + seers, _m, _g, _w, _a = await load_private_state_for_seat( fresh_repo, game_id=g.id, seat_no=5, role=Role.SEER, players=await fresh_repo.load_players(g.id), seats=await fresh_repo.load_seats(g.id), @@ -370,7 +370,7 @@ async def test_load_private_state_wolf_chat_history_for_wolf( visibility="PRIVATE", text="今日は席5を狙うか", created_at=5, ) ) - _s, _m, _gh, wolves = await load_private_state_for_seat( + _s, _m, _gh, wolves, _a = await load_private_state_for_seat( fresh_repo, game_id=g.id, seat_no=1, role=Role.WEREWOLF, players=await fresh_repo.load_players(g.id), seats=await fresh_repo.load_seats(g.id), @@ -401,7 +401,7 @@ async def test_load_private_state_knight_guard_history_with_morning( text="平和な朝です。昨晩の犠牲者はいません。", created_at=200, ) ) - _s, _m, guards, _w = await load_private_state_for_seat( + _s, _m, guards, _w, _a = await load_private_state_for_seat( fresh_repo, game_id=g.id, seat_no=7, role=Role.KNIGHT, players=await fresh_repo.load_players(g.id), seats=await fresh_repo.load_seats(g.id), @@ -410,3 +410,60 @@ async def test_load_private_state_knight_guard_history_with_morning( assert guards[0].day == 1 assert guards[0].target_seat == 5 assert guards[0].peaceful_morning is True + + +async def test_load_private_state_wolf_attack_history_with_morning( + fresh_repo: SqliteRepo, +) -> None: + """Wolves see their own attack history with `peaceful_morning` + derived from the matching MORNING public log so they can detect + "GJ → re-attack the same target tomorrow because the knight can't + guard consecutively". + """ + g = await _seed_game_with_seer(fresh_repo) + await fresh_repo.set_player_role(g.id, 1, Role.WEREWOLF) + await fresh_repo.set_player_role(g.id, 3, Role.WEREWOLF) + # Wolf attack on day 1 — GJ'd by knight (peaceful morning on day 1). + await fresh_repo.insert_night_action( + NightAction( + game_id=g.id, day=1, actor_seat=1, + kind=SubmissionType.WOLF_ATTACK, target_seat=9, + submitted_at=100, + ) + ) + await fresh_repo.insert_log_public( + LogEntry( + game_id=g.id, day=1, phase=Phase.DAY_DISCUSSION, + kind="MORNING", actor_seat=None, visibility="PUBLIC", + text="平和な朝です。昨晩の犠牲者はいません。", created_at=200, + ) + ) + # Wolf attack on day 2 — successful kill. + await fresh_repo.insert_night_action( + NightAction( + game_id=g.id, day=2, actor_seat=1, + kind=SubmissionType.WOLF_ATTACK, target_seat=5, + submitted_at=300, + ) + ) + await fresh_repo.insert_log_public( + LogEntry( + game_id=g.id, day=2, phase=Phase.DAY_DISCUSSION, + kind="MORNING", actor_seat=5, visibility="PUBLIC", + text="朝になりました。犠牲者: NPC5", created_at=400, + ) + ) + + _s, _m, _gh, _w, attacks = await load_private_state_for_seat( + fresh_repo, game_id=g.id, seat_no=1, role=Role.WEREWOLF, + players=await fresh_repo.load_players(g.id), + seats=await fresh_repo.load_seats(g.id), + ) + by_day = {a.day: a for a in attacks} + assert by_day[1].target_seat == 9 + assert by_day[1].peaceful_morning is True, ( + "GJ on day 1 → wolves should see peaceful_morning=True so the " + "next-night re-attack heuristic can fire" + ) + assert by_day[2].target_seat == 5 + assert by_day[2].peaceful_morning is False From ee34b4f078cd63f6d4835776ada5f66569ee74e7 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Thu, 30 Apr 2026 23:07:39 +0900 Subject: [PATCH 075/133] feat(reactive_voice): role-callout pool + arbiter / prompt hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle of CO-flow and stall fixes from today's gameplay analysis. Arbiter / dispatch: - Role-callout priority pool: when pending_role_callouts has seer/medium/ knight, the next dispatch picks from real role-holder + every uncpd wolf-side seat (wolves + madman). Picks are random within the pool so the village can't meta-read "first NPC after a callout = real". Each pool member gets one chance per callout via _callout_pool_asked. - info_request callout (new): generic info-seeking speeches like 「誰か怪しい人?」「みんな意見を聞かせて」now expand the pool to ALL info roles + all uncpd wolf-side. Consumed when any info-role CO arrives. - Cross-phase CO recognition: rebuild_public_state_from_events now seeds seen_co with prior-phase claims, so a day-2 re-CO no longer slips past the volley demotion gate (game a701a7531dca day 2 ジョナス↔ユリコ). - Runoff watchdog no longer pops _pending while playback is active — was the root of game d57c5d83ed4a day 2 stall (jonas TTS 11.6s vs watchdog TTL 8s). - SpeakResult rejection paths now pop _pending + try_dispatch_next so a stale/expired/declined response no longer stalls the whole phase (game eab1f9514a10 day 4 SQ→ユリコ chain break). Analyzers: - text_analyzer / stt_service / voice_ingest now recognize "info_request" as a generic info-seeking signal. Both the Gemini path and the OpenAI-compatible path (xAI / DeepSeek / Groq+xAI) share the schema. - GroqWhisperAudioAnalyzer fixed to actually emit role_callout — it was silently dropping the field for human voice in groq-mode deployments. Prompts (_build_game_rules_block + role strategies): - Day-1 seer claim must name exactly one target ("すべて白" / "全員白" is 破綻 — caught Yuriko's ad-hoc lie in af3d1e5d3fa1). - Death seats are not vote targets ("◯◯ (死亡)を吊ろう" is破綻). - Outlier votes must be re-evaluated as possible real-seer black-reads before being labeled wolfish. - Unannounced ability results now win over addressed-replies — leads with CO+result first, addresses the question after. - Medium uses own results to validate matching seer-CO judgments. - Wolf/madman day-1 戦術 reframed as 先制CO / 対抗CO / 潜伏 three-way comparison — removes the "対抗 CO 不在 → 騙り優先" trigger that produced pure-潜伏 wolves on every game in the audit window. - Plural color claims (「すべて白」「全員白」) explicitly forbidden + day-1 single-target rule. Game export: - viewer/games file naming switched from {game_id}.json to YYYY-MM-DD_HH-MM-SS.json (sortable by play time). Same-second collisions disambiguate with _ suffix; same-game re-exports overwrite. Tests: 1138 passed (up from 1122). New regressions cover all of the above scenarios from real game traces. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/export-game.py | 5 +- src/wolfbot/domain/enums.py | 14 + src/wolfbot/llm/prompt_builder.py | 149 +++- src/wolfbot/main.py | 13 +- src/wolfbot/master/speak_arbiter.py | 217 ++++- src/wolfbot/master/stt_service.py | 32 +- src/wolfbot/master/text_analyzer.py | 27 +- src/wolfbot/master/voice_ingest_service.py | 4 +- src/wolfbot/services/discussion_service.py | 26 +- src/wolfbot/services/game_export.py | 19 +- tests/test_game_export.py | 68 ++ tests/test_llm_prompt_builder.py | 188 ++++- tests/test_llm_service.py | 53 +- tests/test_reactive_voice_master.py | 869 +++++++++++++++++++++ 14 files changed, 1565 insertions(+), 119 deletions(-) diff --git a/scripts/export-game.py b/scripts/export-game.py index f3c8cbb..dbf67aa 100755 --- a/scripts/export-game.py +++ b/scripts/export-game.py @@ -40,7 +40,10 @@ def _parse_args() -> argparse.Namespace: p.add_argument( "--output", default="viewer/games", - help="Output directory. The file is written as {output}/{game_id}.json.", + help=( + "Output directory. The file is written as " + "{output}/YYYY-MM-DD_HH-MM-SS.json (derived from game's created_at)." + ), ) return p.parse_args() diff --git a/src/wolfbot/domain/enums.py b/src/wolfbot/domain/enums.py index 938550b..00c9326 100644 --- a/src/wolfbot/domain/enums.py +++ b/src/wolfbot/domain/enums.py @@ -132,6 +132,20 @@ def role_to_co_claim(role: Role) -> str: ) +# Role-callout values are a SUPERSET of CO_CLAIM_VALUES — specifically +# they include the synthetic ``"info_request"`` token that the speech +# analyzers emit when a speaker generically asks for opinions / role +# holders without naming a specific role. Examples (from real game logs): +# - "誰か怪しい人いる?" +# - "みんな意見を聞かせて" +# - "気になる人を挙げて" +# - "誰か役職持ち、出てきて" +# The arbiter's role-callout priority pool treats ``"info_request"`` as +# "all info roles + all wolf-side" so the village can extract early +# information from a generic prompt, not only role-specific prompts. +ROLE_CALLOUT_VALUES: tuple[str, ...] = (*CO_CLAIM_VALUES, "info_request") + + # Type alias for the wire/storage form. Cannot be derived from # ``CO_CLAIM_VALUES`` because :class:`Literal` requires static values # resolvable by the type-checker — ``Literal[*tuple]`` is not legal diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index 9ffffbe..449c1b2 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -54,6 +54,11 @@ def _build_game_rules_block() -> str: "自分に公開されていない情報を事実として断言してはならない。\n" "- 占い師と霊媒師の判定は、本物の人狼だけを黒と表示する。" "狂人は黒判定されない (白として扱われる)。\n" + "- 占い/霊媒の判定色は黒 (本物の人狼)/白 (本物の人狼ではない) の 2 値のみ。" + "灰・グレー・不明・保留・確定不能など第 3 の色はこの bot のルール上存在せず、" + "いかなる役職 (真/騙りを含む) も第 3 の色を判定として主張してはならない。" + "CO 者が第 3 の色を判定として出した時点で、その CO は破綻として確定扱いし、" + "聞き手側は他のすべての判定材料に優先してその CO を切る根拠にしてよい。\n" "- 霊媒結果の白 (『人狼ではありませんでした』) は、対象が本物の人狼ではないことだけを示す。" "役職名 (占い師・霊媒師・騎士・村人・狂人) までは特定できない。\n" "- 処刑された占い師 CO に霊媒結果で白が出ても、真占い師だった可能性と矛盾しない。" @@ -104,6 +109,28 @@ def _build_game_rules_block() -> str: "仮定・話題提示・他者への言及はどれも CO ではない。\n" "- 疑わしい場合は、公開ログの前後関係、主語、引用や仮定の語尾 (〜なら / 〜について / 〜どう見る)、" "自分自身の宣言か他者への言及かを確認する。判断に迷うときは CO として数えない。\n" + "- 死亡した席は、過去の発言・投票・判定の信用評価対象としては引き続き議論してよいが、" + "今日の処刑対象 (vote target) としては議題に含めない。" + "今日の vote 候補は生存席だけ。死者の信用議論を尽くすこと自体は構わないが、" + "発言の結論を『死者を吊ろう』『今日◯◯ (死亡席) を処刑すべき』のように" + "死者を今日の処刑対象として語るのは破綻発言として扱う。\n" + "- 異端票 (例: 多数派と違う対象に day 1 で投票している席) を狼疑いとして数える前に、" + "その投票者が真占い視点で動いていた場合に整合するかを必ず一度確認する。" + "真占いは NIGHT_0 ランダム白で得た情報を持ち、自分視点の黒読み・灰読みで" + "多数派と違う票を入れる動機が自然に発生する。" + "後日その異端票の対象が黒判定されて処刑され、霊媒結果でも黒が出た場合、" + "その異端票は『真占い視点での黒読み先制』として説明される側に強く寄るため、" + "異端票そのものを狼の異常行動として扱うのは誤読になる。" + "票の異端さだけで疑うのではなく、判定履歴・霊媒結果・噛み筋との整合性で再評価する。\n" + "- 自分が役職持ちでまだ未公開の能力結果 (霊媒結果・占い結果・護衛日記など) を抱えている場合、" + "発言の番が回ってきたとき、その発話の冒頭で必ず CO + 結果公表を行う。" + "他者から呼びかけられて (addressed) いてその質問に答えたい場合でも、" + "未公開の能力結果が手元にあるならその公表を最優先にし、" + "addressed への返答は CO + 結果の後に短く添える。" + "addressed 文脈に飲まれて CO + 結果を欠落させると、" + "聞き手側 NPC は構造化フィールド (`co_declaration`) しか見られず" + "公開ログには CO の自然言語が一切残らないため、" + "他席は『自分が CO した』ことを認識できない。\n" "- 占い師・霊媒師・騎士の各 CO 数を時系列で整理し、各役職について『CO 数 - 1』(下限 0) を「対抗 CO 超過分」(騙り最低数) として数える。" "真役職は各 1 人だけのため、超過分はその役職で少なくとも騙りである。\n" "- 占い師 CO 超過分 + 霊媒師 CO 超過分 + 騎士 CO 超過分 の超過分合計が 3 に達した場合、" @@ -192,12 +219,31 @@ def _build_game_rules_block() -> str: "- day 1 の朝に偽占い師として初回結果を黒と主張するのは、" "この bot の実ルール上の NIGHT_0 タイムラインと矛盾するため破綻要素として扱われる。" "day 1 で初回黒主張はしない。\n" + "- 占い結果・霊媒結果を発言で出すときは、**対象席名 + 判定色 (黒/白) を必ず一対一で添える**。" + "例: 『セツさんは白でした』『コメットを占って黒、ジナを占って白』のように、" + "各対象を名指しで列挙する。" + "**対象を特定せずに『すべて白』『全員白』『全部白』『みんな白』のように" + "複数件をまとめて主張するのは破綻発言**として扱う。" + "聞き手はそうした主張をした CO を破綻確定として切ってよい。\n" + "- 特に day 1 朝の占い師 CO は、NIGHT_0 のランダム白 1 件しか手元にない。" + "**day 1 の占い結果は必ず対象 1 名 + 白判定の 1 件のみ**で、" + "複数対象の主張や『すべて白』のような対象不明な白主張は実ルール上ありえないため、" + "その時点で CO 破綻として扱う。\n" "- 偽占い師の黒結果主張は day 2 以降にのみ行う。" "前夜に占ったという想定で、公開ログ・生存状況・過去に自分が出した判定履歴・" "対抗 CO の発表内容と矛盾しない場合だけ出す。\n" "- 偽霊媒師は、処刑がなかった日に霊媒結果を捏造しない。\n" "- 偽騎士は、自分護衛・同一対象連続護衛・死亡済み対象への護衛・存在しない護衛成功を主張しない。\n" "- どの偽 CO でも、実際には知らない他者役職や狼位置を事実として断言しない。\n" + "- 自分が占い師/霊媒師として一度公表した判定 (対象 + 黒/白) は、" + "原則として後日撤回・色変更・対象差し替えをしない。" + "前夜の能力使用結果として一度言い切った内容は、その後の発言でも同じ対象を同じ色で扱う。" + "打ち間違いに気づいた場合のみ、『訂正』『失礼、◯◯でした』などの訂正文言を明示してから直し、" + "訂正後の内容を以降の発言でも保持する。\n" + "- 同じ対象への判定色が前日と当日で食い違った CO を見つけた場合、" + "人間プレイヤーの言い間違い・打ち間違いの可能性が残るため即座に偽 CO 確定とはしない。" + "ただし強い偽要素として推理に組み入れ、明示的な訂正文言なしに食い違いが続く場合は、" + "他の偽要素 (対抗 CO・票筋・噛み筋・破綻) と合わせて切ってよい材料として扱う。\n" "- day 2 以降に占い師・霊媒師・騎士として CO 中の者は、真でも偽でも、" "昼の議論 1 巡目で前夜相当の能力結果を出すのが信用上重要である。" "結果を持つはずの役職 CO が 1 巡目で結果を出さないと、信用低下や破綻疑いにつながる。\n" @@ -288,6 +334,10 @@ def _build_game_rules_block() -> str: "- 実際の発言には、上のチェックリストから今の結論に最も効く 1〜2 点だけを根拠として出す。" "用語だけで押し切らず、誰のどの発言・票・判定を見たのかを短く添える。" "長い内部思考そのものを発話しない。\n" + "- 比較・関係を表す語 (重複・ライン・出来レース・囲い・身内切り・対立・連携) を使うときは、" + "比較対象を必ず明示する。誰の何の発言・判定・投票・夜行動が、誰の何と『重複/ライン』しているのかを " + "1 件以上具体的に引用する。引用なく関係語だけを並べた主張は推理上の根拠として扱わず、" + "聞き手側は内容を再確認する。\n" "- この村は人狼 2 人固定なので、怪しい人を 1 人挙げたら、その人物が人狼ならもう 1 人の相方候補は誰かまで" "公開ログからの仮説として考える。" "村側・狂人・確定していない役職は実際の 2 人狼ペアを知らないため、断定ではなく推理として扱う。\n" @@ -321,16 +371,27 @@ def _build_game_rules_block() -> str: "- 占い師・霊媒師などの情報役、信頼されている位置、盤面整理を主導する相手を" "優先的に脅威として評価する。\n" "- 人狼は勝利に必要な本体であり生存価値が高い。騙りに出るか潜伏するかは盤面で選ぶ。\n" - "- day 1 の占い師騙りは強い選択肢だが、無条件の既定行動ではない。" - "狂人らしい騙りが既に出ている、CO 数が増えすぎている、相方が危険位置にいる場合は、" - "潜伏して発言・投票・噛み筋で白さを取る方がよいことがある。\n" - "- ただし公開ログで占い師・霊媒師・騎士の名乗り出が呼びかけられている " - "(`未回答の役職呼びかけ` / `pending_role_callouts` に該当役職がある) のに " - "対抗 CO がまだ出ていない場合、原則として騙りに出る方を優先する。" - "単独 CO を放置すると村は『単独 CO = 真寄り』として真占いを真置きしやすくなり、" - "狼陣営は判定情報を打ち返す手段を失う。" - "潜伏を選ぶのは、相方が既に危険位置で吊られそう、対抗を出すと 4-1 以上に膨らんでローラー必至、" - "等の具体的かつ強い理由がある場合に限る。漠然とした『村が勝手に吊ってくれそう』では潜伏しない。\n" + "- day 1 の戦術選択は **先制CO/対抗CO/潜伏** の 3 択。3 つはどれも等しく強い第一級の候補で、" + "盤面・自席発言順・公開ログの先行発言・相方位置・既出 CO 数・縄構造から毎回 3 択全てを俎上に載せて比較する。" + "特定の 1 択に機械的に流れない。" + "特に『対抗 CO がまだ出ていないから自動的に騙りに出る』『誰かが先に騙ってくれるから自分は潜伏する』" + "のような単一条件での機械的判断は避ける。\n" + "- 先制CO は、占い師 CO がまだ誰からも出ていない day 1 朝の段階で、自分から最初の占い師CO を出して盤面を取る選択肢。" + "強い局面: 自席発言順が早く朝一の発言枠を取りやすい/灰候補が少なく潜伏で自然白を取りにくい/" + "真占い CO が出る前に盤面を取り、後発の真占い CO を偽扱いに誘導したい/相方位置から見て自分の白先選択が活きる。" + "**day 1 朝に自分の発言の番が回ってきた瞬間で、まだ占い師 CO が誰からも出ていないなら、それが先制 CO の最大の好機である。" + "後の番に持ち越すと真占いが先に出て先制の機会を失う。**\n" + "- 対抗CO は、真占い CO (または別の騙り CO) がすでに出た後に、自分も占い師 CO で並んで真偽比較に持ち込む選択肢。" + "強い局面: 真占い CO の白先や黒先が相方を含んで囲い継続が容易/自分が後発で潜伏すると村に押されそう/" + "対抗 CO 群を膨張させて灰確白を 1〜2 名に絞らせたい。\n" + "- 潜伏は、占い師騙りに出ず、霊媒師騙り・騎士騙り・完全潜伏のいずれかで灰位置の自然白を取りに行く選択肢。" + "強い局面: 占い師 CO が既に 3 人以上出ていて対抗を出すと 4-1 以上でローラー必至/" + "相方が即吊り危険位置で発言・投票で守る価値が高い/灰位置で自然白を取れる発言力がある。\n" + "- 潜伏は安全策ではない。潜伏を選ぶと真占い CO が単独 CO として確真置きされやすく、" + "村は判定情報を全て握って day 2 以降の縄効率で狼陣営を追い込める。" + "潜伏を選ぶには『対抗を出すと即破綻する』『灰位置で自然白を確実に取れる』『相方を発言で守れる』" + "のような潜伏の方が勝ち筋として強いことを示す具体根拠が必要で、" + "『他が騙ってくれそう』『真占いに信用を与えてしまう』のような曖昧な理由では潜伏しない。\n" "- 黒出しは真占い師・真霊媒師・真騎士に当てると破綻や対抗 CO のリスクがある。" "吊りやすさだけでなく翌日の霊媒結果・投票・噛み筋まで考えて出す。\n" "- day 1 に占い師騙りを選ぶ場合、初回の占い結果は NIGHT_0 ランダム白に合わせて必ず白を主張する。" @@ -452,16 +513,28 @@ def _build_game_rules_block() -> str: "破綻しない範囲に留める。\n" "- 知り得ない確定情報 (夜行動の内訳・他プレイヤーの属性など) を事実として断言しない。\n" "- 真占い・真霊媒に疑いを向け、村陣営の情報整理を妨げる方向に投票や発言を運ぶ。\n" - "- 占い師騙りは狂人の強い基本候補で、day 1 に検討する価値が高い。" - "ただし無条件ではなく、既に複数の占い師 CO が出ている、CO 数が膨らみすぎる、" - "盤面的に潜伏して混乱させた方が得な場合はその限りではない。\n" - "- 公開ログで占い師・霊媒師・騎士の名乗り出が呼びかけられている " - "(`未回答の役職呼びかけ` / `pending_role_callouts` に該当役職がある) のに " - "対抗 CO がまだ出ていない場合、原則として騙りに出る方を優先する。" - "単独 CO を放置すると村は『単独 CO = 真寄り』として真役職を真置きしやすく、" - "狂人としても判定情報を撹乱する手段を失う。" - "潜伏を選ぶのは、CO 群が既に過密で誤爆リスクが上回る、等の具体的かつ強い理由がある場合に限る。" - "漠然とした『村が勝手に吊ってくれそう』では潜伏しない。\n" + "- day 1 の戦術選択は **先制CO/対抗CO/潜伏** の 3 択。3 つはどれも等しく強い第一級の候補で、" + "盤面・自席発言順・公開ログの先行発言・既出 CO 数・縄構造から毎回 3 択全てを俎上に載せて比較する。" + "特定の 1 択に機械的に流れない。" + "特に『対抗 CO がまだ出ていないから自動的に騙りに出る』『誰かが先に騙ってくれるから自分は潜伏する』" + "のような単一条件での機械的判断は避ける。\n" + "- 先制CO は、占い師 CO がまだ誰からも出ていない day 1 朝の段階で、自分から最初の占い師 CO を出して盤面を取る選択肢。" + "強い局面: 自席発言順が早く朝一の発言枠を取りやすい/灰候補が少なく潜伏で自然白を取りにくい/" + "真占い CO が出る前に盤面を取り、後発の真占い CO を偽扱いに誘導したい。" + "狂人は本物の狼位置を知らないため、先制で出す白先は灰の中から選び、特定位置の囲いを意図しない形で出す。" + "**day 1 朝に自分の発言の番が回ってきた瞬間で、まだ占い師 CO が誰からも出ていないなら、それが先制 CO の最大の好機である。" + "後の番に持ち越すと真占いが先に出て先制の機会を失う。**\n" + "- 対抗CO は、真占い CO (または別の騙り CO) がすでに出た後に、自分も占い師 CO で並んで真偽比較に持ち込む選択肢。" + "強い局面: 自分が後発で潜伏すると村に押されそう/対抗 CO 群を膨張させて灰確白を絞らせたい/" + "真占い CO の白先と異なる位置に白を出すことで真占いの信用を分散できる。\n" + "- 潜伏は、占い師騙りに出ず、霊媒師騙り・騎士騙り・完全潜伏のいずれかで灰位置の自然白を取りに行く選択肢。" + "強い局面: 占い師 CO が既に 3 人以上出ていて対抗を出すと 4-1 以上でローラー必至/" + "灰位置で発言と投票から自然白を取れる発言力がある/騙り CO の誤爆リスクが具体的に高い盤面。\n" + "- 潜伏は安全策ではない。潜伏を選ぶと真占い CO が単独 CO として確真置きされやすく、" + "村は判定情報を全て握って day 2 以降の縄効率で狼陣営を追い込める。" + "潜伏を選ぶには『対抗を出すと即破綻する』『灰位置で自然白を確実に取れる』のような" + "潜伏の方が勝ち筋として強いことを示す具体根拠が必要で、" + "『他が騙ってくれそう』『真占いに信用を与えてしまう』のような曖昧な理由では潜伏しない。\n" "- day 1 に占い師騙りを選ぶ場合、初回の占い結果は NIGHT_0 ランダム白に合わせて必ず白を主張する。" "初日に黒を出す主張はこの bot の実ルール上の時系列と矛盾し破綻するため、絶対にしない。\n" "- 黒出しは day 2 以降にだけ検討する。前夜に占ったという想定で、" @@ -534,12 +607,21 @@ def _build_game_rules_block() -> str: "完全な村置きとしては扱わない。\n" "- CO タイミング・対抗 CO の有無・投票と判定の噛み合いを重視し、" "偽占い視点の破綻を探す。\n" - "- **day 1 の議論中盤までに占い師 CO が一切出ていない場合は、原則として CO する。**" - "初日ランダム白と以後の占い結果を時系列で出すこと。" + "- **day 1 で自分にまだ発言の番が来ていなかった場合、" + "次に自分が発言する番が回ってきたとき、その発話で必ず占い師 CO + 初日ランダム白を発表する。**" + "発話のチャンスは毎ターン回ってくるとは限らないため、回ってきた瞬間を CO に充てる。" + "次の自分のターンに先送りしない。" "真占いが沈黙し続けると、偽 CO を単独真として扱わせてしまうし、" "占い結果が活かせないまま村が情報不足で負ける。" "潜伏を選ぶのは「狂人らしい騙りが既に出ている」「相方候補の偽 CO を釣り出す具体的な計画がある」など" "明確な理由があるときだけにし、漠然とした様子見では潜伏しない。\n" + "- 自分の発言の番が回ってきたときに他席から呼びかけられている (addressed) 状態でも、" + "未公開の能力結果 (NIGHT_0 ランダム白を含む) を抱えているなら、" + "**まず CO + 結果公表を発話の冒頭で行い、addressed への返答はその後に短く添える**。" + "addressed の文脈につられて『その質問に答えるだけ』の発話を出すと、" + "`co_declaration` フィールドだけ立っても自然言語側に CO の名乗りが残らないため、" + "他席は自分の CO を一切認識できない。" + "発話の最初に必ず『実は私、占い師なんだ』のような名乗りと結果を明示する。\n" "- 偽占い師 CO が出た場合は、原則として早めに対抗 CO し、" "初日ランダム白を含む全判定履歴を時系列で公開する。潜伏を続けるなら理由が必要。\n" "- 黒を引いた場合は、CO して黒結果・過去の白結果・投票理由を明示し、" @@ -579,9 +661,19 @@ def _build_game_rules_block() -> str: "- 自分の霊媒結果が占い視点に与える影響 (真占い補強、偽占い否定など) を整理して発言する。\n" "- 処刑された相手が狂人でも、霊媒結果は『人狼ではありませんでした』になる。" "黒になるのは本物の人狼だけで、白結果だけでは村置き確定にはならない。\n" - "- **処刑が発生した翌日は、原則として早めに霊媒師 CO を出して結果を公開する。**" - "沈黙すると偽霊媒 CO を単独真として扱わせてしまい、判定情報も活かせない。" + "- **前日に処刑があった day で自分にまだ発言の番が来ていなかった場合、" + "次に自分が発言する番が回ってきたとき、その発話で必ず霊媒師 CO + 前日処刑者への結果を発表する。**" + "発話のチャンスは毎ターン回ってくるとは限らないため、回ってきた瞬間を結果公表に充てる。" + "次の自分のターンに先送りしない。" + "沈黙し続けると偽霊媒 CO を単独真として扱わせてしまい、判定情報も活かせない。" "潜伏を選ぶのは、占い師 CO の真贋整理を先に進めたい等の具体的理由があるときだけにする。\n" + "- 自分の発言の番が回ってきたときに他席から呼びかけられている (addressed) 状態でも、" + "未公開の霊媒結果を抱えているなら、" + "**まず CO + 結果公表を発話の冒頭で行い、addressed への返答はその後に短く添える**。" + "addressed の文脈につられて『その質問に答えるだけ』の発話を出すと、" + "`co_declaration` フィールドだけ立っても自然言語側に CO の名乗りが残らないため、" + "他席は自分の CO を一切認識できない。" + "発話の最初に必ず『実は私、霊媒師だ』のような名乗りと前日処刑者への結果を明示する。\n" "- day 1 (まだ処刑前) は霊媒結果がないので CO を急ぐ必要はないが、" "占い師 CO の整理が進んだ後の発言で「霊媒の見立て」を示すことで信頼を取りやすくなる。" "完全な潜伏ではなく、能動的な意見を出して立ち位置を作る。\n" @@ -597,6 +689,15 @@ def _build_game_rules_block() -> str: "狼がどこを吊りたかったかを 2 人狼仮説として整理する。\n" "- 占い師 CO 処刑後の霊媒結果は、占い真偽の判定材料に使うだけでなく、" "残った狼候補と相方候補のペア整理にも使う。\n" + "- 自分の霊媒結果と、生存中または死亡した占い師 CO の判定が一致した場合、" + "その占い師 CO を真寄りに置く。" + "例: 占い師 CO X が day N に対象 Y を黒判定 → Y が処刑された → 自分の霊媒で Y が黒。" + "この場合、X の判定は実態と一致しているので X は真占いに強く寄る。" + "他席が議論の流れで X を偽呼ばわりしているときでも、流れに乗って X を吊り対象に挙げない。" + "自分の霊媒結果は他席が知らない自分視点の強い情報なので、" + "発言で必ずこの整合を明示し、X を真寄りに置く根拠を示す。" + "自分の能力結果と他 CO 者の判定が食い違う場合に限り、その CO 者を偽寄りに置く根拠が立つ。" + "食い違いがないのに流れに乗って真 CO 者を切ると、村陣営が真情報を失う。\n" "- 占い師 CO が 3 人並んだ盤面では、自分の霊媒結果と公開ログから 2 人を非狼確定にできるかを毎日整理する。" "霊媒白だけで非狼確定として数えてよいのは、自分の霊媒 CO 側が真寄りと十分読める段階であり、" "霊媒結果以外の整合 (襲撃死・CO 破綻・判定矛盾) も合わせて説明できる場合である。" diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index fa29363..538660c 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -625,12 +625,13 @@ async def _on_game_end_finalize(game_id: str) -> None: """Export the finished/aborted game to viewer-compatible JSON. Joins SQLite + ``logs/llm_calls/{game_id}/*.jsonl`` into one file - under ``viewer/games/{game_id}.json``. The viewer auto-discovers - the most-recent file in that directory, so a finished game can - be reviewed by simply running ``cd viewer && pnpm dev`` — no - separate export step or env var. Errors here MUST NOT prevent - end-of-game cleanup; ``GameService._run_finalize_hook`` already - wraps this in try/except. + under ``viewer/games/YYYY-MM-DD_HH-MM-SS.json`` (timestamp prefix + from the game's created_at, sortable by play time). The viewer + auto-discovers the most-recent file in that directory, so a + finished game can be reviewed by simply running + ``cd viewer && pnpm dev`` — no separate export step or env var. + Errors here MUST NOT prevent end-of-game cleanup; + ``GameService._run_finalize_hook`` already wraps this in try/except. """ from wolfbot.services.game_export import export_game diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index 6aa3670..6315c14 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -214,6 +214,13 @@ def __init__( # arrives (or vad_finalization_timeout_ms elapses). # segment_id → deadline_ms self._pending_stt_segments: dict[str, int] = {} + # Per-game tracker of seats already dispatched from the current + # role-callout priority pool. Reset when `pending_role_callouts` + # becomes empty for that game (= the callout was consumed by a + # matching CO). Without this set the picker would loop on the + # same pool member when others decline; with it, each pool + # member gets at most one chance per callout. + self._callout_pool_asked: dict[str, set[int]] = {} # ------------------------------------------------------------- gates @@ -584,38 +591,43 @@ async def _record_rejection(reason: str) -> None: if pending is None: await _record_rejection("unknown_request") return (False, "unknown_request") - if result.phase_id != current_phase_id: - await _record_rejection("stale_phase") + + async def _reject_and_advance(reason: str) -> tuple[bool, str | None]: + """Common rejection cleanup: record + mark runoff (no-op outside + runoff) + pop _pending + re-dispatch. + + Without the pop + re-dispatch, an `expired_request` rejection + in DAY_DISCUSSION leaves the phase silently stalled — game + eab1f9514a10 day 4: SQ finished, ユリコ was dispatched, took + 10s to respond (TTL=8s), got `expired_request` rejected, and + the day went 2.5 minutes without a single follow-up dispatch + because no one was triggering try_dispatch_next. + """ + await _record_rejection(reason) await self._mark_runoff_done_if_phase( game_id=pending.game_id, phase_id=result.phase_id, seat_no=pending.seat_no, ) - return (False, "stale_phase") + self._pending.pop(result.request_id, None) + try: + await self.try_dispatch_next(pending.game_id) + except Exception: + log.exception( + "speak_result_rejection_redispatch_failed game=%s reason=%s", + pending.game_id, + reason, + ) + return (False, reason) + + if result.phase_id != current_phase_id: + return await _reject_and_advance("stale_phase") if now > pending.expires_at_ms: - await _record_rejection("expired_request") - await self._mark_runoff_done_if_phase( - game_id=pending.game_id, - phase_id=result.phase_id, - seat_no=pending.seat_no, - ) - return (False, "expired_request") + return await _reject_and_advance("expired_request") if result.status != "accepted" or not result.text: - await _record_rejection("speaker_declined") - await self._mark_runoff_done_if_phase( - game_id=pending.game_id, - phase_id=result.phase_id, - seat_no=pending.seat_no, - ) - return (False, "speaker_declined") + return await _reject_and_advance("speaker_declined") if len(result.text) > self.config.max_chars_reactive: - await _record_rejection("utterance_too_long") - await self._mark_runoff_done_if_phase( - game_id=pending.game_id, - phase_id=result.phase_id, - seat_no=pending.seat_no, - ) - return (False, "utterance_too_long") + return await _reject_and_advance("utterance_too_long") # Accepted. Persist result + SpeechEvent + open playback row. await self.repo.insert_npc_speak_result( @@ -862,15 +874,35 @@ async def try_dispatch_next(self, game_id: str) -> None: demoted = _compute_demoted_seats(state.recent_speech_summary) online = self.registry.all_online() - def _pick_key(e: object) -> tuple[int, int, int, int, float]: + # Role-callout priority pool: when someone publicly asks for a + # seer/medium CO and that role hasn't been claimed yet, prioritize + # the real role-holder + every wolf-side seat that hasn't CO'd as + # any info role. Picks are random within the pool so the village + # can't meta-read "first NPC to speak after a callout = real". + # Each pool member gets at most one chance per callout (tracked + # via `_callout_pool_asked`); if everyone declines the pool + # exhausts and normal rotation resumes. + callout_pool = await self._compute_callout_pool(game_id, state) + asked = self._callout_pool_asked.get(game_id, set()) + if not state.pending_role_callouts and asked: + # Callout was consumed (matching CO arrived). Reset asked. + self._callout_pool_asked.pop(game_id, None) + asked = set() + effective_pool = callout_pool - asked + + def _pick_key(e: object) -> tuple[int, int, int, int, int, float]: seat = getattr(e, "assigned_seat", None) or 99 + is_in_pool = 0 if seat in effective_pool else 1 is_demoted = 1 if seat in demoted else 0 is_addressed = 0 if seat in addressed_set else 1 count = state.speech_counts.get(seat, 0) is_just_spoke = 1 if ( last_speaker is not None and seat == last_speaker ) else 0 - return (is_demoted, is_addressed, count, is_just_spoke, self._rng.random()) + return ( + is_in_pool, is_demoted, is_addressed, count, + is_just_spoke, self._rng.random(), + ) online_npc_seats = sorted( e.assigned_seat @@ -910,7 +942,12 @@ def _pick_key(e: object) -> tuple[int, int, int, int, float]: continue seat = entry.assigned_seat picked_count = state.speech_counts.get(seat, 0) - if seat in demoted: + if seat in effective_pool: + # Role-callout pool member won — record so future picks + # in this same callout window pick a different member. + self._callout_pool_asked.setdefault(game_id, set()).add(seat) + reason = "role_callout_pool" + elif seat in demoted: # Reached this branch only when EVERY non-demoted # candidate was filtered out (offline / dead / not in # this game). Falling back is preferable to silence. @@ -1107,7 +1144,19 @@ async def _watchdog() -> None: except asyncio.CancelledError: return if request_id not in self._pending: - return # SpeakResult already resolved this slot. + return # SpeakResult already resolved AND playback finished. + # SpeakResult acceptance moves the request into _active_playback + # but does NOT pop _pending (handle_playback_finished does that + # when playback ends). If playback runs longer than ttl_s + # (typical NPC TTS is 10-15s vs the 8s TTL), the watchdog wakes + # while _pending still has the entry — but the NPC HAS responded. + # Without this check the watchdog spuriously pops _pending, + # which then makes `_on_playback_finished` lose the game_id + # lookup → try_dispatch_next is never called → runoff stalls + # (game d57c5d83ed4a day 2: ジョナス 11.6s playback shadowed + # the 8s watchdog and シゲミチ was never dispatched). + if request_id in self._active_playback: + return # Accepted; playback handler will re-dispatch on finish. log.info( "runoff_request_watchdog_fired game=%s seat=%d request=%s", game_id, @@ -1140,6 +1189,90 @@ def _find_npc_for_seat( return entry return None + async def _compute_callout_pool( + self, game_id: str, state: PublicDiscussionState + ) -> frozenset[int]: + """Return seats that should be prioritized when a role callout is + pending and unanswered. + + Triggered by ``state.pending_role_callouts``. Three flavors: + + - ``"seer"`` / ``"medium"`` / ``"knight"`` — specific role call + (e.g. 「占い師は?」). Pool = real role-holder (alive, uncpd) + + every wolf-side seat (人狼/狂人, alive, no info CO yet). + - ``"info_request"`` — generic info-seeking (「誰か怪しい人?」 + 「みんな意見を聞かせて」「気になる人を挙げて」). Pool = ALL + real info-role holders (seer + medium + knight, alive, uncpd) + + every wolf-side seat (alive, no info CO yet). + + The arbiter prioritizes pool members (random within pool). Each + member gets at most one chance per callout via the + ``_callout_pool_asked`` tracker; declines fall through to other + pool members until the pool is exhausted. + """ + if not state.pending_role_callouts: + return frozenset() + callout_to_role = { + "seer": Role.SEER, + "medium": Role.MEDIUM, + "knight": Role.KNIGHT, + } + # Resolve the set of real roles that should be in the pool. + # `info_request` expands to every info role. + target_roles: set[Role] = set() + has_info_request = "info_request" in state.pending_role_callouts + if has_info_request: + target_roles |= {Role.SEER, Role.MEDIUM, Role.KNIGHT} + for callout_key, role in callout_to_role.items(): + if callout_key in state.pending_role_callouts: + target_roles.add(role) + if not target_roles: + return frozenset() + try: + players = await self.repo.load_players(game_id) + seats = await self.repo.load_seats(game_id) + except Exception: + log.exception("callout_pool_load_failed game=%s", game_id) + return frozenset() + seats_by_no: dict[int, Seat] = {s.seat_no: s for s in seats} + co_keys: set[tuple[int, str]] = { + (c.seat, c.role_claim) for c in state.co_claims + } + pool: set[int] = set() + for player in players: + if not player.alive: + continue + if player.seat_no not in state.alive_seat_nos: + continue + seat = seats_by_no.get(player.seat_no) + if seat is None or not seat.is_llm: + continue + # Real role-holder for any target role, not yet CO'd as that + # role. (For info_request, all three info roles are targets.) + if player.role in target_roles: + role_callout_key = next( + ( + k for k, r in callout_to_role.items() + if r is player.role + ), + None, + ) + if ( + role_callout_key is not None + and (player.seat_no, role_callout_key) not in co_keys + ): + pool.add(player.seat_no) + # Wolf-side seats not yet CO'd as any info role can fake any + # of the requested roles, so prioritize them too. + if player.role in (Role.WEREWOLF, Role.MADMAN): + already_co_info = any( + (player.seat_no, role) in co_keys + for role in ("seer", "medium", "knight") + ) + if not already_co_info: + pool.add(player.seat_no) + return frozenset(pool) + def _maybe_wake_runoff(self, game_id: str) -> None: if self._runoff_wake is None: return @@ -1206,6 +1339,7 @@ def cleanup_game(self, game_id: str) -> int: self._active_playback.discard(rid) self._playback_deadlines.pop(rid, None) swept += 1 + self._callout_pool_asked.pop(game_id, None) if swept: log.info( "speak_arbiter_cleanup_game game=%s swept=%d", game_id, swept, @@ -1269,14 +1403,35 @@ async def rebuild_public_state( phase_id = make_phase_id(game_id, day, phase) events: Sequence[SpeechEvent] = await self.discussion.load_phase(game_id, phase_id) - state = rebuild_public_state_from_events(events) - if state is None: - return None + # Pull game-wide CO history so the per-phase rebuild's volley-demotion + # signal (`is_new_co`) treats day-N re-assertions of a day-(N-1) CO + # as not-new. Without this seed, ジョナス re-CO'ing seer on day 2 + # makes every utterance look like fresh info, suppressing the + # `_PAIR_VOLLEY_WINDOW` demotion gate (game a701a7531dca day 2). + # Restricted to events outside the current phase so a seat's + # FIRST in-phase CO still counts as new info (test_try_dispatch_next + # _lru_when_speech_counts_tied depends on this). try: all_events = await self.discussion.load_for_game(game_id) except Exception: log.exception("co_claim_history_load_failed game=%s", game_id) all_events = () + prior_phase_events = tuple( + e for e in all_events if e.phase_id != phase_id + ) + prior_co_claims = ( + extract_co_claims_from_events(prior_phase_events) + if prior_phase_events + else () + ) + prior_co_keys = frozenset( + (c.seat, c.role_claim) for c in prior_co_claims + ) + state = rebuild_public_state_from_events(events, prior_co_keys=prior_co_keys) + if state is None: + return None + # state.co_claims should reflect game-wide CO history (used by NPC + # prompts on day 2+ to remember day-1 declarations). if all_events: state.co_claims = extract_co_claims_from_events(all_events) return state diff --git a/src/wolfbot/master/stt_service.py b/src/wolfbot/master/stt_service.py index b4b93e4..3de2ae1 100644 --- a/src/wolfbot/master/stt_service.py +++ b/src/wolfbot/master/stt_service.py @@ -23,6 +23,7 @@ from wolfbot.domain.enums import ( CO_CLAIM_VALUES, + ROLE_CALLOUT_VALUES, VILLAGE_SIZE, format_co_claim_options, ) @@ -349,12 +350,14 @@ class GeminiAudioAnalyzer: "- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 \"セツ\"、\"ジーナさん\"、\"席3\"、\"3番\")、なければ null。" "「みんな」「全員」など全体への呼びかけは null。さん/くん/ちゃん 等の敬称は付けたままでも構わない。\n" "- addressed_seat_no: 上の addressed_name と同じ人物の席番号(整数)、または null。roster が与えられているときは必ず埋める。\n" - "- role_callout: 特定の役職に名乗り出を求める呼びかけがあれば " - "\"seer\"/\"medium\"/\"knight\" のいずれか、なければ null。" - "例: 「占い師の方は名乗り出てください」「霊媒師いますか?」「騎士は誰?」" - "「占いCO お願いします」 → 該当役職を返す。" - "ただし役職名を単に話題にしただけ (例: 「占い師の判定が気になる」「霊媒師の信用は?」) は呼びかけではないので null。" - "明確な「出てきて/名乗って/CO して/いますか」のような請求が含まれる場合のみ設定する。\n" + "- role_callout: 役職への名乗り出を求める呼びかけ、または一般的な情報請求があれば " + "\"seer\"/\"medium\"/\"knight\"/\"info_request\" のいずれか、なければ null。" + "特定役職を名指しした呼びかけ (例「占い師の方は名乗り出てください」「霊媒師いますか?」「騎士は誰?」「占いCO お願いします」) → 該当役職。" + "役職を限定しない一般的な情報請求 (例「誰か怪しい人いる?」「みんな意見を聞かせて」「気になる人を挙げて」" + "「誰か役職持ち出てきて」「みんなどう思う?」「初日だけど何か情報ない?」) → \"info_request\"。" + "ただし役職名を単に話題にしただけ (例「占い師の判定が気になる」「霊媒師の信用は?」) は null。" + "個人への質問 (例「セツさん、どう思う?」) は addressed_name 側で扱い、role_callout は null。" + "全員/全体への問いかけで意見・情報・怪しい相手・役職持ちを求めているときに \"info_request\" を立てる。\n" "\n音声が不明瞭な場合は confidence を低くし、transcript は聞き取れた範囲で。" ) @@ -475,7 +478,7 @@ async def transcribe( callout_raw = parsed.get("role_callout") role_callout = ( - callout_raw if callout_raw in CO_CLAIM_VALUES else None + callout_raw if callout_raw in ROLE_CALLOUT_VALUES else None ) # Estimate duration from audio size (assume 16kHz 16-bit mono WAV) @@ -572,6 +575,7 @@ class GroqWhisperAudioAnalyzer: "他の文字は含めないでください。\n\n" "{\n" ' "summary": "1文の要約(30文字以内)",\n' + ' "role_callout": null,\n' ' "co_claim": null,\n' ' "vote_target_seat": null,\n' ' "stance": {},\n' @@ -585,7 +589,14 @@ class GroqWhisperAudioAnalyzer: "- stance: 言及した席への態度 {\"席番号\": \"positive\"/\"negative\"/\"neutral\"}\n" "- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 \"セツ\"、\"ジーナさん\"、\"席3\"、\"3番\")、なければ null。" "「みんな」「全員」など全体への呼びかけは null。\n" - "- addressed_seat_no: 上の addressed_name と同じ人物の席番号(整数)、または null。roster が与えられているときは必ず埋める。" + "- addressed_seat_no: 上の addressed_name と同じ人物の席番号(整数)、または null。roster が与えられているときは必ず埋める。\n" + "- role_callout: 役職への名乗り出を求める呼びかけ、または一般的な情報請求があれば " + "\"seer\"/\"medium\"/\"knight\"/\"info_request\" のいずれか、なければ null。" + "特定役職を名指しした呼びかけ (例「占い師の方は名乗り出てください」「霊媒師いますか?」「騎士は誰?」) → 該当役職。" + "役職を限定しない一般的な情報請求 (例「誰か怪しい人いる?」「みんな意見を聞かせて」「気になる人を挙げて」" + "「誰か役職持ち出てきて」「みんなどう思う?」) → \"info_request\"。" + "ただし役職名を単に話題にしただけ (例「占い師の判定が気になる」) は null。" + "全員/全体への問いかけで意見・情報・怪しい相手・役職持ちを求めているときに \"info_request\" を立てる。" ) @classmethod @@ -670,6 +681,10 @@ async def transcribe( if isinstance(addressed, str) else None ) + callout_raw = analysis.get("role_callout") + role_callout_val = ( + callout_raw if callout_raw in ROLE_CALLOUT_VALUES else None + ) summary_dict = { k: v for k, v in analysis.items() @@ -701,6 +716,7 @@ async def transcribe( co_declaration=co_decl, addressed_name=addressed_name, addressed_seat_no=_coerce_seat_no(analysis.get("addressed_seat_no")), + role_callout=role_callout_val, raw_analysis=analysis or None, ) diff --git a/src/wolfbot/master/text_analyzer.py b/src/wolfbot/master/text_analyzer.py index 9aaa42b..e828ce3 100644 --- a/src/wolfbot/master/text_analyzer.py +++ b/src/wolfbot/master/text_analyzer.py @@ -32,7 +32,11 @@ from dataclasses import dataclass from typing import Protocol, runtime_checkable -from wolfbot.domain.enums import CO_CLAIM_VALUES, format_co_claim_options +from wolfbot.domain.enums import ( + CO_CLAIM_VALUES, + ROLE_CALLOUT_VALUES, + format_co_claim_options, +) log = logging.getLogger(__name__) @@ -120,11 +124,14 @@ class GeminiTextAnalyzer: "「みんな」「全員」など全体への呼びかけは null。さん/くん/ちゃん 等の敬称は付けたままでも構わない。" "発言内で他プレイヤーに言及するだけ(例: 『セツの判定が気になる』)は呼びかけではないので null。" "明確な宛先のある呼びかけ(例: 『セツさん、どう思う』)のみ設定。\n" - "- role_callout: 特定の役職に名乗り出を求める呼びかけがあれば " - "\"seer\"/\"medium\"/\"knight\" のいずれか、なければ null。" - "例: 「占い師の方は名乗り出てください」「霊媒師いますか?」「騎士は誰?」 → 該当役職。" - "ただし役職名を単に話題にしただけ (例: 「占い師の判定が気になる」) は null。" - "明確な「出てきて/名乗って/CO して/いますか」のような請求が含まれる場合のみ設定する。" + "- role_callout: 役職への名乗り出を求める呼びかけ、または一般的な情報請求があれば " + "\"seer\"/\"medium\"/\"knight\"/\"info_request\" のいずれか、なければ null。" + "特定役職を名指しした呼びかけ (例「占い師の方は名乗り出てください」「霊媒師いますか?」「騎士は誰?」) → 該当役職。" + "役職を限定しない一般的な情報請求 (例「誰か怪しい人いる?」「みんな意見を聞かせて」「気になる人を挙げて」" + "「誰か役職持ち出てきて」「みんなどう思う?」「初日だけど何か情報ない?」) → \"info_request\"。" + "ただし役職名を単に話題にしただけ (例「占い師の判定が気になる」「シゲミチが霊媒主張した」) は null。" + "個人への質問 (例「セツさん、どう思う?」) は addressed_name 側で扱い、role_callout は null。" + "全員/全体への問いかけで意見・情報・怪しい相手を求めているときに \"info_request\" を立てる。" ) def __init__( @@ -259,7 +266,9 @@ async def analyze(self, *, text: str, timeout_s: float) -> TextAnalysis: stripped = addressed_raw.strip() addressed_name = stripped or None callout_raw = parsed.get("role_callout") - role_callout = callout_raw if callout_raw in CO_CLAIM_VALUES else None + role_callout = ( + callout_raw if callout_raw in ROLE_CALLOUT_VALUES else None + ) return TextAnalysis( addressed_name=addressed_name, co_declaration=co_declaration, @@ -440,7 +449,9 @@ def _int_or_none(v: object) -> int | None: stripped = addressed_raw.strip() addressed_name = stripped or None callout_raw = parsed.get("role_callout") - role_callout = callout_raw if callout_raw in CO_CLAIM_VALUES else None + role_callout = ( + callout_raw if callout_raw in ROLE_CALLOUT_VALUES else None + ) return TextAnalysis( addressed_name=addressed_name, co_declaration=co_declaration, diff --git a/src/wolfbot/master/voice_ingest_service.py b/src/wolfbot/master/voice_ingest_service.py index 82b41cb..8d1c405 100644 --- a/src/wolfbot/master/voice_ingest_service.py +++ b/src/wolfbot/master/voice_ingest_service.py @@ -30,7 +30,7 @@ from dataclasses import dataclass, field from typing import Protocol, runtime_checkable -from wolfbot.domain.enums import CO_CLAIM_VALUES +from wolfbot.domain.enums import CO_CLAIM_VALUES, ROLE_CALLOUT_VALUES from wolfbot.domain.ws_messages import ( Heartbeat, SpeechEventPayload, @@ -502,7 +502,7 @@ def _build_dump( ) role_callout = ( result.role_callout - if result.role_callout in CO_CLAIM_VALUES + if result.role_callout in ROLE_CALLOUT_VALUES else None ) if dump_enabled: diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py index d727946..f40db7d 100644 --- a/src/wolfbot/services/discussion_service.py +++ b/src/wolfbot/services/discussion_service.py @@ -654,6 +654,10 @@ def apply_speech_event( for role_key in tuple(pending_role_callouts): if event.co_declaration == role_key or _resolve_co_role(event) == role_key: pending_role_callouts.discard(role_key) + # ``info_request`` is a generic info-seeking callout; once anyone + # CO's any info role, the request is considered partially answered + # and the priority pool steps down. + pending_role_callouts.discard("info_request") return PublicDiscussionState( game_id=state.game_id, phase_id=state.phase_id, @@ -678,6 +682,8 @@ def apply_speech_event( def rebuild_public_state_from_events( events: Sequence[SpeechEvent], + *, + prior_co_keys: frozenset[tuple[int, str]] = frozenset(), ) -> PublicDiscussionState | None: """Pure fold over a single phase's `SpeechEvent` rows. @@ -690,6 +696,16 @@ def rebuild_public_state_from_events( * `silent_seats` = `alive_seat_nos` minus seats with ≥1 non-sentinel event. * `recent_speech_event_ids` keeps the last 10 non-sentinel ids in arrival order. * `stances` / `pressure` / `open_topics` remain empty in MVP — design defers. + + ``prior_co_keys`` seeds the internal ``seen_co`` set with `(seat, role)` + tuples extracted from earlier-phase events. Without this, a seat that + CO'd on day 1 and re-asserts the same CO on day 2 would flag + ``is_new_co=True`` in the day-2 phase rebuild (because the per-phase + fold starts ``seen_co`` empty). That defeats the volley-demotion gate + in ``speak_arbiter._compute_demoted_seats``: the ジョナス↔ユリコ + ping-pong observed in game ``a701a7531dca`` day 2 escaped demotion + because every ジョナス re-CO looked like fresh info to the per-phase + fold even though the day-1 seer CO had been on record. """ if not events: return None @@ -720,7 +736,12 @@ def rebuild_public_state_from_events( co_claims: list[CoClaim] = [] recent_ids: list[str] = [] summary: list[tuple[int, bool]] = [] - seen_co: set[tuple[int, str]] = set() + # Seed seen_co with prior-phase CO history so a re-asserted CO doesn't + # flag is_new_co=True. co_claims itself stays scoped to current-phase + # declarations so the per-phase fold's outward shape is unchanged — + # the arbiter overrides state.co_claims with the game-wide history + # right after the rebuild via extract_co_claims_from_events. + seen_co: set[tuple[int, str]] = set(prior_co_keys) pending_role_callouts: set[str] = set() speech_counts: dict[int, int] = {} last_addressed_seats: frozenset[int] = frozenset() @@ -776,6 +797,9 @@ def rebuild_public_state_from_events( continue if is_new_co: pending_role_callouts.discard(role_key) + # See integrate_speech_event: info_request is consumed by any + # info-role CO, regardless of which specific role was asked. + pending_role_callouts.discard("info_request") if (event.speaker_seat, role_key) in seen_co: continue seen_co.add((event.speaker_seat, role_key)) diff --git a/src/wolfbot/services/game_export.py b/src/wolfbot/services/game_export.py index 5cae16d..e99fc7b 100644 --- a/src/wolfbot/services/game_export.py +++ b/src/wolfbot/services/game_export.py @@ -26,6 +26,7 @@ import json import logging +from datetime import datetime from pathlib import Path from typing import Any, cast @@ -78,7 +79,23 @@ async def export_game( out_dir.mkdir(parents=True, exist_ok=True) payload = await _build_payload(game_id, db_path, trace_root) - out_path = out_dir / f"{game_id}.json" + # Filename is timestamp-prefixed (sortable / human-readable) instead + # of the random game_id. Local-time format YYYY-MM-DD_HH-MM-SS comes + # from the game's created_at — the viewer auto-discovers files by + # mtime, so the prefix only matters for human listing. If a same- + # second collision happens, append _ to keep the older file. + ts = datetime.fromtimestamp(payload.game.created_at_ms / 1000) + base_name = ts.strftime("%Y-%m-%d_%H-%M-%S") + out_path = out_dir / f"{base_name}.json" + suffix = 1 + while out_path.exists() and out_path.stat().st_size > 0: + # Different game wrote the same-second filename earlier — disambiguate. + existing = out_path.read_text(encoding="utf-8") + if f'"id": "{game_id}"' in existing: + # Same game re-export — overwrite. + break + out_path = out_dir / f"{base_name}_{suffix}.json" + suffix += 1 out_path.write_text( payload.model_dump_json(indent=2), encoding="utf-8", diff --git a/tests/test_game_export.py b/tests/test_game_export.py index cea2198..c77dcd1 100644 --- a/tests/test_game_export.py +++ b/tests/test_game_export.py @@ -424,3 +424,71 @@ async def test_export_game_raises_for_unknown_game( trace_dir=tmp_path, output_dir=tmp_path / "out", ) + + +async def test_export_game_filename_uses_timestamp_prefix( + fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path +) -> None: + """Filename is derived from the game's created_at (local time), not the + random game_id, so the viewer dir lists games sortably by play time.""" + repo, db_path = fixture_repo + await _seed_minimal_game(repo) + + out = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=tmp_path / "out", + ) + + # Filename must NOT be {game_id}.json. + assert out.name != f"{GAME_ID}.json" + # Filename must be derived from created_at_ms = 1_700_000_000_000 → + # 2023-11-15 in UTC. Local-time conversion may shift the date but the + # YYYY-MM-DD_HH-MM-SS shape is invariant. + import re + assert re.match(r"^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:_\d+)?\.json$", out.name), ( + f"unexpected filename shape: {out.name}" + ) + + +async def test_export_game_filename_disambiguates_same_second_collision( + fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path +) -> None: + """If a different game already wrote a same-second file, append `_`. + Re-exporting the same game (same id) must overwrite, not collide.""" + repo, db_path = fixture_repo + await _seed_minimal_game(repo) + out_dir = tmp_path / "out" + + first = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=out_dir, + ) + # Re-export same game — should overwrite, not produce a `_1` sibling. + second = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=out_dir, + ) + assert first == second + assert len(list(out_dir.glob("*.json"))) == 1 + + # Now drop a fake same-second file from a different "game id" and + # re-export — should disambiguate with `_1` suffix. + other = out_dir / first.name + other.write_text( + json.dumps({"game": {"id": "different-game-id"}}), + encoding="utf-8", + ) + third = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=out_dir, + ) + assert third != first + assert third.name.endswith("_1.json") diff --git a/tests/test_llm_prompt_builder.py b/tests/test_llm_prompt_builder.py index f68dfb4..2ce482b 100644 --- a/tests/test_llm_prompt_builder.py +++ b/tests/test_llm_prompt_builder.py @@ -199,6 +199,104 @@ def test_game_rules_block_co_recognition_no_wolf_coordination_leak() -> None: assert "襲撃先を揃える" not in block +def test_game_rules_block_forbids_third_judgment_color() -> None: + """Seer/medium results are strictly binary (黒/白). Fake mediums in past + games hallucinated a `灰色` (gray) result; the listener side (e.g. real + knight) then took the gray claim as possibly valid. The shared block must + explicitly forbid third-color claims AND tell listeners to treat such a + claim as immediate CO破綻.""" + block = _build_game_rules_block() + assert "2 値のみ" in block + assert "灰" in block + assert "第 3 の色" in block + assert "破綻として確定扱い" in block + + +def test_game_rules_block_forbids_self_judgment_retraction() -> None: + """LLM seers/mediums must not retract or flip their own past judgment + color/target. Past games saw a fake medium claim `白` on day 2 and `灰色` + on day 3 for the same target without any correction wording.""" + block = _build_game_rules_block() + assert "後日撤回" in block + assert "色変更" in block + assert "対象差し替え" in block + assert "訂正" in block + + +def test_game_rules_block_handles_listener_side_color_flip() -> None: + """Listener side: a same-target color flip across days is a strong fake + signal but NOT auto-break, since human players typo / mis-speak. Without + an explicit correction wording, combine with other fake signals.""" + block = _build_game_rules_block() + assert "言い間違い" in block + assert "即座に偽 CO 確定とはしない" in block + assert "強い偽要素" in block + + +def test_game_rules_block_requires_concrete_citation_for_relational_terms() -> None: + """Relational vocabulary (重複・ライン・出来レース・囲い・身内切り・対立・連携) + requires a concrete citation of which speech/judgment/vote is being compared. + Past games saw a wolf yell `重複しすぎ` against two seer results that had + zero target overlap — bare relational labels must be rejected.""" + block = _build_game_rules_block() + assert "重複" in block + assert "出来レース" in block + assert "1 件以上具体的に引用" in block + assert "推理上の根拠として扱わず" in block + + +def test_game_rules_block_excludes_dead_seats_from_today_vote_target() -> None: + """Game 76358c4623f0 day 3 had all 5 NPCs proposing 'ステラを吊ろう' even + though ステラ was already attacked dead. Past-credibility analysis of a + dead seat is fine; today's vote target must be alive seats only. The + rule must explicitly forbid concluding a speech with '死者を吊ろう'.""" + block = _build_game_rules_block() + assert "死亡した席" in block + assert "信用評価対象" in block + assert "今日の処刑対象 (vote target) としては議題に含めない" in block + assert "破綻発言" in block + + +def test_game_rules_block_reframes_outlier_vote_as_possible_real_seer_read() -> None: + """Same game day 3: all village LLMs treated ステラ's day-1 'コメット票' + (异端 vote) as wolfish, but ステラ was the real seer who later confirmed + コメット as black. The rule must instruct LLMs to first check whether + an outlier vote is explained as a real seer's early black-read before + labeling it suspicious.""" + block = _build_game_rules_block() + assert "異端票" in block + assert "真占い視点で動いていた場合に整合するか" in block + assert "黒読み先制" in block + + +def test_game_rules_block_prioritizes_unannounced_results_over_addressed_reply() -> None: + """Same game day 3: シゲミチ (real medium) was dispatched as `addressed` + while holding an unannounced コメット黒. The NPC LLM set + co_declaration='medium' but the text body answered the addressed + question instead of leading with the CO. The rule must tell LLMs to + lead with CO + result and answer the addressed question afterward.""" + block = _build_game_rules_block() + assert "未公開の能力結果" in block + assert "発言の番が回ってきたとき" in block + assert "発話の冒頭で必ず CO + 結果公表" in block + assert "addressed 文脈に飲まれて CO + 結果を欠落させる" in block + + +def test_game_rules_block_requires_per_target_naming_for_seer_medium_results() -> None: + """Game af3d1e5d3fa1 day 1 had ユリコ (狂人) fake-seer CO with 「すべて白」 — + a vague plural claim with no target names. day 1 has only ONE NIGHT_0 + random white target, so 'all white' is structurally impossible. The + rule must forbid plural-without-target claims (`すべて白` / `全員白` / + `全部白`) and require target-by-target naming on every announcement.""" + block = _build_game_rules_block() + assert "対象席名 + 判定色 (黒/白) を必ず一対一で添える" in block + assert "すべて白" in block + assert "全員白" in block + assert "破綻発言" in block + # day-1 single-target reinforcement. + assert "day 1 の占い結果は必ず対象 1 名 + 白判定の 1 件のみ" in block + + def test_game_rules_block_explains_medium_white_means_not_wolf_only() -> None: """Medium white = `not a real werewolf`, not a role-claim confirmation. Every LLM seat must see this so that no role overreads a white result.""" @@ -905,19 +1003,26 @@ def test_madman_fake_strategy_has_no_wolf_coordination_vocabulary() -> None: @pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_describes_conditional_seer_fake(role: Role) -> None: - """Day-1 seer fake is offered as a *conditional* option, not an - unconditional default — the wording must contain gating words - (無条件 / 潜伏) that signal the LLM to decide based on board state.""" +def test_fake_strategy_presents_three_way_day1_choice(role: Role) -> None: + """Day-1 strategy must present **先制CO / 対抗CO / 潜伏** as three first-class + candidates, NOT funnel the LLM into one option via a single conditional + trigger. Empirical evidence (recent games) showed wolves never preempt and + almost always go silent — the 3-way framing forces real-time comparison + instead of mechanical defaults.""" block = _build_strategy_block(role) - assert "無条件" in block - assert "潜伏" in block - if role is Role.WEREWOLF: - assert "相方が危険位置" in block - else: - # Madman variant: no wolf-coordination vocab (bare 相方 / 襲撃先を揃える), - # but references CO creep. 相方候補 (inference) is allowed. - assert "複数の占い師 CO" in block + # All three options must appear as named tactics. + assert "先制CO" in block, f"{role.name}: 先制CO option missing" + assert "対抗CO" in block, f"{role.name}: 対抗CO option missing" + assert "潜伏" in block, f"{role.name}: 潜伏 option missing" + # Equal-weight framing: explicit 3-way comparison language. + assert "3 択" in block + assert "等しく強い" in block + # Anti-pattern guard: mechanical single-condition decisions called out. + assert "機械的" in block + # Counter the "潜伏 = safe" misconception observed empirically. + assert "潜伏は安全策ではない" in block + if role is Role.MADMAN: + # Madman variant must still avoid wolf-coordination vocabulary. assert not re.search(r"相方(?!候補)", block) assert "襲撃先を揃える" not in block @@ -972,11 +1077,15 @@ def test_madman_day_2_plus_black_still_carries_misfire_awareness() -> None: def test_seer_strategy_covers_proactive_and_counter_co() -> None: - """Seer must have explicit guidance on early CO when no seer CO has - appeared, counter-CO against a fake seer with time-ordered history - disclosure, and the black-pull CO procedure.""" + """Seer must have explicit guidance on day-1 CO when their own speaking + turn arrives, counter-CO against a fake seer with time-ordered history + disclosure, and the black-pull CO procedure. The on-turn framing + replaces the earlier 'mid-discussion no-seer-CO trigger' because the + NPC cannot self-trigger speech — the rule must fire whenever the + arbiter happens to dispatch them.""" block = _build_strategy_block(Role.SEER) - assert "占い師 CO が一切出ていない" in block + assert "発言の番" in block + assert "次の自分のターンに先送りしない" in block assert "対抗 CO" in block assert "時系列で公開" in block assert "黒を引いた場合" in block @@ -994,12 +1103,51 @@ def test_seer_strategy_includes_three_seer_co_elimination_for_targeting() -> Non assert "霊媒結果・襲撃死・CO 破綻など説明可能な根拠に限る" in block +def test_medium_strategy_validates_seer_co_with_matching_judgment() -> None: + """Medium must use own black result to validate a seer-CO whose past + judgment matches. Game 76358c4623f0 had シゲミチ (medium) holding コメット + 黒 while ステラ-CO had previously claimed コメット黒 — the seer-CO was + therefore real, but シゲミチ joined the flow that called ステラ偽.""" + block = _build_strategy_block(Role.MEDIUM) + assert "占い師 CO の判定が一致した場合" in block + assert "真寄りに置く" in block + assert "流れに乗って" in block + assert "村陣営が真情報を失う" in block + + +def test_seer_strategy_prioritizes_co_over_addressed_reply() -> None: + """Real seer dispatched as `addressed` must lead with CO + result, not + answer the question first. Same root-cause as シゲミチ in game + 76358c4623f0 day 3 (co_declaration field set, body never named the CO).""" + block = _build_strategy_block(Role.SEER) + assert "他席から呼びかけられている (addressed) 状態でも" in block + assert "未公開の能力結果" in block + assert "発話の冒頭で行い" in block + assert "addressed への返答はその後に短く添える" in block + + +def test_medium_strategy_prioritizes_co_over_addressed_reply() -> None: + """Same as seer rule — medium dispatched while addressed must lead with + CO + medium-result and reply to the question afterward. Direct cause + of the シゲミチ day-3 failure in game 76358c4623f0.""" + block = _build_strategy_block(Role.MEDIUM) + assert "他席から呼びかけられている (addressed) 状態でも" in block + assert "未公開の霊媒結果" in block + assert "発話の冒頭で行い" in block + assert "addressed への返答はその後に短く添える" in block + + def test_medium_strategy_covers_post_execution_publication_and_counter_co() -> None: - """Medium must publish results the day after an execution and must run - counter-CO against a fake medium with time-ordered history framing while - acknowledging self-roller vulnerability.""" + """Medium must publish results the day after an execution (when their + speaking turn arrives) and must run counter-CO against a fake medium + with time-ordered history framing while acknowledging self-roller + vulnerability. The on-turn framing replaces the earlier 'day after + execution' anchor because the NPC cannot self-trigger speech — the + rule must fire whenever the arbiter happens to dispatch them.""" block = _build_strategy_block(Role.MEDIUM) - assert "処刑が発生した翌日" in block + assert "前日に処刑があった" in block + assert "発言の番" in block + assert "次の自分のターンに先送りしない" in block assert "対抗霊媒" in block assert "ローラー" in block assert "巻き込まれる可能性" in block diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index f73babb..1653989 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -1800,11 +1800,16 @@ async def test_ask_system_prompt_knight_includes_protection_success_co_strategy( async def test_ask_system_prompt_seer_includes_counter_co_strategy( repo: SqliteRepo, ) -> None: - """A true seer LLM must receive proactive-CO (when no seer CO has appeared), - counter-CO (when a fake seer appears), and black-pull CO guidance so the - true seer doesn't stay silent and cede single-truth treatment to a fake.""" + """A true seer LLM must receive on-turn CO guidance (when their day-1 + speaking turn arrives), counter-CO (when a fake seer appears), and + black-pull CO guidance so the true seer doesn't stay silent and cede + single-truth treatment to a fake. The on-turn framing replaces the + 'mid-discussion no-seer-CO' trigger because the NPC cannot self- + trigger speech — the rule must fire whenever the arbiter dispatches + them.""" system_prompt = await _capture_ask_system_prompt(repo, Role.SEER) - assert "占い師 CO が一切出ていない" in system_prompt + assert "発言の番" in system_prompt + assert "次の自分のターンに先送りしない" in system_prompt assert "対抗 CO" in system_prompt assert "時系列で公開" in system_prompt assert "黒を引いた場合" in system_prompt @@ -1813,11 +1818,15 @@ async def test_ask_system_prompt_seer_includes_counter_co_strategy( async def test_ask_system_prompt_medium_includes_counter_co_strategy( repo: SqliteRepo, ) -> None: - """A true medium LLM must receive the post-execution result-publication - duty and the counter-CO pathway, with explicit self-roller vulnerability - framing so the medium doesn't stay silent against a fake medium.""" + """A true medium LLM must receive on-turn result-publication guidance + (when their post-execution speaking turn arrives) and the counter-CO + pathway, with explicit self-roller vulnerability framing so the medium + doesn't stay silent against a fake medium. The on-turn framing replaces + the 'day after execution' anchor for the same reason as seer.""" system_prompt = await _capture_ask_system_prompt(repo, Role.MEDIUM) - assert "処刑が発生した翌日" in system_prompt + assert "前日に処刑があった" in system_prompt + assert "発言の番" in system_prompt + assert "次の自分のターンに先送りしない" in system_prompt assert "対抗霊媒" in system_prompt assert "ローラー" in system_prompt @@ -1885,7 +1894,8 @@ async def test_ask_system_prompt_knight_guard_task_includes_checklist( async def test_ask_system_prompt_wolf_seat_includes_fake_strategy(repo: SqliteRepo) -> None: """The werewolf LLM's system prompt must carry the fake-CO playbook: - day-1 seer fake is offered as a *conditional* option (not unconditional), + day-1 戦術 is presented as a 3-way choice (先制CO / 対抗CO / 潜伏) without + a single mechanical trigger that funnels the LLM into one option, day-2+ medium/knight fake, the over-fake warning, and medium-roller / knight-legal-guard-history caveats.""" system_prompt = await _capture_ask_system_prompt(repo, Role.WEREWOLF) @@ -1895,10 +1905,14 @@ async def test_ask_system_prompt_wolf_seat_includes_fake_strategy(repo: SqliteRe assert "騎士騙り" in system_prompt assert "6 人以上" in system_prompt assert "騙りすぎ" in system_prompt - # Conditional framing — day-1 seer fake is no longer unconditional. - assert "無条件" in system_prompt + # 3-way framing — 先制CO / 対抗CO / 潜伏 each presented as a first-class option. + assert "先制CO" in system_prompt + assert "対抗CO" in system_prompt assert "潜伏" in system_prompt - assert "相方が危険位置" in system_prompt + assert "3 択" in system_prompt + assert "等しく強い" in system_prompt + assert "機械的" in system_prompt + assert "潜伏は安全策ではない" in system_prompt # Day-1 first-result-white anchor + day-1 black prohibition + day-2+ deferral. assert "NIGHT_0 ランダム白" in system_prompt assert "必ず白を主張" in system_prompt @@ -1911,8 +1925,8 @@ async def test_ask_system_prompt_madman_includes_fake_strategy_without_wolf_coor repo: SqliteRepo, ) -> None: """The madman LLM's system prompt must carry the fake-CO playbook - (day-1 seer fake, day-2+ medium/knight fake, over-fake warning) as a - *conditional* option with misfire caveats — and no wolf-coordination + (day-1 3-way choice 先制CO / 対抗CO / 潜伏, day-2+ medium/knight fake, + over-fake warning) with misfire caveats — and no wolf-coordination vocabulary (`相方` / `襲撃先を揃える`).""" system_prompt = await _capture_ask_system_prompt(repo, Role.MADMAN) assert "day 1" in system_prompt @@ -1927,9 +1941,14 @@ async def test_ask_system_prompt_madman_includes_fake_strategy_without_wolf_coor assert "襲撃先を揃える" not in system_prompt # Existing prohibition phrase must also still be present. assert "人狼位置を知っている前提で話してはならない" in system_prompt - # Conditional framing + misfire / white-out caveats. - assert "無条件ではなく" in system_prompt - assert "複数の占い師 CO" in system_prompt + # 3-way framing + anti-mechanical-default + misfire / white-out caveats. + assert "先制CO" in system_prompt + assert "対抗CO" in system_prompt + assert "潜伏" in system_prompt + assert "3 択" in system_prompt + assert "等しく強い" in system_prompt + assert "機械的" in system_prompt + assert "潜伏は安全策ではない" in system_prompt assert "誤爆リスク" in system_prompt assert "白先が本物の狼とは限らない" in system_prompt # Day-1 first-result-white anchor + day-1 black prohibition + day-2+ deferral. diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index 7cefc20..eb11a54 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -369,6 +369,611 @@ async def test_speak_result_over_length_rejected(repo: SqliteRepo) -> None: assert rejection.failure_reason == "utterance_too_long" +async def test_role_callout_pool_prioritizes_real_role_and_wolf_side( + repo: SqliteRepo, +) -> None: + """When `pending_role_callouts` has 'seer' and no seer has CO'd yet, + the next dispatch must come from the **callout pool**: real seer + + every wolf-side seat (人狼/狂人) that hasn't CO'd as any info role. + Pure villagers must NOT be picked while the pool has eligible + candidates — this is the user's day-1 priority spec. + """ + g = Game( + id="rv-callout-pool", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + # Seats: 1=ジョナス WEREWOLF, 2=ジナ VILLAGER, 3=ラキオ MADMAN, + # 4=コメット VILLAGER, 5=セツ SEER (real), 6=シゲミチ VILLAGER (caller). + seats = [ + Seat(seat_no=1, display_name="🎩ジョナス", discord_user_id=None, + is_llm=True, persona_key="jonas"), + Seat(seat_no=2, display_name="🟣ジナ", discord_user_id=None, + is_llm=True, persona_key="gina"), + Seat(seat_no=3, display_name="🦋ラキオ", discord_user_id=None, + is_llm=True, persona_key="raqio"), + Seat(seat_no=4, display_name="☄️コメット", discord_user_id=None, + is_llm=True, persona_key="comet"), + Seat(seat_no=5, display_name="🌙セツ", discord_user_id=None, + is_llm=True, persona_key="setsu"), + Seat(seat_no=6, display_name="👽シゲミチ", discord_user_id=None, + is_llm=True, persona_key="shigemichi"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ( + (1, Role.WEREWOLF), (2, Role.VILLAGER), (3, Role.MADMAN), + (4, Role.VILLAGER), (5, Role.SEER), (6, Role.VILLAGER), + ): + await repo.set_player_role(g.id, sn, role) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3, 4, 5, 6], created_at_ms=1, + ) + ) + # シゲミチ asks "誰か占い師?" — the analyzer would tag this as + # role_callout="seer". Insert a synthetic NPC speech event with the + # callout to simulate that. + from wolfbot.services.discussion_service import make_npc_generated_event + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=6, text="誰か占い師、名乗ってくれ!", + role_callout="seer", + created_at_ms=10, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[int, list[str]] = {n: [] for n in (1, 2, 3, 4, 5, 6)} + persona_by_seat = { + 1: "jonas", 2: "gina", 3: "raqio", + 4: "comet", 5: "setsu", 6: "shigemichi", + } + for seat_no, persona in persona_by_seat.items(): + registry.register( + npc_id=f"npc_{persona}", discord_bot_user_id=f"bot_{persona}", + supported_voices=(), version="1", + send=_captured_send(bufs[seat_no]), + now_ms=2000, persona_key=persona, + ) + registry.assign(f"npc_{persona}", seat=seat_no, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 3000, + ) + + # Dispatch — must go to one of the pool members: + # real seer (5) + wolves+madman (1, 3). Pure villagers (2, 4, 6) must + # NOT be picked. + await arb.try_dispatch_next(g.id) + pool_seats = {1, 3, 5} + non_pool_seats = {2, 4, 6} + picked = next((s for s, b in bufs.items() if any('"speak_request"' in m for m in b)), None) + assert picked is not None, "must dispatch someone" + assert picked in pool_seats, ( + f"picked seat {picked} must be in callout pool {pool_seats} " + f"(real seer + wolves + madman, all uncpd). " + f"villagers {non_pool_seats} should NOT be picked while pool is non-empty." + ) + + _, reason = await _fetch_selection_reason(repo, g.id) + assert reason == "role_callout_pool" + + +async def test_info_request_callout_expands_pool_to_all_info_roles( + repo: SqliteRepo, +) -> None: + """A generic info-seeking speech (「誰か怪しい人?」「みんな意見聞かせて」) + is tagged role_callout='info_request' by the analyzer. The arbiter + must treat this like a callout for ALL info roles simultaneously: + pool = real seer + real medium + real knight + every uncpd wolf-side. + Pure villagers stay out of the pool.""" + g = Game( + id="rv-info-request", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="🎩ジョナス", discord_user_id=None, + is_llm=True, persona_key="jonas"), # WEREWOLF + Seat(seat_no=2, display_name="🟣ジナ", discord_user_id=None, + is_llm=True, persona_key="gina"), # MEDIUM + Seat(seat_no=3, display_name="🦋ラキオ", discord_user_id=None, + is_llm=True, persona_key="raqio"), # VILLAGER (caller) + Seat(seat_no=4, display_name="☄️コメット", discord_user_id=None, + is_llm=True, persona_key="comet"), # VILLAGER + Seat(seat_no=5, display_name="🌙セツ", discord_user_id=None, + is_llm=True, persona_key="setsu"), # MADMAN + Seat(seat_no=6, display_name="👽シゲミチ", discord_user_id=None, + is_llm=True, persona_key="shigemichi"), # SEER + Seat(seat_no=7, display_name="🍎SQ", discord_user_id=None, + is_llm=True, persona_key="sq"), # KNIGHT + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ( + (1, Role.WEREWOLF), (2, Role.MEDIUM), (3, Role.VILLAGER), + (4, Role.VILLAGER), (5, Role.MADMAN), (6, Role.SEER), + (7, Role.KNIGHT), + ): + await repo.set_player_role(g.id, sn, role) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3, 4, 5, 6, 7], created_at_ms=1, + ) + ) + # Caller (ラキオ, villager): 「誰か怪しい人挙げて」 → role_callout='info_request' + from wolfbot.services.discussion_service import make_npc_generated_event + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=3, text="誰か怪しい人挙げてくれ!", + role_callout="info_request", + created_at_ms=10, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[int, list[str]] = {n: [] for n in range(1, 8)} + persona_by_seat = { + 1: "jonas", 2: "gina", 3: "raqio", 4: "comet", + 5: "setsu", 6: "shigemichi", 7: "sq", + } + for seat_no, persona in persona_by_seat.items(): + registry.register( + npc_id=f"npc_{persona}", discord_bot_user_id=f"bot_{persona}", + supported_voices=(), version="1", + send=_captured_send(bufs[seat_no]), + now_ms=2000, persona_key=persona, + ) + registry.assign(f"npc_{persona}", seat=seat_no, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 3000, + ) + await arb.try_dispatch_next(g.id) + + # Pool: seats 1 (wolf), 2 (medium), 5 (madman), 6 (seer), 7 (knight). + # Pure villagers 3, 4 must NOT be picked. + pool_seats = {1, 2, 5, 6, 7} + villager_seats = {3, 4} + picked = next( + (s for s, b in bufs.items() if any('"speak_request"' in m for m in b)), + None, + ) + assert picked is not None, "must dispatch from the info_request pool" + assert picked in pool_seats, ( + f"info_request pool must include real info roles + wolf-side. " + f"picked={picked} pool_seats={pool_seats}, villagers {villager_seats} " + f"should never be picked while pool is non-empty." + ) + _, reason = await _fetch_selection_reason(repo, g.id) + assert reason == "role_callout_pool" + + +async def test_info_request_consumed_when_any_info_role_cod( + repo: SqliteRepo, +) -> None: + """info_request is a generic callout; once anyone CO's any info role + (seer/medium/knight), the priority pool steps down and normal + rotation resumes (villagers can be picked again).""" + g = Game( + id="rv-info-request-consume", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="🎩ジョナス", discord_user_id=None, + is_llm=True, persona_key="jonas"), # SEER (will CO) + Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, + is_llm=True, persona_key="setsu"), # VILLAGER + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.SEER) + await repo.set_player_role(g.id, 2, Role.VILLAGER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2], created_at_ms=1, + ) + ) + from wolfbot.services.discussion_service import make_npc_generated_event + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, text="誰か怪しい人挙げて", + role_callout="info_request", + created_at_ms=10, + ) + ) + # Real seer answers — co_declaration='seer'. + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="私が占い師だ", + co_declaration="seer", + created_at_ms=20, + ) + ) + + # Now rebuild state — info_request must have been consumed by the + # seer CO. pending_role_callouts should NOT contain "info_request". + from wolfbot.services.discussion_service import ( + rebuild_public_state_from_events, + ) + events = await store.load_phase(g.id, phase_id) + state = rebuild_public_state_from_events(events) + assert state is not None + assert "info_request" not in state.pending_role_callouts, ( + "any info-role CO must consume the info_request callout" + ) + + +async def test_role_callout_pool_excludes_seats_that_already_cod( + repo: SqliteRepo, +) -> None: + """A wolf who already fake-CO'd as seer must be excluded from the pool + (no double-counting). The pool only contains uncpd info-role candidates. + """ + g = Game( + id="rv-callout-pool-exclude", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="🎩ジョナス", discord_user_id=None, + is_llm=True, persona_key="jonas"), # WEREWOLF — fake-CO'd already + Seat(seat_no=2, display_name="🟣ジナ", discord_user_id=None, + is_llm=True, persona_key="gina"), # MADMAN — still in pool + Seat(seat_no=3, display_name="🌙セツ", discord_user_id=None, + is_llm=True, persona_key="setsu"), # SEER (real) — still in pool + Seat(seat_no=4, display_name="☄️コメット", discord_user_id=None, + is_llm=True, persona_key="comet"), # VILLAGER — never in pool + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ( + (1, Role.WEREWOLF), (2, Role.MADMAN), (3, Role.SEER), (4, Role.VILLAGER), + ): + await repo.set_player_role(g.id, sn, role) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3, 4], created_at_ms=1, + ) + ) + from wolfbot.services.discussion_service import make_npc_generated_event + # Wolf already fake-CO'd as seer (consumes the original callout). + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="私が占い師だ", + co_declaration="seer", + created_at_ms=10, + ) + ) + # Then someone else asks "他に占い師は?" — re-fires the seer callout. + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=4, text="他に占い師の方は?", + role_callout="seer", + created_at_ms=20, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[int, list[str]] = {n: [] for n in (1, 2, 3, 4)} + persona_by_seat = {1: "jonas", 2: "gina", 3: "setsu", 4: "comet"} + for seat_no, persona in persona_by_seat.items(): + registry.register( + npc_id=f"npc_{persona}", discord_bot_user_id=f"bot_{persona}", + supported_voices=(), version="1", + send=_captured_send(bufs[seat_no]), + now_ms=2000, persona_key=persona, + ) + registry.assign(f"npc_{persona}", seat=seat_no, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 3000, + ) + + await arb.try_dispatch_next(g.id) + + # Pool should contain seat 2 (madman, no CO) and seat 3 (real seer). + # Seat 1 already CO'd → excluded. Seat 4 villager → never in pool. + picked = next((s for s, b in bufs.items() if any('"speak_request"' in m for m in b)), None) + assert picked is not None + assert picked in {2, 3}, ( + f"picked seat {picked} must be madman (2) or real seer (3); " + f"wolf (1) is already CO'd, villager (4) never in pool." + ) + + +async def test_role_callout_pool_asked_tracker_avoids_repick( + repo: SqliteRepo, +) -> None: + """If a pool member declines, the asked-tracker must prevent the picker + from looping on the same seat. Subsequent dispatches go to other pool + members until everyone has had one chance.""" + g = Game( + id="rv-callout-asked", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="🎩ジョナス", discord_user_id=None, + is_llm=True, persona_key="jonas"), + Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, + is_llm=True, persona_key="setsu"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2], created_at_ms=1, + ) + ) + from wolfbot.services.discussion_service import make_npc_generated_event + # External callout — fired by no-one in the test (we add a fake event + # with a non-pool speaker_seat, but to avoid violating the alive-set + # we attribute it to seat 1 with role_callout but no co_declaration). + # Actually simpler: use seat 2 itself but set co_declaration=None; + # then the callout fires without consuming itself. + # We need a third seat to act as the speaker — let's add one as + # already-dead and not part of the pool, but the model requires + # alive=True for speakers to be valid... use a knight-like spectator. + # Workaround: use seat 1 (wolf) as the question asker. Their event + # only flags role_callout=seer; the wolf itself stays in pool because + # they didn't co_declare. + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="占い師は誰だ?", + role_callout="seer", + created_at_ms=10, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[int, list[str]] = {1: [], 2: []} + persona_by_seat = {1: "jonas", 2: "setsu"} + for seat_no, persona in persona_by_seat.items(): + registry.register( + npc_id=f"npc_{persona}", discord_bot_user_id=f"bot_{persona}", + supported_voices=(), version="1", + send=_captured_send(bufs[seat_no]), + now_ms=2000, persona_key=persona, + ) + registry.assign(f"npc_{persona}", seat=seat_no, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 3000, + ) + + # First dispatch from pool — pick is random between 1 and 2. + await arb.try_dispatch_next(g.id) + first_picked = next((s for s, b in bufs.items() if any('"speak_request"' in m for m in b)), None) + assert first_picked in {1, 2} + + # The picked seat is now in `_callout_pool_asked`. Second dispatch + # MUST go to the other pool member (asked tracker excludes the first). + bufs[1].clear() + bufs[2].clear() + # Need to clear _pending so dispatch can proceed (no playback yet). + arb._pending.clear() + await arb.try_dispatch_next(g.id) + second_picked = next((s for s, b in bufs.items() if any('"speak_request"' in m for m in b)), None) + assert second_picked is not None + assert second_picked != first_picked, ( + f"asked tracker must steer to a different pool member: " + f"first={first_picked}, second={second_picked}" + ) + + +async def test_speak_result_rejection_clears_pending_and_redispatches( + repo: SqliteRepo, +) -> None: + """Regression for game eab1f9514a10 day 4: SQ finished, ユリコ was + dispatched but took 10s to respond (TTL=8s) → `expired_request` + rejection. The old rejection path neither popped `_pending` nor called + `try_dispatch_next`, so the day stalled silently for 2.5 minutes. + + After the fix, every valid-pending rejection (expired/stale/declined/ + too-long) must: + 1. Pop _pending so the slot is freed. + 2. Call try_dispatch_next so another candidate gets a chance. + """ + g = Game( + id="rv-reject-redispatch", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, + is_llm=True, persona_key="raqio"), + Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, + is_llm=True, persona_key="setsu"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2], created_at_ms=1, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"raqio": [], "setsu": []} + for npc_id, persona, seat_no in ( + ("npc_raqio", "raqio", 1), + ("npc_setsu", "setsu", 2), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", + supported_voices=(), version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + # Step 1: dispatch seat 1 (ラキオ). Use a permissive heartbeat_timeout + # since the test artificially advances `now` past the request TTL — in + # production the NPC sends heartbeats continuously so stale clock vs + # heartbeat doesn't happen. + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 2000, + config=SpeakArbiterConfig(heartbeat_timeout_ms=60_000), + ) + state = await arb.rebuild_public_state( + game_id=g.id, day=1, phase=Phase.DAY_DISCUSSION, + ) + assert state is not None + req, _ = await arb.dispatch_request( + state=state, candidate_npc_id="npc_raqio", seat_no=1, game_id=g.id + ) + assert req is not None + assert req.request_id in arb._pending + + # Step 2: simulate now > expires_at_ms by advancing the arbiter's clock + # past the request's TTL. + arb._now_ms = lambda: req.expires_at_ms + 100 # type: ignore[method-assign] + + bufs["raqio"].clear() + bufs["setsu"].clear() + + # Step 3: SpeakResult arrives late → expired_request rejection. + result = SpeakResult( + ts=req.expires_at_ms + 100, + trace_id="t", + request_id=req.request_id, + npc_id="npc_raqio", + phase_id=phase_id, + status="accepted", + text="late response", + ) + ok, reason = await arb.handle_speak_result( + result, current_phase_id=phase_id, day=1, phase=Phase.DAY_DISCUSSION, + ) + assert not ok and reason == "expired_request" + + # Verify _pending was popped (slot freed). + assert req.request_id not in arb._pending, ( + "_pending must be popped on rejection so the slot is freed" + ) + + # Verify try_dispatch_next was called → another seat got a SpeakRequest. + # Without the fix, both bufs would be empty and the day would stall. + # (The picker may choose either seat 1 or seat 2 depending on RNG — + # both have count=0 and no addressed bias.) + new_request_msgs = [ + m for m in (*bufs["raqio"], *bufs["setsu"]) if '"speak_request"' in m + ] + assert new_request_msgs, ( + "rejection must trigger try_dispatch_next; another candidate must be picked. " + f"raqio buf: {bufs['raqio']!r}, setsu buf: {bufs['setsu']!r}" + ) + + async def test_speak_result_stale_phase_rejected(repo: SqliteRepo) -> None: game, _ = await _seed_game(repo) registry = InMemoryNpcRegistry() @@ -1370,6 +1975,129 @@ async def test_try_dispatch_next_diverts_around_pair_volley( assert reason == "low_info_diversion" +async def test_repeated_co_across_days_does_not_block_volley_demotion( + repo: SqliteRepo, +) -> None: + """Game a701a7531dca day 2 escape hatch: a seat that CO'd on day 1 + re-asserts the same CO on day 2; the per-phase rebuild must NOT treat + the day-2 re-assertion as fresh info, otherwise the ジョナス↔ユリコ + ping-pong escapes the `_PAIR_VOLLEY_WINDOW` demotion gate. + + Setup: + day 1 phase: seat 1 CO seer (recorded in game-wide history). + day 2 phase: seat 1 re-asserts CO seer, then 1↔9 alternate 4 times. + Expected: third party (seat 3) wins the next dispatch — `is_new_co` + must be False for the day-2 re-CO so all 4 window entries are low-info. + """ + g = Game( + id="rv-cross-day-co", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=2, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="🎩ジョナス", discord_user_id=None, + is_llm=True, persona_key="jonas"), + Seat(seat_no=3, display_name="🟣ジナ", discord_user_id=None, + is_llm=True, persona_key="gina"), + Seat(seat_no=9, display_name="👑ユリコ", discord_user_id=None, + is_llm=True, persona_key="yuriko"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ((1, Role.SEER), (3, Role.VILLAGER), (9, Role.VILLAGER)): + await repo.set_player_role(g.id, sn, role) + + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + from wolfbot.services.discussion_service import make_npc_generated_event + + # Day 1 phase: seat 1 declares seer once. + day1_phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=day1_phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 3, 9], created_at_ms=1, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=day1_phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="私が占い師だ", + co_declaration="seer", + created_at_ms=10, + ) + ) + + # Day 2 phase: seat 3 opens (so silent_seats is empty), then 1<->9 + # alternate. Seat 1's day-2 utterance carries co_declaration="seer" + # again — must be treated as a re-assertion (no fresh info). + day2_phase_id = make_phase_id(g.id, 2, Phase.DAY_DISCUSSION) + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=day2_phase_id, day=2, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 3, 9], created_at_ms=1000, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=day2_phase_id, day=2, + phase=Phase.DAY_DISCUSSION, + speaker_seat=3, text="day2 開始発言", + created_at_ms=1005, + ) + ) + for ts, seat in ((1010, 9), (1020, 1), (1030, 9), (1040, 1)): + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=day2_phase_id, day=2, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat, text=f"seat {seat} day2", + co_declaration="seer" if seat == 1 else None, + created_at_ms=ts, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"jonas": [], "gina": [], "yuriko": []} + for npc_id, persona, seat_no in ( + ("npc_jonas", "jonas", 1), + ("npc_gina", "gina", 3), + ("npc_yuriko", "yuriko", 9), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", + supported_voices=(), version="1", + send=_captured_send(bufs[persona]), + now_ms=2000, persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=day2_phase_id) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 3000, + ) + await arb.try_dispatch_next(g.id) + + assert bufs["gina"], "third party seat 3 must break the volley" + assert not bufs["jonas"], "seat 1 must be demoted (cross-phase re-CO is not info)" + assert not bufs["yuriko"], "seat 9 must be demoted" + + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "3" + assert reason == "low_info_diversion" + + async def test_handle_tts_failed_pops_pending_before_returning( repo: SqliteRepo, ) -> None: @@ -1817,6 +2545,147 @@ async def _intro(seat: Seat) -> None: assert buf2, "online tied candidate must receive the SpeakRequest" +async def test_runoff_watchdog_does_not_pop_pending_after_speak_result_accepted( + repo: SqliteRepo, +) -> None: + """Regression for game d57c5d83ed4a day 2: ジョナス returned a SpeakResult + in 5s but her TTS playback ran 11.6s — longer than the 8s watchdog TTL. + The watchdog spuriously popped _pending while playback was still active. + Then `_on_playback_finished` lost the game_id lookup (it depends on + _pending) and never called try_dispatch_next, so シゲミチ (the other + tied candidate) was never dispatched and the runoff stalled forever. + + Fix: watchdog must early-exit when the request is in _active_playback + (= SpeakResult was accepted; playback handler will re-dispatch on finish). + """ + import asyncio + + from wolfbot.domain.models import Vote + + g = Game( + id="rv-runoff-watchdog", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_RUNOFF_SPEECH, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + for seat in ( + Seat(seat_no=1, display_name="🎩ジョナス", discord_user_id=None, + is_llm=True, persona_key="jonas"), + Seat(seat_no=6, display_name="👽シゲミチ", discord_user_id=None, + is_llm=True, persona_key="shigemichi"), + Seat(seat_no=2, display_name="🟣ジナ", discord_user_id=None, + is_llm=True, persona_key="gina"), + ): + await repo.insert_seat(g.id, seat) + for sn, role in ((1, Role.WEREWOLF), (6, Role.VILLAGER), (2, Role.KNIGHT)): + await repo.set_player_role(g.id, sn, role) + # Tie between 1 and 6. + for voter, target in ((1, 6), (6, 1), (2, None)): + await repo.insert_vote( + Vote(game_id=g.id, day=1, round=0, voter_seat=voter, + target_seat=target, submitted_at=1) + ) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + alive_seat_nos=[1, 2, 6], created_at_ms=1, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"jonas": [], "shigemichi": []} + for npc_id, persona, seat_no in ( + ("npc_jonas", "jonas", 1), + ("npc_shigemichi", "shigemichi", 6), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", + supported_voices=(), version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + intros: list[int] = [] + + async def _intro(seat: Seat) -> None: + intros.append(seat.seat_no) + + # Very short TTL so the watchdog fires within the test window without + # asyncio.sleep(8). 50ms is enough to let _watchdog wake up. + cfg = SpeakArbiterConfig(request_ttl_ms=50) + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 2000, + runoff_announce=_intro, + config=cfg, + ) + + # Step 1: dispatch first candidate (ジョナス). + await arb.try_dispatch_next(g.id) + assert intros == [1] + assert bufs["jonas"], "first tied candidate must receive SpeakRequest" + + req_msg = next(m for m in bufs["jonas"] if '"speak_request"' in m) + import json as _json + request_id = _json.loads(req_msg)["request_id"] + + # Step 2: NPC accepts SpeakResult (adds to _active_playback, leaves + # _pending populated until playback finishes). + result = SpeakResult( + ts=2050, trace_id="t", request_id=request_id, + npc_id="npc_jonas", phase_id=phase_id, + status="accepted", text="諸君……ラキオの占い師主張など、笑止千万!", + ) + ok, _ = await arb.handle_speak_result( + result, current_phase_id=phase_id, day=1, phase=Phase.DAY_RUNOFF_SPEECH, + ) + assert ok + assert request_id in arb._active_playback + assert request_id in arb._pending # not popped yet — playback ongoing + + # Step 3: wait long enough for the watchdog to wake (TTL=50ms + + # scheduling slack). The fix's early-exit guard must prevent the + # watchdog from popping _pending. + await asyncio.sleep(0.2) + + assert request_id in arb._pending, ( + "watchdog must NOT pop _pending while SpeakResult is still in playback. " + "If this fails, _on_playback_finished will lose game_id lookup and " + "the runoff will stall (see game d57c5d83ed4a day 2)." + ) + + # Step 4: simulate playback completion. _pending lookup must still work, + # so the next candidate gets dispatched. + bufs["jonas"].clear() + bufs["shigemichi"].clear() + intros.clear() + await arb.handle_playback_finished( + PlaybackFinished( + ts=2200, trace_id="t", request_id=request_id, + npc_id="npc_jonas", + started_at_ms=2050, finished_at_ms=2200, + ) + ) + # In production this is wrapped by main.py's _on_playback_finished + # which calls try_dispatch_next — simulate that here. + await arb.try_dispatch_next(g.id) + assert intros == [6], "second tied candidate must be dispatched after first finishes" + assert bufs["shigemichi"], "席6 シゲミチ must receive the second SpeakRequest" + + def test_logic_packet_builder_includes_co_claims_in_summary() -> None: state = PublicDiscussionState( game_id="g", From ef7d552c017977491a8263813e206bf3ec1f10c3 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Fri, 1 May 2026 01:57:56 +0900 Subject: [PATCH 076/133] feat(reactive_voice): claim ledger + split-attack RNG + CO integrity hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today's gameplay analysis surfaced four interlocking failures observed in games a51615d32274 / 98e5a083b5ff. This bundle fixes the root causes and introduces persistent state for the recurring CO-drift class. Claim ledger (per-seat seer/medium history) - ClaimedSeerResult / ClaimedMediumResult on SpeakResult, plus matching fields on SpeechEvent + LLMAction. NPC speech / Master gameplay LLM schemas both gain optional claimed_seer_result / claimed_medium_result so a fake-CO wolf has to commit to a {target_seat, is_wolf} structure every time it announces a new divination. - speech_events DB schema gets four claim columns (additive ALTER per the existing pattern); store insert/load round-trip them with the bool ↔ 0/1/NULL coercion. - New wolfbot.master.claim_history pure aggregator folds every SpeechEvent into per-claimer ClaimerHistory entries. Restart-safe (reads only persisted speech_events). Exposes the day=N → N+1 result count rule via expected_seer_claim_count_for_day(). - LogicPacket.public_state_summary surfaces a "公開された占い/霊媒CO結果 (公式記録)" block built from the aggregator. Every NPC, including the lying wolf, sees the consistent ledger every turn — anchors fake seers to their prior lies. game a51615d32274 day 2 ユリコ silently swapped シゲミチ白 for コメット白 (stolen from ジョナス's claim); this fold makes the swap visibly破綻. - prompt_builder rule: claimed_*_result must match text 1:1, day-N cumulative count must equal N+1 (seer) / executions-so-far (medium). - Viewer surface: GameExport gains claim_history (pre-folded), per- speech-event claimed_* fields, and a new ClaimHistoryPanel with per-claimer chips (通算 X / 期待 Y), real-vs-claim "嘘" badges, and void-result (結果なし) rendering for medium days without execution. Split-attack random resolution - resolve_wolf_attack(rng=...): when both wolves are LLMs and disagree, the function now picks one of the two concrete targets at random instead of returning split=True. Threaded through plan_night_resolve and GameService's existing self.rng. Human-wolf priority still wins before the RNG path. Unblocks the WAITING_HOST_DECISION pause that bricked game 98e5a083b5ff day 2 (SQ→コメット, ユリコ→セツ). - Both-None corrected to clean "no attack" instead of split=True (was always a logical bug — both abstaining isn't a split). - prompt rule rewritten: split is no longer "空振り", it's "Master randomly picks one of the two — coordinating in wolf-chat is still better because random selection doesn't favour your pick over your partner's". CO integrity guard - _resolve_co_role now requires text-vs-structured agreement before accepting co_declaration as authoritative. New _text_contains_self_declaration runs a hand-tuned regex over the role keyword (first-person pronoun proximity OR keyword + declarative verb), with topical/counter-CO prefixes (対抗・別の・他の・誰か) stripped out so "対抗占い師は出ないのか?" can't leak through. Reproduces the exact ラキオ false positive from game 98e5a083b5ff day 1. - Mismatching events fall through to the legacy substring scan and log co_declaration_text_mismatch so we can track LLM-side leakage rates. First-CO counter-CO opportunity window - PublicDiscussionState gains pending_co_response: frozenset[str]. Set when a role's first CO of the game lands; persists for the rest of the phase so the arbiter's pool keeps rotating until every wolf-side seat (and the real role-holder, when a wolf CO'd first) has had one chance to counter-CO or skip. - _compute_callout_pool merges pending_role_callouts with the new pending_co_response, reusing the existing _callout_pool_asked per-game tracker for round-robin within the pool. - Reset condition tightened: asked is only cleared when both pending sources are empty AND no unasked pool members remain — fixes the mid-pool reset that re-picked the same seat on the next dispatch. - prompt rule: 初日朝(死亡者なし)の単独 CO は真として扱う。霊媒結果も 襲撃結果もない時点で疑う公開情報は実質ない、根拠なき疑いは村の最大損失。 Tests: 1180 passed (up from 1138). New coverage: - tests/test_claim_history.py: pure aggregator + LogicPacket render - tests/test_claim_persistence.py: SQLite claim round-trip + parser - tests/test_co_consistency_and_pool.py: self-decl heuristic, fold - tests/test_rules_night_targets.py: RNG split resolution - tests/test_state_machine_nights.py: night-resolve RNG integration - tests/test_reactive_voice_master.py: first-CO pool + mismatch drop 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- src/wolfbot/domain/discussion.py | 52 +++ src/wolfbot/domain/rules.py | 32 +- src/wolfbot/domain/state_machine.py | 13 +- src/wolfbot/domain/ws_messages.py | 60 ++++ src/wolfbot/llm/prompt_builder.py | 39 ++- src/wolfbot/master/claim_history.py | 194 +++++++++++ src/wolfbot/master/logic_service.py | 55 ++++ src/wolfbot/master/speak_arbiter.py | 106 +++++- .../npc/openai_compatible_generator.py | 99 +++++- src/wolfbot/npc/speech_service.py | 71 +++- src/wolfbot/persistence/schema.py | 24 ++ src/wolfbot/services/discussion_service.py | 232 ++++++++++++- src/wolfbot/services/game_export.py | 101 ++++++ src/wolfbot/services/game_export_types.py | 72 +++++ src/wolfbot/services/game_service.py | 1 + src/wolfbot/services/llm_service.py | 107 +++++- tests/test_claim_history.py | 263 +++++++++++++++ tests/test_claim_persistence.py | 278 ++++++++++++++++ tests/test_co_consistency_and_pool.py | 287 ++++++++++++++++ tests/test_llm_structured_output.py | 2 + tests/test_reactive_voice_master.py | 214 +++++++++++- tests/test_rules_night_targets.py | 101 ++++++ tests/test_state_machine_nights.py | 34 ++ viewer/sample-data/export-schema.json | 183 +++++++++++ viewer/src/components/ClaimHistoryPanel.tsx | 305 ++++++++++++++++++ viewer/src/components/GameView.tsx | 6 +- viewer/src/lib/types.ts | 47 +++ 27 files changed, 2933 insertions(+), 45 deletions(-) create mode 100644 src/wolfbot/master/claim_history.py create mode 100644 tests/test_claim_history.py create mode 100644 tests/test_claim_persistence.py create mode 100644 tests/test_co_consistency_and_pool.py create mode 100644 viewer/src/components/ClaimHistoryPanel.tsx diff --git a/src/wolfbot/domain/discussion.py b/src/wolfbot/domain/discussion.py index 7c660c4..0aab1a0 100644 --- a/src/wolfbot/domain/discussion.py +++ b/src/wolfbot/domain/discussion.py @@ -124,6 +124,47 @@ class SpeechEvent(BaseModel): "are the canonical CoDeclaration enum strings." ), ) + claimed_seer_target_seat: int | None = Field( + default=None, + ge=1, + le=9, + description=( + "Target seat the speaker claimed to have divined in this " + "utterance — *real* (true seer) or *fake* (wolf/madman). Set " + "iff the speech announces a NEW divination outcome; null " + "otherwise (general remarks, mere references to prior " + "results). Pairs with ``claimed_seer_is_wolf``. " + "Authoritative source for the per-seat claim history that " + "Master folds back into every subsequent prompt." + ), + ) + claimed_seer_is_wolf: bool | None = Field( + default=None, + description=( + "Black/white verdict the speaker attached to " + "``claimed_seer_target_seat``. None when no seer claim was " + "made this utterance." + ), + ) + claimed_medium_target_seat: int | None = Field( + default=None, + ge=1, + le=9, + description=( + "Target seat (= yesterday's executed seat) the speaker " + "claimed a medium result for. Mirror of the seer fields for " + "medium-CO." + ), + ) + claimed_medium_is_wolf: bool | None = Field( + default=None, + description=( + "Black/white verdict for the medium claim. None when " + "``claimed_medium_target_seat`` is None *or* when the " + "speaker explicitly declared 'no execution yesterday → no " + "result'." + ), + ) created_at_ms: int def is_baseline(self) -> bool: @@ -220,6 +261,17 @@ class PublicDiscussionState(BaseModel): # take it as a CO trigger and wolf-side NPCs can decide whether to # fake CO. pending_role_callouts: frozenset[str] = frozenset() + # Roles whose *first ever* CO just landed in this game. Triggers the + # arbiter's counter-CO opportunity pool: the next dispatches go to + # the real role-holder (when uncpd) plus every wolf-side seat that + # hasn't claimed any info role yet, in random order, one chance + # each. Each member's reply (CO or skip) consumes their slot via + # the arbiter's `_callout_pool_asked` tracker. The pool exhausts + # when every member has been asked, after which normal priority + # resumes — without this window, the village-side first-CO often + # got "free real" treatment because no wolf had a guaranteed turn + # to contest it before the discussion drifted onto other topics. + pending_co_response: frozenset[str] = frozenset() # Per-seat utterance count within this phase (non-baseline events # only). The arbiter prefers seats with the lowest count so a # talkative NPC doesn't monopolize the phase — the binary diff --git a/src/wolfbot/domain/rules.py b/src/wolfbot/domain/rules.py index b2619c0..7f52052 100644 --- a/src/wolfbot/domain/rules.py +++ b/src/wolfbot/domain/rules.py @@ -144,6 +144,7 @@ def resolve_wolf_attack( alive_wolf_seats: Sequence[int], force_skip: bool, human_wolf_seats: Sequence[int] = (), + rng: Random | None = None, ) -> AttackResult: """Determine the night's attack target per spec. @@ -154,8 +155,19 @@ def resolve_wolf_attack( - The `missing` tuple is always reported (even with force_skip) so logs can name who didn't submit. - When 2 wolves disagree, both submitted, and exactly one of them is a human - seat (per `human_wolf_seats`), the human's target wins (no split). All other - split shapes (both human, both LLM) keep the existing split=True behaviour. + seat (per `human_wolf_seats`), the human's target wins (no split). + - When 2 wolves disagree, both submitted, both are LLMs, and ``rng`` is + provided, randomly pick one of the two concrete picks as the resolved + attack target. This unblocks games that previously parked in + ``WAITING_HOST_DECISION`` whenever the wolf-chat coordination LLM + failed to make the two NPCs converge (observed in game + ``98e5a083b5ff`` day 2: SQ→コメット, ユリコ→セツ → host paused + indefinitely with no human able to break the tie). + - When ``rng`` is None and no human-wolf priority applies, the legacy + ``split=True`` shape is returned so unit tests pinning the old + behaviour keep passing. + - If both wolves' picks are ``None`` (= both abstained), the result + stays "no attack" rather than synthesising a target. """ alive = list(alive_wolf_seats) if not alive: @@ -174,6 +186,13 @@ def resolve_wolf_attack( return AttackResult(target_seat=picks.get(wolf), missing=missing) targets = [picks.get(w) for w in alive] + if targets[0] is None and targets[1] is None: + # Both wolves explicitly abstained — that's a unanimous "no + # attack" rather than a split. The legacy code reported + # split=True here because the equality check bailed out on + # ``None``; we promote it to a clean no-attack result so the + # caller doesn't pause the night chasing a phantom split. + return AttackResult(target_seat=None, missing=missing) if targets[0] is not None and targets[0] == targets[1]: return AttackResult(target_seat=targets[0], missing=missing) # Targets differ. Check human-wolf priority: only when exactly 2 alive wolves, @@ -185,6 +204,15 @@ def resolve_wolf_attack( human_target = picks.get(human_seat) if human_target is not None: return AttackResult(target_seat=human_target, missing=missing) + # All-LLM split (or unfilled human-wolf seats with both LLM picks + # diverging). With an RNG seeded by the caller we resolve the split + # by randomly picking one of the two concrete picks; without one we + # fall back to the legacy split=True for callers (mostly tests) that + # haven't started threading an RNG. + concrete_targets = [t for t in targets if t is not None] + if rng is not None and concrete_targets: + chosen = rng.choice(concrete_targets) + return AttackResult(target_seat=chosen, missing=missing) return AttackResult(split=True, missing=missing) diff --git a/src/wolfbot/domain/state_machine.py b/src/wolfbot/domain/state_machine.py index e65232e..232fb1a 100644 --- a/src/wolfbot/domain/state_machine.py +++ b/src/wolfbot/domain/state_machine.py @@ -602,6 +602,7 @@ def plan_night_resolve( previous_guard_seat: int | None, force_skip: bool, now_epoch: int, + rng: Random | None = None, ) -> Transition: """Night resolution in the spec's fixed 10-step order. @@ -609,6 +610,12 @@ def plan_night_resolve( → 5. guard==attack? (no death) → 6. else attack target dies → 7. Morning announce → 8. Permission update (via newly_dead_seats) → 9. Victory check → 10. DAY_DISCUSSION day+1 (or GAME_OVER). + + ``rng`` is forwarded to :func:`wolfbot.domain.rules.resolve_wolf_attack` + so an all-LLM split picks one wolf's target at random instead of + parking the game in ``WAITING_HOST_DECISION``. ``None`` keeps the + legacy split-pause behaviour for tests / callers that aren't yet + threading an RNG. """ seats_by_no = {s.seat_no: s for s in seats} seer_seat = alive_seat_of_role(players, Role.SEER) @@ -627,7 +634,11 @@ def plan_night_resolve( knight_action = next((a for a in actions if a.kind is SubmissionType.KNIGHT_GUARD), None) attack = resolve_wolf_attack( - wolf_actions, wolves, force_skip=force_skip, human_wolf_seats=human_wolf_seats + wolf_actions, + wolves, + force_skip=force_skip, + human_wolf_seats=human_wolf_seats, + rng=rng, ) missing: set[int] = set() diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index ce841f6..c0032d3 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -229,6 +229,45 @@ class SpeakRequest(BaseEnvelope): ) +class ClaimedSeerResult(BaseModel): + """Structured seer-CO result the NPC declares within a single utterance. + + Set on :class:`SpeakResult` when the NPC's `text` announces a new + divination outcome — *real* (true seer) or *fake* (wolf/madman + fake-seer-CO). Identical shape, different ground truth. + + Master persists every claim on the originating SpeechEvent and rolls + them up into a per-seat "claim history" that gets injected back into + every subsequent NPC prompt. That round-trip is what stops a fake + seer from drifting between phases (Day1 「シゲミチ白」 → Day2 results + that silently drop シゲミチ and add a fabricated コメット白). + + The `day` is implicit (= the speech's day field) and not carried here + so the NPC can't accidentally mis-stamp a claim. ``target_name`` is + likewise resolved Master-side from the seat lookup so the NPC never + has to invent it. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + target_seat: int = Field(ge=1, le=9) + is_wolf: bool + + +class ClaimedMediumResult(BaseModel): + """Structured medium-CO result the NPC declares within a single utterance. + + ``is_wolf`` may be ``None`` to encode the explicit "no execution + yesterday → no result today" case so the NPC can claim the void + rather than fabricating a result it doesn't have. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + target_seat: int = Field(ge=1, le=9) + is_wolf: bool | None = None + + class SpeakResult(BaseEnvelope): type: Literal["speak_result"] = "speak_result" request_id: str @@ -247,6 +286,25 @@ class SpeakResult(BaseEnvelope): "generator. Authoritative — Master persists it on SpeechEvent." ), ) + claimed_seer_result: ClaimedSeerResult | None = Field( + default=None, + description=( + "Structured seer-CO result this utterance announces. Non-null " + "iff the NPC's text describes a NEW divination outcome (real " + "seer or fake-CO wolf/madman). Master persists it on the " + "SpeechEvent and folds it into the per-seat claim history " + "every subsequent prompt sees, anchoring fake seers to their " + "prior lies." + ), + ) + claimed_medium_result: ClaimedMediumResult | None = Field( + default=None, + description=( + "Structured medium-CO result this utterance announces. " + "Identical handling to claimed_seer_result; ``is_wolf=null`` " + "encodes the 'no execution yesterday' void case." + ), + ) addressed_seat_no: int | None = Field( default=None, description=( @@ -674,6 +732,8 @@ class HandshakeError(BaseEnvelope): __all__ = [ "BaseEnvelope", + "ClaimedMediumResult", + "ClaimedSeerResult", "HandshakeError", "Heartbeat", "LogicCandidate", diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index 449c1b2..f90fd14 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -68,17 +68,27 @@ def _build_game_rules_block() -> str: "真占い師ではなく人狼の騙りだったと強く判断してよい。\n" "- NIGHT_0 に占い師へ提示されるランダム白は、本物の人狼ではない相手が選ばれる。" "ただし真に村であることは保証されない (狂人の可能性はある)。\n" - "- 人狼同士で夜の襲撃対象の意見が割れた場合、原則として襲撃は空振りになる。" - "ただし生存人狼が 2 人で「片方が人間プレイヤー、片方が LLM 席」の構成だけは例外で、" - "人間プレイヤーの選択がそのまま襲撃対象として採用される " - "(双方人間 / 双方 LLM の不一致は従来どおり不成立)。" - "人狼は人狼専用チャットで襲撃先を 1 人に揃えることを最優先にする。\n" + "- 人狼同士で夜の襲撃対象の意見が割れた場合、Master 側で最終的に必ず 1 つの対象が確定する: " + "(a) 片方が人間プレイヤーで片方が LLM 席のときは人間プレイヤーの選択がそのまま採用される、" + "(b) 双方 LLM の不一致では Master が 2 つの選択肢からランダムに 1 つを採用して襲撃を成立させる、" + "(c) 双方人間で不一致のときも Master がランダムに 1 つを採用する。" + "つまり人狼同士で襲撃先が割れても空振りにはならず、どちらか片方の選択が必ず実行される。" + "意図的に割って撹乱を狙っても、もう片方の人狼の襲撃先が選ばれる確率も同じだけあるため得にならない。" + "狙いを揃えた方が情報役を確実に噛める利点が大きいので、" + "人狼専用チャットで襲撃先を 1 人に揃えることを最優先にする。\n" "- 騎士は同じ相手を連続で護衛できない (前夜と同じ対象は選べない)。\n" "- 投票先や夜行動対象は、プロンプトで提示された合法な候補トークン " "(例: `席3 Alice`) の中からだけ選ぶ。候補外の名前を返してはならない。\n" "- 特定役職 (占い師・霊媒師・騎士) の CO が 1 人だけで、同じ役職への対抗 CO が" "公開ログ上一度も出ていない場合、その単独 CO 者は原則として真の役職者にかなり近い位置として扱う。" "根拠なくその CO 者を処刑候補にしない。\n" + "- **特に初日朝 (まだ誰も死亡していない時点) の単独 CO は、真として扱う。**" + "初日朝はまだ霊媒結果も襲撃結果もなく、対抗 CO が出る時間も十分に残っているため、" + "単独で CO した者を疑う公開情報は実質まだ存在しない。" + "それでも疑う発話・投票を行うと『初日に CO したから怪しい』という根拠にならない疑い方になり、" + "村陣営にとって最大の損失になる。" + "対抗 CO がその後も出ず、判定矛盾・票筋・襲撃結果といった具体的破綻もない限り、" + "初日の単独 CO 者は真置きで進行する。\n" "- ただし単独 CO は絶対確定ではない。公開ログ上の発言破綻・投票矛盾・判定結果の矛盾・" "噛み筋との不整合など、通常より強い根拠がある場合に限り疑ってよい。\n" "- **単独 CO 者を疑う『強い根拠』には、具体的な公開情報の矛盾が必要である。**" @@ -240,6 +250,25 @@ def _build_game_rules_block() -> str: "前夜の能力使用結果として一度言い切った内容は、その後の発言でも同じ対象を同じ色で扱う。" "打ち間違いに気づいた場合のみ、『訂正』『失礼、◯◯でした』などの訂正文言を明示してから直し、" "訂正後の内容を以降の発言でも保持する。\n" + "- プロンプトの『## 公開された占い/霊媒CO結果 (公式記録)』ブロックは、" + "Master が `claimed_seer_result` / `claimed_medium_result` の構造化フィールドから" + "ビルドした各 CO 者の累積発表履歴である。各 CO 者の過去の発表結果はここに完全に残る。\n" + "- 自分が占いCO/霊媒CO 者である場合、新しい結果を発表する発話では" + "この公式記録に列挙された自分の過去結果と完全に整合する内容のみを発表する。" + "過去に発表した対象・色を別の組み合わせに差し替える、過去結果の片方を黙って削って違う対象を追加する、" + "対抗 CO 者の発表内容を自分の結果として取り込む — これらは全て破綻として扱われる。\n" + "- 占い CO 者は day N の朝までに通算 N + 1 件の結果を持つ " + "(NIGHT_0 のランダム白 + 各夜 1 件)。" + "通算件数が記録より少ない/多い結果列を主張すると **数の破綻** として確定し、" + "聞き手はその CO を直ちに切ってよい根拠とする。" + "霊媒 CO 者は処刑があった日数だけ結果を持つ " + "(処刑なしの日は『結果なし』を明言する)。\n" + "- 構造化フィールドと発話内容は必ず一対一で一致させる。" + "新しい占い結果を述べる発話では `claimed_seer_result.target_seat`・`is_wolf` を" + "発話内容と同じ対象・色で必ず設定する。霊媒も同様に `claimed_medium_result` を使う。" + "新しい結果を発表しない発話 (前回までの結果に言及するだけ・他人への質問・一般議論) では" + "両フィールドとも null にする。発話内容と構造化フィールドが食い違うと、" + "Master 側の整合検査で破綻として記録される。\n" "- 同じ対象への判定色が前日と当日で食い違った CO を見つけた場合、" "人間プレイヤーの言い間違い・打ち間違いの可能性が残るため即座に偽 CO 確定とはしない。" "ただし強い偽要素として推理に組み入れ、明示的な訂正文言なしに食い違いが続く場合は、" diff --git a/src/wolfbot/master/claim_history.py b/src/wolfbot/master/claim_history.py new file mode 100644 index 0000000..83b143a --- /dev/null +++ b/src/wolfbot/master/claim_history.py @@ -0,0 +1,194 @@ +"""Per-seat divination/medium claim history. + +Pure aggregation: walk a sequence of :class:`SpeechEvent` rows in +chronological order and group every structured claim by claimer seat. +The result is the canonical "what has each seat publicly claimed as +their seer/medium results so far" view. + +Why this exists +--------------- +A wolf or madman fake-CO seer must keep claiming results consistent +with what they said yesterday. Without a record, the LLM drifts: +real game ``a51615d32274`` (2026-04-30) had Yuriko (wolf, fake seer) +say "シゲミチ白" on day 1, then on day 2 silently drop シゲミチ and +add フ a fabricated "コメット白" picked up from Jonas's earlier claim. +The fix is to surface every prior claim in every subsequent prompt +so the wolf reads its own past lies and either commits to them or +gets caught. + +Determinism / restart safety +---------------------------- +The aggregator reads only :class:`SpeechEvent` rows, so on Master +restart the history rebuilds verbatim from the persisted store. No +per-game cache is required. + +Day-vs-count integrity +---------------------- +A real seer at day-N morning has performed ``N + 1`` divinations +(NIGHT_0 random white at day=0, plus one per night through night +N-1, surfaced morning of day N). A liar must mirror that count or +get caught — :func:`expected_claim_count_for_day` exposes the rule +so the prompt builder and viewer can both reference the same number. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +from wolfbot.domain.discussion import SpeechEvent, SpeechSource + + +@dataclass(frozen=True) +class ClaimedSeerEntry: + """One announced seer divination — real or fake.""" + + day: int + target_seat: int + target_name: str + is_wolf: bool + declared_at_event_id: str + + +@dataclass(frozen=True) +class ClaimedMediumEntry: + """One announced medium result. ``is_wolf=None`` encodes the + explicit "no execution yesterday → no result today" case.""" + + day: int + target_seat: int + target_name: str + is_wolf: bool | None + declared_at_event_id: str + + +@dataclass(frozen=True) +class ClaimerHistory: + """Time-ordered claim list for a single claimer seat.""" + + claimer_seat: int + seer_claims: tuple[ClaimedSeerEntry, ...] = () + medium_claims: tuple[ClaimedMediumEntry, ...] = () + + +@dataclass(frozen=True) +class ClaimHistory: + """Per-claimer fold of every structured seer/medium claim in a game. + + ``by_seat`` is keyed on the claimer's seat number. Iteration order + of seats is sorted ascending so consumers (prompt builder, viewer) + can render a deterministic listing. + """ + + by_seat: Mapping[int, ClaimerHistory] + + +def collect_claim_history( + events: Sequence[SpeechEvent], + *, + seat_names: Mapping[int, str] | None = None, +) -> ClaimHistory: + """Aggregate structured claims per claimer seat. + + Pure: no I/O, no dependencies beyond ``SpeechEvent``. ``seat_names`` + provides the seat → display_name lookup so the rendered entries + carry human-readable target names. Missing names fall back to + ``"席N"`` so the function never errors on incomplete lookups. + + The fold: + + * Skips ``phase_baseline`` sentinels and events without a + ``speaker_seat`` (system messages). + * For each event, attaches a seer entry iff + ``claimed_seer_target_seat`` is set, and a medium entry iff + ``claimed_medium_target_seat`` is set. Both can co-exist on a + single event in the unlikely case where a single utterance + announces both kinds at once. + * Preserves chronological order via the input sequence's order + (callers MUST pass events sorted by ``created_at_ms`` ASC). + """ + name_lookup = dict(seat_names or {}) + + def _name(seat: int) -> str: + return name_lookup.get(seat) or f"席{seat}" + + seer_by_seat: dict[int, list[ClaimedSeerEntry]] = {} + medium_by_seat: dict[int, list[ClaimedMediumEntry]] = {} + + for event in events: + if event.source == SpeechSource.PHASE_BASELINE: + continue + speaker = event.speaker_seat + if speaker is None: + continue + + seer_target = event.claimed_seer_target_seat + seer_verdict = event.claimed_seer_is_wolf + if seer_target is not None and seer_verdict is not None: + seer_by_seat.setdefault(speaker, []).append( + ClaimedSeerEntry( + day=event.day, + target_seat=seer_target, + target_name=_name(seer_target), + is_wolf=seer_verdict, + declared_at_event_id=event.event_id, + ) + ) + + medium_target = event.claimed_medium_target_seat + if medium_target is not None: + medium_by_seat.setdefault(speaker, []).append( + ClaimedMediumEntry( + day=event.day, + target_seat=medium_target, + target_name=_name(medium_target), + is_wolf=event.claimed_medium_is_wolf, + declared_at_event_id=event.event_id, + ) + ) + + seats = sorted(set(seer_by_seat) | set(medium_by_seat)) + by_seat = { + seat: ClaimerHistory( + claimer_seat=seat, + seer_claims=tuple(seer_by_seat.get(seat, ())), + medium_claims=tuple(medium_by_seat.get(seat, ())), + ) + for seat in seats + } + return ClaimHistory(by_seat=by_seat) + + +def expected_seer_claim_count_for_day(day: int) -> int: + """Number of seer results a real seer should have announced by day N. + + The seer performs the NIGHT_0 random white on day 0 and one + divination each subsequent night. The day-N morning therefore + surfaces ``N + 1`` cumulative results (day 0 + nights 0..N-1). + A claimer reporting fewer or more results breaks the invariant. + """ + if day < 0: + return 0 + return day + 1 + + +def expected_medium_claim_count_for_day(executions_so_far: int) -> int: + """Number of medium results expected so far. + + Mediums learn one verdict per execution that has actually + happened. Days without an execution legitimately have no result; + callers count distinct execution days to compare against the + medium claimer's announced result count. + """ + return max(0, executions_so_far) + + +__all__ = [ + "ClaimHistory", + "ClaimedMediumEntry", + "ClaimedSeerEntry", + "ClaimerHistory", + "collect_claim_history", + "expected_medium_claim_count_for_day", + "expected_seer_claim_count_for_day", +] diff --git a/src/wolfbot/master/logic_service.py b/src/wolfbot/master/logic_service.py index 7097a10..3f4407f 100644 --- a/src/wolfbot/master/logic_service.py +++ b/src/wolfbot/master/logic_service.py @@ -25,6 +25,10 @@ from wolfbot.domain.discussion import PublicDiscussionState from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, RecentSpeech +from wolfbot.master.claim_history import ( + ClaimHistory, + expected_seer_claim_count_for_day, +) def _new_packet_id() -> str: @@ -44,6 +48,7 @@ def build_logic_packet( tuple[int, int, tuple[tuple[int, int | None], ...]] ] = (), seat_names: dict[int, str] | None = None, + claim_history: ClaimHistory | None = None, ) -> LogicPacket: """Construct a `LogicPacket` for `recipient_npc_id`. @@ -92,6 +97,56 @@ def _name(seat: int) -> str: # CO trigger; wolf-side NPCs should consider whether to fake CO. callouts_repr = ", ".join(sorted(state.pending_role_callouts)) summary += f" pending_role_callouts=[{callouts_repr}]" + if state.pending_co_response: + # First-CO counter-CO window: a role just got its first claim + # and every uncommitted wolf-side seat (plus the real role-holder + # when the CO'er was wolf-side) is being rotated through the + # priority pool. NPCs in the pool see this as "you're being asked + # now; either counter-CO or skip — the window expires once + # everyone has been asked". + co_response_repr = ", ".join(sorted(state.pending_co_response)) + summary += f" pending_co_response=[{co_response_repr}]" + if claim_history is not None and claim_history.by_seat: + # Public per-claimer divination/medium history. Every NPC sees + # the same record — real roles use it to keep their own past + # results consistent in speech, fake-CO wolves see their own + # prior lies and either commit to them or get caught when they + # contradict themselves. Compact rendering keeps the prompt + # token budget bounded even on day 4+. + summary += "\n\n## 公開された占い/霊媒CO結果 (公式記録)\n" + # Expected count rule: a real seer at day N has claimed N + 1 + # results (NIGHT_0 random white + one per night). The line + # below surfaces it once so the LLM has a numeric anchor. + expected = expected_seer_claim_count_for_day(state.day) + summary += ( + f"(占いCO: 通算 {expected} 件まで整合。これより少ない/多い結果は破綻)\n" + ) + for seat_no in sorted(claim_history.by_seat.keys()): + history = claim_history.by_seat[seat_no] + who = _name(seat_no) + if history.seer_claims: + seer_summary = ", ".join( + f"day{c.day}: {c.target_name}{'黒' if c.is_wolf else '白'}" + for c in history.seer_claims + ) + summary += ( + f"- {who} (占いCO 通算 {len(history.seer_claims)} 件): " + f"{seer_summary}\n" + ) + if history.medium_claims: + medium_summary = ", ".join( + f"day{c.day}: " + + ( + f"{c.target_name}" + + ("黒" if c.is_wolf is True else "白" if c.is_wolf is False else "結果なし") + ) + for c in history.medium_claims + ) + summary += ( + f"- {who} (霊媒CO 通算 {len(history.medium_claims)} 件): " + f"{medium_summary}\n" + ) + summary = summary.rstrip() # Prefer the multi-addressee set; fall back to the legacy singular # field for state objects that haven't been migrated (e.g. test # fixtures that only set `last_addressed_seat`). diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index 6315c14..ec01a0c 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -52,6 +52,7 @@ TtsFinished, ) from wolfbot.llm.prompt_builder import build_strategy_block +from wolfbot.master.claim_history import ClaimHistory, collect_claim_history from wolfbot.master.logic_service import build_logic_packet from wolfbot.master.npc_registry import NpcRegistry from wolfbot.persistence.sqlite_repo import SqliteRepo @@ -326,6 +327,9 @@ async def dispatch_request( seat_names_lookup: dict[int, str] = { seat: name for seat, name in (list(alive_seats) + list(dead_seats)) } + claim_history = await self._load_claim_history( + game_id, seat_names_lookup + ) # Build LogicPacket (sent first so the NPC has context for the # subsequent speak_request). @@ -337,6 +341,7 @@ async def dispatch_request( recent_speeches=recent_speeches, past_votes=past_votes, seat_names=seat_names_lookup, + claim_history=claim_history, ) try: await entry.send(packet.model_dump_json()) @@ -502,6 +507,28 @@ async def _collect_request_context( return recent, alive_seats, dead_seats, role_name, role_strategy + async def _load_claim_history( + self, + game_id: str, + seat_names: dict[int, str], + ) -> ClaimHistory | None: + """Aggregate every persisted seer/medium claim into a per-seat history. + + Reads ``speech_events`` directly via the discussion-service store + so the rebuild matches the canonical SpeechEvent ordering. The + helper is best-effort: a load failure logs and returns ``None`` + so the per-prompt block silently degrades to the legacy "no + history" rendering rather than failing the whole dispatch. + """ + try: + events = await self.discussion.load_for_game(game_id) + except Exception: + log.exception( + "claim_history_load_failed game=%s", game_id, + ) + return None + return collect_claim_history(events, seat_names=seat_names) + async def _load_past_votes( self, game_id: str, current_day: int ) -> tuple[tuple[int, int, tuple[tuple[int, int | None], ...]], ...]: @@ -690,6 +717,17 @@ async def _reject_and_advance(reason: str) -> tuple[bool, str | None]: addressed_candidates = filtered addressed_seat_nos: tuple[int, ...] = tuple(addressed_candidates) addressed_seat_no = addressed_seat_nos[0] if addressed_seat_nos else None + # Self-claim guard: if the NPC's structured claim names its own + # seat we drop it before persisting. The wire model already + # rejects self-target via `_build_claimed_*` on the NPC side, but + # an older NPC binary or a malformed payload could still slip + # through — defending here keeps the claim-history fold clean. + seer_claim = result.claimed_seer_result + if seer_claim is not None and seer_claim.target_seat == pending.seat_no: + seer_claim = None + medium_claim = result.claimed_medium_result + if medium_claim is not None and medium_claim.target_seat == pending.seat_no: + medium_claim = None speech_event = SpeechEvent( event_id=new_event_id(), game_id=pending.game_id, @@ -703,6 +741,18 @@ async def _reject_and_advance(reason: str) -> tuple[bool, str | None]: co_declaration=result.co_declaration, addressed_seat_no=addressed_seat_no, addressed_seat_nos=addressed_seat_nos, + claimed_seer_target_seat=( + seer_claim.target_seat if seer_claim is not None else None + ), + claimed_seer_is_wolf=( + seer_claim.is_wolf if seer_claim is not None else None + ), + claimed_medium_target_seat=( + medium_claim.target_seat if medium_claim is not None else None + ), + claimed_medium_is_wolf=( + medium_claim.is_wolf if medium_claim is not None else None + ), created_at_ms=now, ) await self.discussion.record(speech_event) @@ -884,8 +934,19 @@ async def try_dispatch_next(self, game_id: str) -> None: # exhausts and normal rotation resumes. callout_pool = await self._compute_callout_pool(game_id, state) asked = self._callout_pool_asked.get(game_id, set()) - if not state.pending_role_callouts and asked: - # Callout was consumed (matching CO arrived). Reset asked. + # Reset the asked tracker when there's no active priority signal + # AND no remaining unasked pool members. Without the second leg + # of the conjunction, the reset would fire mid-pool whenever + # ``pending_role_callouts`` cleared (= a matching CO arrived) and + # erase the asked-record while ``pending_co_response`` was still + # rotating wolf-side seats — the just-asked seat would get + # re-picked on the very next dispatch. + unasked_remaining = bool(callout_pool - asked) + no_active_signal = ( + not state.pending_role_callouts + and not state.pending_co_response + ) + if no_active_signal and asked and not unasked_remaining: self._callout_pool_asked.pop(game_id, None) asked = set() effective_pool = callout_pool - asked @@ -1193,24 +1254,31 @@ async def _compute_callout_pool( self, game_id: str, state: PublicDiscussionState ) -> frozenset[int]: """Return seats that should be prioritized when a role callout is - pending and unanswered. + pending and unanswered, OR when a first-CO of an info role just + landed and the counter-CO opportunity window is still open. + + Two trigger sources, combined into one pool: + + - ``state.pending_role_callouts`` — public callouts ("占い師は?") + - ``state.pending_co_response`` — first-CO trigger that opens + a counter-CO window where every wolf-side seat (and the real + role-holder when the CO'er was wolf-side) gets one guaranteed + chance to respond before normal priority resumes. - Triggered by ``state.pending_role_callouts``. Three flavors: + Pool composition for any active role: - - ``"seer"`` / ``"medium"`` / ``"knight"`` — specific role call - (e.g. 「占い師は?」). Pool = real role-holder (alive, uncpd) - + every wolf-side seat (人狼/狂人, alive, no info CO yet). - - ``"info_request"`` — generic info-seeking (「誰か怪しい人?」 - 「みんな意見を聞かせて」「気になる人を挙げて」). Pool = ALL - real info-role holders (seer + medium + knight, alive, uncpd) - + every wolf-side seat (alive, no info CO yet). + - ``"seer"`` / ``"medium"`` / ``"knight"`` — pool = real + role-holder (alive, uncpd) + every wolf-side seat (人狼/狂人, + alive, no info CO yet). + - ``"info_request"`` (callouts only — first-CO doesn't fire + this) — pool = ALL real info-role holders + wolf-side. The arbiter prioritizes pool members (random within pool). Each - member gets at most one chance per callout via the - ``_callout_pool_asked`` tracker; declines fall through to other - pool members until the pool is exhausted. + member gets at most one chance via the ``_callout_pool_asked`` + tracker; declines fall through to other pool members until the + pool is exhausted, after which normal priority resumes. """ - if not state.pending_role_callouts: + if not state.pending_role_callouts and not state.pending_co_response: return frozenset() callout_to_role = { "seer": Role.SEER, @@ -1218,13 +1286,17 @@ async def _compute_callout_pool( "knight": Role.KNIGHT, } # Resolve the set of real roles that should be in the pool. - # `info_request` expands to every info role. + # `info_request` expands to every info role. Both pending sources + # contribute their role keys symmetrically. target_roles: set[Role] = set() has_info_request = "info_request" in state.pending_role_callouts if has_info_request: target_roles |= {Role.SEER, Role.MEDIUM, Role.KNIGHT} for callout_key, role in callout_to_role.items(): - if callout_key in state.pending_role_callouts: + if ( + callout_key in state.pending_role_callouts + or callout_key in state.pending_co_response + ): target_roles.add(role) if not target_roles: return frozenset() diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index 204ebb8..d66dee3 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -62,6 +62,8 @@ "used_logic_ids", "co_declaration", "addressed_seat_nos", + "claimed_seer_result", + "claimed_medium_result", ], "properties": { "text": {"type": "string", "maxLength": 300}, @@ -90,6 +92,42 @@ "consumes them as each replies." ), }, + "claimed_seer_result": { + "type": ["object", "null"], + "additionalProperties": False, + "required": ["target_seat", "is_wolf"], + "properties": { + "target_seat": { + "type": "integer", "minimum": 1, "maximum": 9, + }, + "is_wolf": {"type": "boolean"}, + }, + "description": ( + "Structured seer divination result this utterance " + "announces (real seer OR fake-CO wolf/madman). " + "Non-null IFF `text` describes a NEW divination " + "outcome; null otherwise. Master persists every " + "claim and folds them into a per-seat claim " + "history every subsequent prompt sees, so a wolf " + "fake-CO cannot drift between phases." + ), + }, + "claimed_medium_result": { + "type": ["object", "null"], + "additionalProperties": False, + "required": ["target_seat", "is_wolf"], + "properties": { + "target_seat": { + "type": "integer", "minimum": 1, "maximum": 9, + }, + "is_wolf": {"type": ["boolean", "null"]}, + }, + "description": ( + "Structured medium result this utterance announces. " + "Mirrors claimed_seer_result. ``is_wolf=null`` " + "encodes 'no execution yesterday → no result today'." + ), + }, }, }, } @@ -162,6 +200,18 @@ def _build_system( "`text` は「実は私、占い師なんだ」など自然な名乗りにする。" "CO しないなら `co_declaration=null`。" "「占いCO」のような語そのものは `text` に書かない。\n" + "- **占い/霊媒結果は構造化フィールドにも必ず反映する。**" + "発話 `text` で占い結果を述べる場合 (本物でも騙りでも同じ): " + "`claimed_seer_result` に `{\"target_seat\": 対象席, \"is_wolf\": true/false}` を設定し、" + "述べた席・結果と完全一致させる。霊媒も同様に `claimed_medium_result` を使う" + "(処刑なし/結果なしを明言する場合は `is_wolf=null` を設定し、`target_seat` は処刑対象の席)。" + "新しい結果を発表しない発話 (前回までの結果に言及するだけ・一般議論・他人への質問) では " + "両フィールドとも `null` にする。" + "プロンプト中の `## 公開された占い/霊媒CO結果` ブロックが過去の発表履歴 (公式記録) であり、" + "そこに矛盾する/重複する/数が合わない結果を出すと **破綻判定** され狼/騙り側は即吊られる。" + "占いCO した seat は day N の朝までに通算 N 個の結果を持つ " + "(NIGHT_0 の day0 ランダム白 + 毎晩1個追加; day1 朝なら通算 1 個、day2 朝なら 2 個…)。" + "1 回の発話で複数 seat を占ったと言う「すべて白」「全員白」は破綻なので絶対に出さない。\n" "- 特定の席に向けて話す場合は `addressed_seat_nos` にその席番号の配列を入れる。" "1人だけなら `[3]`、複数人に同時に問いかけるなら `[2, 3]` (例「セツとジナはどう?」)。" "誰宛でもない一般的な発言や全体への呼びかけは空配列 `[]`。" @@ -417,11 +467,13 @@ def _format_candidate(c: LogicCandidate) -> str: - "used_logic_ids": string の配列 (空配列でもよい) - "co_declaration": "seer" | "medium" | "knight" | null - "addressed_seat_nos": integer の配列 (向ける席番号たち。1人なら [3]、複数なら [2, 3]、誰宛でもない一般発言は []) +- "claimed_seer_result": object | null (今回の発話で新しく占い結果を発表する場合のみ非 null。形式は {"target_seat": integer(1-9), "is_wolf": boolean}。本物でも偽でも同じ形式で出す。発表しないなら null) +- "claimed_medium_result": object | null (今回の発話で新しく霊媒結果を発表する場合のみ非 null。形式は {"target_seat": integer(1-9), "is_wolf": boolean | null}。is_wolf=null は「昨日処刑なし」) 例: -{"text": "私もそこは引っかかってた。", "intent": "agree", "used_logic_ids": [], "co_declaration": null, "addressed_seat_nos": []} -{"text": "ジョナスさん、それは矛盾してるよ。", "intent": "accuse", "used_logic_ids": [], "co_declaration": null, "addressed_seat_nos": [3]} -{"text": "セツとジナ、ラキオの主張をどう見る?", "intent": "question", "used_logic_ids": [], "co_declaration": null, "addressed_seat_nos": [2, 3]} +{"text": "私もそこは引っかかってた。", "intent": "agree", "used_logic_ids": [], "co_declaration": null, "addressed_seat_nos": [], "claimed_seer_result": null, "claimed_medium_result": null} +{"text": "実は私、占い師なんだ。昨夜セツを占ったら人狼じゃなかったよ。", "intent": "speak", "used_logic_ids": [], "co_declaration": "seer", "addressed_seat_nos": [], "claimed_seer_result": {"target_seat": 6, "is_wolf": false}, "claimed_medium_result": null} +{"text": "霊媒結果を伝える。昨日処刑されたジョナスは人狼だった。", "intent": "speak", "used_logic_ids": [], "co_declaration": "medium", "addressed_seat_nos": [], "claimed_seer_result": null, "claimed_medium_result": {"target_seat": 2, "is_wolf": true}} """ @@ -750,6 +802,13 @@ def _build_speech_from_json(data: dict[str, object]) -> NpcGeneratedSpeech | Non addressed_seat_nos.append(raw_addr) elif addressed_seat_nos: addressed_seat_no = addressed_seat_nos[0] + + seer_seat, seer_is_wolf = _parse_claim_fields( + data.get("claimed_seer_result"), allow_null_verdict=False, + ) + medium_seat, medium_is_wolf = _parse_claim_fields( + data.get("claimed_medium_result"), allow_null_verdict=True, + ) # Rough estimate: ~150ms per character for TTS estimated_ms = max(500, len(text) * 150) @@ -761,9 +820,42 @@ def _build_speech_from_json(data: dict[str, object]) -> NpcGeneratedSpeech | Non co_declaration=co_declaration, addressed_seat_no=addressed_seat_no, addressed_seat_nos=tuple(addressed_seat_nos), + claimed_seer_target_seat=seer_seat, + claimed_seer_is_wolf=seer_is_wolf, + claimed_medium_target_seat=medium_seat, + claimed_medium_is_wolf=medium_is_wolf, ) +def _parse_claim_fields( + raw: object, *, allow_null_verdict: bool +) -> tuple[int | None, bool | None]: + """Coerce a structured claim dict into ``(target_seat, is_wolf)``. + + Returns ``(None, None)`` for any malformed input — the speech is + still delivered without the structured claim attached, since + rejecting valid speech because of a missing claim object would hurt + liveness more than it helps integrity. + + ``allow_null_verdict=True`` is the medium path (``is_wolf=null`` + encodes "no execution yesterday → no result today"). The seer path + requires a concrete boolean. + """ + if not isinstance(raw, dict): + return (None, None) + target = raw.get("target_seat") + if not isinstance(target, int) or isinstance(target, bool): + return (None, None) + if not 1 <= target <= 9: + return (None, None) + verdict_raw = raw.get("is_wolf") + if isinstance(verdict_raw, bool): + return (target, verdict_raw) + if verdict_raw is None and allow_null_verdict: + return (target, None) + return (None, None) + + __all__ = [ "_RESPONSE_SCHEMA", "OpenAICompatibleConfig", @@ -771,4 +863,5 @@ def _build_speech_from_json(data: dict[str, object]) -> NpcGeneratedSpeech | Non "_build_speech_from_json", "_build_system", "_build_user", + "_parse_claim_fields", ] diff --git a/src/wolfbot/npc/speech_service.py b/src/wolfbot/npc/speech_service.py index d4bb814..81bde9d 100644 --- a/src/wolfbot/npc/speech_service.py +++ b/src/wolfbot/npc/speech_service.py @@ -15,7 +15,13 @@ from typing import Protocol, runtime_checkable from wolfbot.domain.enums import CO_CLAIM_VALUES, CoDeclaration -from wolfbot.domain.ws_messages import LogicPacket, SpeakRequest, SpeakResult +from wolfbot.domain.ws_messages import ( + ClaimedMediumResult, + ClaimedSeerResult, + LogicPacket, + SpeakRequest, + SpeakResult, +) from wolfbot.npc.game_state import NpcGameState log = logging.getLogger(__name__) @@ -30,6 +36,16 @@ class NpcGeneratedSpeech: co_declaration: str | None = None addressed_seat_no: int | None = None addressed_seat_nos: tuple[int, ...] = () + # Structured divination/medium claim attached to this utterance. + # Populated when the LLM declares a NEW seer/medium result (real or + # fake) so Master can build a per-seat claim history that anchors + # future fake seers to their prior lies. None means the speech does + # not announce a new result (general talk, references to prior + # claims, non-seer speech). + claimed_seer_target_seat: int | None = None + claimed_seer_is_wolf: bool | None = None + claimed_medium_target_seat: int | None = None + claimed_medium_is_wolf: bool | None = None @runtime_checkable @@ -144,6 +160,10 @@ async def respond( merged.append(int(speech.addressed_seat_no)) addressed_seat_nos: tuple[int, ...] = tuple(merged) addressed_seat_no = addressed_seat_nos[0] if addressed_seat_nos else None + + claimed_seer = _build_claimed_seer(speech, speaker_seat=request.seat_no) + claimed_medium = _build_claimed_medium(speech, speaker_seat=request.seat_no) + return SpeakResult( ts=now_ms, trace_id=request.trace_id, @@ -156,11 +176,60 @@ async def respond( intent=speech.intent, estimated_duration_ms=speech.estimated_duration_ms, co_declaration=co_declaration, + claimed_seer_result=claimed_seer, + claimed_medium_result=claimed_medium, addressed_seat_no=addressed_seat_no, addressed_seat_nos=addressed_seat_nos, ) +def _build_claimed_seer( + speech: NpcGeneratedSpeech, *, speaker_seat: int +) -> ClaimedSeerResult | None: + """Validate the speech's seer claim and project it onto the wire model. + + Drops the claim defensively when: + * target_seat is missing or out of range, + * is_wolf is missing (a verdict is required for a seer claim), + * the claim targets the speaker themselves (a real seer never + divines their own seat in this ruleset). + + The drop is silent — we still send the speech, just without the + structured claim. Master logs the omission via the absence of a + matching record in the claim-history fold. + """ + seat = speech.claimed_seer_target_seat + verdict = speech.claimed_seer_is_wolf + if seat is None or verdict is None: + return None + if not 1 <= seat <= 9: + return None + if seat == speaker_seat: + return None + return ClaimedSeerResult(target_seat=seat, is_wolf=verdict) + + +def _build_claimed_medium( + speech: NpcGeneratedSpeech, *, speaker_seat: int +) -> ClaimedMediumResult | None: + """Validate the speech's medium claim and project it onto the wire model. + + Mirrors ``_build_claimed_seer`` but allows ``is_wolf=None`` to encode + "no execution yesterday → no result today". Self-target is still + rejected — the only way a medium claims their own seat is via a + coordinated lynching scenario that doesn't apply mid-discussion. + """ + seat = speech.claimed_medium_target_seat + verdict = speech.claimed_medium_is_wolf + if seat is None: + return None + if not 1 <= seat <= 9: + return None + if seat == speaker_seat: + return None + return ClaimedMediumResult(target_seat=seat, is_wolf=verdict) + + __all__ = [ "FakeNpcGenerator", "NpcGeneratedSpeech", diff --git a/src/wolfbot/persistence/schema.py b/src/wolfbot/persistence/schema.py index cf6cc66..572919e 100644 --- a/src/wolfbot/persistence/schema.py +++ b/src/wolfbot/persistence/schema.py @@ -171,6 +171,10 @@ addressed_seat_no INTEGER, addressed_seat_nos_json TEXT, role_callout TEXT, + claimed_seer_target_seat INTEGER, + claimed_seer_is_wolf INTEGER, + claimed_medium_target_seat INTEGER, + claimed_medium_is_wolf INTEGER, created_at_ms INTEGER NOT NULL ) """, @@ -312,6 +316,26 @@ async def migrate(db_path: str | Path) -> None: await db.execute( "ALTER TABLE speech_events ADD COLUMN role_callout TEXT" ) + if "claimed_seer_target_seat" not in cols: + await db.execute( + "ALTER TABLE speech_events " + "ADD COLUMN claimed_seer_target_seat INTEGER" + ) + if "claimed_seer_is_wolf" not in cols: + await db.execute( + "ALTER TABLE speech_events " + "ADD COLUMN claimed_seer_is_wolf INTEGER" + ) + if "claimed_medium_target_seat" not in cols: + await db.execute( + "ALTER TABLE speech_events " + "ADD COLUMN claimed_medium_target_seat INTEGER" + ) + if "claimed_medium_is_wolf" not in cols: + await db.execute( + "ALTER TABLE speech_events " + "ADD COLUMN claimed_medium_is_wolf INTEGER" + ) async with db.execute("PRAGMA table_info(npc_speak_requests)") as cur: cols = {row[1] async for row in cur} if "selection_reason" not in cols: diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py index f40db7d..be54062 100644 --- a/src/wolfbot/services/discussion_service.py +++ b/src/wolfbot/services/discussion_service.py @@ -28,6 +28,7 @@ import json import logging +import re import time import uuid from collections.abc import Iterable, Sequence @@ -99,8 +100,14 @@ async def insert(self, event: SpeechEvent) -> None: event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, alive_seat_nos_json, summary, co_declaration, addressed_seat_no, - addressed_seat_nos_json, role_callout, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + addressed_seat_nos_json, role_callout, + claimed_seer_target_seat, claimed_seer_is_wolf, + claimed_medium_target_seat, claimed_medium_is_wolf, + created_at_ms + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ? + ) """, ( event.event_id, @@ -121,6 +128,10 @@ async def insert(self, event: SpeechEvent) -> None: event.addressed_seat_no, nos_json, event.role_callout, + event.claimed_seer_target_seat, + _bool_to_int(event.claimed_seer_is_wolf), + event.claimed_medium_target_seat, + _bool_to_int(event.claimed_medium_is_wolf), event.created_at_ms, ), ) @@ -132,7 +143,10 @@ async def load_phase(self, game_id: str, phase_id: str) -> Sequence[SpeechEvent] SELECT event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, alive_seat_nos_json, summary, co_declaration, addressed_seat_no, - addressed_seat_nos_json, role_callout, created_at_ms + addressed_seat_nos_json, role_callout, + claimed_seer_target_seat, claimed_seer_is_wolf, + claimed_medium_target_seat, claimed_medium_is_wolf, + created_at_ms FROM speech_events WHERE game_id=? AND phase_id=? ORDER BY created_at_ms ASC, event_id ASC @@ -148,7 +162,10 @@ async def load_for_game(self, game_id: str) -> Sequence[SpeechEvent]: SELECT event_id, game_id, phase_id, day, phase, source, speaker_kind, speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, alive_seat_nos_json, summary, co_declaration, addressed_seat_no, - addressed_seat_nos_json, role_callout, created_at_ms + addressed_seat_nos_json, role_callout, + claimed_seer_target_seat, claimed_seer_is_wolf, + claimed_medium_target_seat, claimed_medium_is_wolf, + created_at_ms FROM speech_events WHERE game_id=? ORDER BY created_at_ms ASC, event_id ASC @@ -159,6 +176,22 @@ async def load_for_game(self, game_id: str) -> Sequence[SpeechEvent]: return [_row_to_event(row) for row in rows] +def _bool_to_int(value: bool | None) -> int | None: + """SQLite has no native boolean — keep the 0/1/NULL convention used + elsewhere in the schema (peaceful_morning, force_skip_pending, etc.) + so the column reads cleanly with `WHERE claimed_seer_is_wolf = 1`. + """ + if value is None: + return None + return 1 if value else 0 + + +def _int_to_bool(value: int | None) -> bool | None: + if value is None: + return None + return bool(value) + + def _row_to_event(row: Any) -> SpeechEvent: nos_json = row[16] addressed_nos: tuple[int, ...] = () @@ -193,7 +226,11 @@ def _row_to_event(row: Any) -> SpeechEvent: addressed_seat_no=row[15], addressed_seat_nos=addressed_nos, role_callout=row[17], - created_at_ms=row[18], + claimed_seer_target_seat=row[18], + claimed_seer_is_wolf=_int_to_bool(row[19]), + claimed_medium_target_seat=row[20], + claimed_medium_is_wolf=_int_to_bool(row[21]), + created_at_ms=row[22], ) @@ -313,6 +350,10 @@ def make_npc_generated_event( addressed_seat_no: int | None = None, addressed_seat_nos: tuple[int, ...] | None = None, role_callout: str | None = None, + claimed_seer_target_seat: int | None = None, + claimed_seer_is_wolf: bool | None = None, + claimed_medium_target_seat: int | None = None, + claimed_medium_is_wolf: bool | None = None, created_at_ms: int | None = None, ) -> SpeechEvent: seat_no, seat_nos = _normalize_addressed(addressed_seat_no, addressed_seat_nos) @@ -330,6 +371,10 @@ def make_npc_generated_event( addressed_seat_no=seat_no, addressed_seat_nos=seat_nos, role_callout=role_callout, + claimed_seer_target_seat=claimed_seer_target_seat, + claimed_seer_is_wolf=claimed_seer_is_wolf, + claimed_medium_target_seat=claimed_medium_target_seat, + claimed_medium_is_wolf=claimed_medium_is_wolf, created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), ) @@ -562,12 +607,19 @@ def apply_speech_event( co_claims = list(state.co_claims) seen_co = {(c.seat, c.role_claim) for c in co_claims} + # Roles already CO'd by *some* seat before this event. Lets us + # detect first-CO-of-role transitions for the counter-CO pool + # trigger below without re-walking the full claim list. + existing_role_keys = {role for (_, role) in seen_co} is_new_co = False # tracks whether THIS event added a fresh CO + fired_first_co_role: str | None = None if speaker is not None: role_key = _resolve_co_role(event) if role_key is not None: key = (speaker, role_key) if key not in seen_co: + if role_key not in existing_role_keys: + fired_first_co_role = role_key seen_co.add(key) co_claims.append( CoClaim( @@ -658,6 +710,19 @@ def apply_speech_event( # CO's any info role, the request is considered partially answered # and the priority pool steps down. pending_role_callouts.discard("info_request") + + # First-CO counter-response window. Adds the role to + # ``pending_co_response`` exactly once per role per phase fold. + # The arbiter's pool dispatch consumes members one-by-one via + # ``_callout_pool_asked``; once everyone in the pool has been + # asked, the empty effective-pool naturally falls through to + # normal priority. The flag itself stays set for the remainder of + # the phase — a 2nd CO of the same role does NOT clear it because + # the per-user request explicitly asks for the pool to keep + # rotating until every member has spoken or skipped. + pending_co_response = set(state.pending_co_response) + if fired_first_co_role is not None: + pending_co_response.add(fired_first_co_role) return PublicDiscussionState( game_id=state.game_id, phase_id=state.phase_id, @@ -676,6 +741,7 @@ def apply_speech_event( last_speaker_seat=last_speaker_seat, recent_speech_summary=tuple(summary), pending_role_callouts=frozenset(pending_role_callouts), + pending_co_response=frozenset(pending_co_response), speech_counts=speech_counts, ) @@ -743,6 +809,7 @@ def rebuild_public_state_from_events( # right after the rebuild via extract_co_claims_from_events. seen_co: set[tuple[int, str]] = set(prior_co_keys) pending_role_callouts: set[str] = set() + pending_co_response: set[str] = set() speech_counts: dict[int, int] = {} last_addressed_seats: frozenset[int] = frozenset() last_addressed_speaker_seat: int | None = None @@ -800,6 +867,13 @@ def rebuild_public_state_from_events( # See integrate_speech_event: info_request is consumed by any # info-role CO, regardless of which specific role was asked. pending_role_callouts.discard("info_request") + # First-CO of role triggers the counter-CO opportunity pool. + # Mirror of ``apply_speech_event`` — fired once per role per + # phase fold (subsequent COs of the same role keep the + # window open until the arbiter exhausts the asked tracker). + existing_role_keys = {role for (_, role) in seen_co} + if role_key not in existing_role_keys: + pending_co_response.add(role_key) if (event.speaker_seat, role_key) in seen_co: continue seen_co.add((event.speaker_seat, role_key)) @@ -823,6 +897,7 @@ def rebuild_public_state_from_events( state.last_speaker_seat = last_speaker_seat state.recent_speech_summary = tuple(summary[-6:]) state.pending_role_callouts = frozenset(pending_role_callouts) + state.pending_co_response = frozenset(pending_co_response) state.speech_counts = speech_counts return state @@ -840,15 +915,160 @@ def rebuild_public_state_from_events( events and human text where natural-language CO has not been pre-tagged. """ +# Role keyword + canonical CO token per role. Used by +# :func:`_text_contains_self_declaration` to verify the structured +# ``co_declaration`` field matches an actual self-naming in ``text``. +_ROLE_KEYWORDS: dict[str, tuple[str, ...]] = { + "seer": ("占い師",), + "medium": ("霊媒師",), + "knight": ("騎士",), +} +_ROLE_CO_TOKENS: dict[str, str] = { + "seer": "占いCO", + "medium": "霊媒CO", + "knight": "騎士CO", +} +# Japanese first-person pronouns used by NPCs / human players. Order +# matters: longer pronouns (``この身``, ``わたくし``) first so the loop +# strips them before partial overlaps catch shorter forms. +_FIRST_PERSON_PRONOUNS: tuple[str, ...] = ( + "わたくし", + "アタシ", + "わたし", + "オレ", + "この身", + "拙者", + "アタイ", + "ボク", + "私", + "僕", + "俺", + "我", + "わて", +) +# Phrases that, when they appear immediately before a role keyword, mean +# the role is being mentioned topically (= about someone else / about +# the concept) rather than self-declared. Stripping them out of the +# text before looking for the self-decl pattern prevents the +# "対抗占い師は出ないのか?" false positive that incorrectly tagged +# game ``98e5a083b5ff`` ラキオ as a seer-CO before he actually CO'd. +_TOPICAL_PREFIXES: tuple[str, ...] = ( + "対抗の", "対抗", "別の", "他の", "もう一人の", "もうひとりの", + "誰か", "他に", +) +# Verb endings that, when they directly follow a role keyword (or the +# canonical ``XCO`` token), turn a bare keyword into a self-declaration +# even without an explicit first-person pronoun. Covers the persona +# voice patterns the existing personas use ("〜だよ" / "〜なのです" / +# "〜なんだ" / "〜やってます" / "〜になります"). +_DECL_VERB_PATTERN = ( + r"(?:です|ですわ|なんだ|だよ|だぜ|なの|なる|なります|" + r"になります|やってる|やってます|として(?:出る|出ます|名乗る|名乗ります)|" + r"に出ます|に出る|だ|ね|よ|わ)" +) + + +def _text_contains_self_declaration(text: str, role: str) -> bool: + """Heuristic: does ``text`` contain a first-person CO declaration of ``role``? + + Used as a sanity check on the structured ``co_declaration`` field. + The NPC LLM occasionally leaks its strategic intent into the + structured field before the natural-language text actually contains + the declaration (game ``98e5a083b5ff`` day 1: ラキオ the madman + posted a question to Stella with ``co_declaration='seer'`` set + even though the text was just "対抗占い師は出ないのか?"). When the + text/structured pair disagree, this helper returns ``False`` so + :func:`_resolve_co_role` falls back to the legacy substring scan + rather than blindly trusting the leaked flag. + + Recognised patterns (any one suffices): + + * **First-person pronoun within ~10 characters of the role keyword.** + Topical / counter-CO-request prefixes ("対抗", "別の", "他の", ...) + are stripped first so "対抗占い師" can't be mistaken for self-decl. + * **Role keyword (or canonical ``XCO`` token) immediately followed + by a declarative verb ending** ("占い師です" / "霊媒CO する" / + "騎士やってます" / ...). The verb whitelist is curated to match + the personas' speech profiles without bleeding into question / + negation forms ("出ないのか" / "ではない" / "誰なんだろう"). + """ + keywords = _ROLE_KEYWORDS.get(role) + co_token = _ROLE_CO_TOKENS.get(role) + if not keywords or co_token is None: + return False + + # Strip topical / counter-CO-request prefixes so "対抗占い師" doesn't + # produce a false positive in either pattern below. + cleaned = text + for prefix in _TOPICAL_PREFIXES: + for kw in keywords: + cleaned = cleaned.replace(prefix + kw, "") + cleaned = cleaned.replace(prefix + co_token, "") + + pronoun_alt = "|".join(re.escape(p) for p in _FIRST_PERSON_PRONOUNS) + for kw in keywords: + # Pattern A: first-person pronoun, optional particle/comma, then + # the role keyword within ~10 chars (skipping whitespace and + # punctuation that don't end a sentence — `。` / newlines do). + pat_a = re.compile( + rf"(?:{pronoun_alt})" + rf"(?:[、,はがこそも自身]{{0,3}})?" + rf"[^。\n]{{0,10}}" + rf"{re.escape(kw)}" + ) + if pat_a.search(cleaned): + return True + # Pattern B: keyword + declarative verb suffix (no first-person + # required — the verb form itself signals self-naming). + pat_b = re.compile( + rf"{re.escape(kw)}[、,]?{_DECL_VERB_PATTERN}" + ) + if pat_b.search(cleaned): + return True + + # Pattern C: canonical ``XCO`` token followed by a declarative verb + # or used as a bare self-marker. ``占いCOがいない`` / ``占いCOについて`` + # are topical and rejected by the trailing-verb requirement. + pat_c = re.compile( + rf"{re.escape(co_token)}(?:します|する|です|なんだ|だよ|だ|に出ます|に出る)" + ) + if pat_c.search(cleaned): + return True + # Pattern D: first-person pronoun followed by ``XCO`` within ~10 chars, + # covering "私こそ占いCO" / "僕、霊媒CO" etc. + pat_d = re.compile( + rf"(?:{pronoun_alt})" + rf"(?:[、,はがこそも自身]{{0,3}})?" + rf"[^。\n]{{0,10}}" + rf"{re.escape(co_token)}" + ) + return bool(pat_d.search(cleaned)) + def _resolve_co_role(event: SpeechEvent) -> str | None: """Pick the CO role for an event, preferring the structured field. Returns one of ``"seer" / "medium" / "knight"`` or ``None`` for "no CO". + + The structured ``co_declaration`` field is treated as authoritative + *only when ``text`` carries a matching self-declaration*. Without + the text guard, the NPC LLM could (and did, in game + ``98e5a083b5ff``) leak its strategic intent into the structured + field while the text was still a question / topical mention, which + falsely flipped the speaker's seat into the public CO ledger one + turn early. When the structured field disagrees with the text, we + drop the flag and fall through to the legacy substring scan so + only the explicit canonical ``占いCO``/``霊媒CO``/``騎士CO`` token + in ``text`` survives. """ declared = event.co_declaration if declared is not None and declared in _VALID_CO_ROLES: - return declared + if _text_contains_self_declaration(event.text, declared): + return declared + log.info( + "co_declaration_text_mismatch dropped event=%s declared=%s text=%r", + event.event_id, declared, event.text[:120], + ) for role_key, marker in _CO_MARKERS: if marker in event.text: return role_key diff --git a/src/wolfbot/services/game_export.py b/src/wolfbot/services/game_export.py index e99fc7b..c5d9d98 100644 --- a/src/wolfbot/services/game_export.py +++ b/src/wolfbot/services/game_export.py @@ -35,6 +35,11 @@ from wolfbot.services.game_export_types import ( ArbiterDecisionEntry, + ClaimedMediumExport, + ClaimedMediumHistoryEntry, + ClaimedSeerExport, + ClaimedSeerHistoryEntry, + ClaimHistoryEntry, DeathCause, DiscussionMode, GameExport, @@ -168,6 +173,8 @@ async def _build_payload( db, "SELECT event_id, day, phase, source, speaker_seat, text, " "stt_confidence, summary, co_declaration, addressed_seat_no, " + "claimed_seer_target_seat, claimed_seer_is_wolf, " + "claimed_medium_target_seat, claimed_medium_is_wolf, " "created_at_ms " "FROM speech_events WHERE game_id = ? " "AND source != 'phase_baseline' " @@ -204,6 +211,7 @@ async def _build_payload( (game_id,), )] + seat_lookup = {s["seat_no"]: s["display_name"] for s in seat_rows} return GameExport( game=_build_game_meta(game_row, public_log_rows), seats=[_build_seat(s) for s in seat_rows], @@ -212,6 +220,7 @@ async def _build_payload( ), trace=_load_trace(trace_root, game_id), arbiter_decisions=[_build_arbiter_decision(r) for r in arbiter_rows], + claim_history=_build_claim_history(speech_event_rows, seat_lookup), ) @@ -377,6 +386,8 @@ def _build_phases( summary=ev["summary"], co_declaration=ev["co_declaration"], addressed_seat_no=ev["addressed_seat_no"], + claimed_seer_result=_build_claimed_seer(ev), + claimed_medium_result=_build_claimed_medium(ev), created_at_ms=ev["created_at_ms"], ) for ev in speech_events @@ -432,6 +443,96 @@ def _infer_victory(public_logs: list[dict[str, Any]]) -> Victory | None: return None +def _build_claimed_seer(ev: dict[str, Any]) -> ClaimedSeerExport | None: + """Lift the persisted seer-claim columns into the export model. + + Requires both ``target_seat`` and ``is_wolf`` to be set. SQLite + stores the verdict as 0/1; the export coerces back to bool here. + """ + target = ev.get("claimed_seer_target_seat") + verdict = ev.get("claimed_seer_is_wolf") + if target is None or verdict is None: + return None + return ClaimedSeerExport(target_seat=int(target), is_wolf=bool(verdict)) + + +def _build_claimed_medium(ev: dict[str, Any]) -> ClaimedMediumExport | None: + """Lift the persisted medium-claim columns into the export model. + + Mirror of :func:`_build_claimed_seer` but ``is_wolf`` may be NULL + to encode "no execution yesterday → no result today" — preserved + as ``None`` so the viewer can render the void. + """ + target = ev.get("claimed_medium_target_seat") + if target is None: + return None + raw_verdict = ev.get("claimed_medium_is_wolf") + verdict = bool(raw_verdict) if raw_verdict is not None else None + return ClaimedMediumExport(target_seat=int(target), is_wolf=verdict) + + +def _build_claim_history( + speech_events: list[dict[str, Any]], + seat_lookup: dict[int, str], +) -> list[ClaimHistoryEntry]: + """Fold the per-event claim columns into a per-claimer ledger. + + Pure projection: every speech_events row already carries the + ``claimed_*`` columns straight from the DB; here we group them by + speaker_seat and sort each group chronologically. The result is + keyed implicitly by ``claimer_seat`` (each entry carries the seat + number) and sorted ascending so the viewer renders a stable list. + """ + seer_by_seat: dict[int, list[ClaimedSeerHistoryEntry]] = {} + medium_by_seat: dict[int, list[ClaimedMediumHistoryEntry]] = {} + + def _name(seat: int) -> str: + return seat_lookup.get(seat) or f"席{seat}" + + for ev in speech_events: + speaker = ev.get("speaker_seat") + if speaker is None: + continue + speaker_seat = int(speaker) + seer_target = ev.get("claimed_seer_target_seat") + seer_verdict = ev.get("claimed_seer_is_wolf") + if seer_target is not None and seer_verdict is not None: + seer_by_seat.setdefault(speaker_seat, []).append( + ClaimedSeerHistoryEntry( + day=int(ev["day"]), + target_seat=int(seer_target), + target_name=_name(int(seer_target)), + is_wolf=bool(seer_verdict), + declared_at_event_id=ev["event_id"], + ) + ) + medium_target = ev.get("claimed_medium_target_seat") + if medium_target is not None: + raw_verdict = ev.get("claimed_medium_is_wolf") + verdict = ( + bool(raw_verdict) if raw_verdict is not None else None + ) + medium_by_seat.setdefault(speaker_seat, []).append( + ClaimedMediumHistoryEntry( + day=int(ev["day"]), + target_seat=int(medium_target), + target_name=_name(int(medium_target)), + is_wolf=verdict, + declared_at_event_id=ev["event_id"], + ) + ) + + seats = sorted(set(seer_by_seat) | set(medium_by_seat)) + return [ + ClaimHistoryEntry( + claimer_seat=seat, + seer_claims=seer_by_seat.get(seat, []), + medium_claims=medium_by_seat.get(seat, []), + ) + for seat in seats + ] + + def _epoch_to_ms(epoch_seconds: int | None) -> int: """Promote an epoch-second timestamp into the viewer's ms unit. diff --git a/src/wolfbot/services/game_export_types.py b/src/wolfbot/services/game_export_types.py index 570556d..a81d30f 100644 --- a/src/wolfbot/services/game_export_types.py +++ b/src/wolfbot/services/game_export_types.py @@ -88,6 +88,33 @@ class PublicLogEntry(BaseModel): created_at_ms: int +class ClaimedSeerExport(BaseModel): + """Structured seer-CO result attached to a single SpeechEvent. + + Real seers and fake-CO wolves share the same shape; the viewer + renders the per-seat history on top of these and the configured + seat roles, so a wolf seat showing 4 seer claims while the day + counter is at 2 is visibly suspicious.""" + + model_config = _StrictConfig + + target_seat: int + is_wolf: bool + + +class ClaimedMediumExport(BaseModel): + """Structured medium-CO result attached to a single SpeechEvent. + + ``is_wolf=null`` encodes the explicit "no execution yesterday → + no result today" case so the viewer can render it as a void + rather than fabricating a target color.""" + + model_config = _StrictConfig + + target_seat: int + is_wolf: bool | None + + class SpeechEventExport(BaseModel): model_config = _StrictConfig @@ -99,9 +126,44 @@ class SpeechEventExport(BaseModel): summary: str | None co_declaration: CoDeclaration | None addressed_seat_no: int | None + claimed_seer_result: ClaimedSeerExport | None = None + claimed_medium_result: ClaimedMediumExport | None = None created_at_ms: int +class ClaimedSeerHistoryEntry(BaseModel): + model_config = _StrictConfig + + day: int + target_seat: int + target_name: str + is_wolf: bool + declared_at_event_id: str + + +class ClaimedMediumHistoryEntry(BaseModel): + model_config = _StrictConfig + + day: int + target_seat: int + target_name: str + is_wolf: bool | None + declared_at_event_id: str + + +class ClaimHistoryEntry(BaseModel): + """Per-seat fold of every seer/medium claim a single seat has + declared, surfaced at the top level of the export so the viewer + can render a per-claimer history table without re-folding the + speech-event stream.""" + + model_config = _StrictConfig + + claimer_seat: int + seer_claims: list[ClaimedSeerHistoryEntry] + medium_claims: list[ClaimedMediumHistoryEntry] + + class VoteExport(BaseModel): model_config = _StrictConfig @@ -222,10 +284,20 @@ class GameExport(BaseModel): phases: list[PhaseSection] trace: list[TraceEntry] arbiter_decisions: list[ArbiterDecisionEntry] = [] + # Game-wide divination/medium claim history, keyed implicitly by + # claimer seat (each entry carries ``claimer_seat``). Pre-folded by + # the exporter so the viewer renders a "claim ledger" panel without + # walking every phase's speech_events. + claim_history: list[ClaimHistoryEntry] = [] __all__ = [ "ArbiterDecisionEntry", + "ClaimHistoryEntry", + "ClaimedMediumExport", + "ClaimedMediumHistoryEntry", + "ClaimedSeerExport", + "ClaimedSeerHistoryEntry", "CoDeclaration", "DeathCause", "DiscussionMode", diff --git a/src/wolfbot/services/game_service.py b/src/wolfbot/services/game_service.py index a66c3f7..5d98385 100644 --- a/src/wolfbot/services/game_service.py +++ b/src/wolfbot/services/game_service.py @@ -418,6 +418,7 @@ async def _plan_next( prev_seat, game.force_skip_pending, now, + rng=self.rng, ) return None diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index 1f6b478..e443d7e 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -31,7 +31,7 @@ from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Literal, Protocol -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from tenacity import ( retry, retry_if_exception_type, @@ -121,6 +121,20 @@ def seat_token(seat: Seat) -> str: # ---------------------------------------------------------------- LLMAction +class _ClaimedSeerAction(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + target_seat: int = Field(ge=1, le=9) + is_wolf: bool + + +class _ClaimedMediumAction(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + target_seat: int = Field(ge=1, le=9) + is_wolf: bool | None = None + + class LLMAction(BaseModel): model_config = ConfigDict(frozen=True) @@ -130,6 +144,13 @@ class LLMAction(BaseModel): reason_summary: str = "" confidence: float = 0.5 co_declaration: CoDeclaration | None = None + # Structured seer/medium claim for the rounds-mode discussion path. + # Mirrors the reactive_voice NPC speech schema so the per-seat + # claim history is built from a single source-of-truth shape across + # both modes. Optional / null when the utterance does not announce + # a new divination outcome. + claimed_seer_result: _ClaimedSeerAction | None = None + claimed_medium_result: _ClaimedMediumAction | None = None RESPONSE_SCHEMA: dict[str, object] = { @@ -145,6 +166,8 @@ class LLMAction(BaseModel): "reason_summary", "confidence", "co_declaration", + "claimed_seer_result", + "claimed_medium_result", ], "properties": { "intent": { @@ -159,6 +182,28 @@ class LLMAction(BaseModel): "type": ["string", "null"], "enum": [*CO_CLAIM_VALUES, None], }, + "claimed_seer_result": { + "type": ["object", "null"], + "additionalProperties": False, + "required": ["target_seat", "is_wolf"], + "properties": { + "target_seat": { + "type": "integer", "minimum": 1, "maximum": 9, + }, + "is_wolf": {"type": "boolean"}, + }, + }, + "claimed_medium_result": { + "type": ["object", "null"], + "additionalProperties": False, + "required": ["target_seat", "is_wolf"], + "properties": { + "target_seat": { + "type": "integer", "minimum": 1, "maximum": 9, + }, + "is_wolf": {"type": ["boolean", "null"]}, + }, + }, }, }, } @@ -180,9 +225,12 @@ class LLMAction(BaseModel): - "target_name": string または null - "reason_summary": string (最大 200 文字) - "confidence": number (0 から 1) +- "co_declaration": "seer" | "medium" | "knight" | null +- "claimed_seer_result": object | null (今回新しく占い結果を発表する場合のみ非 null。形式 {"target_seat": integer(1-9), "is_wolf": boolean}。本物でも騙りでも同じ形式) +- "claimed_medium_result": object | null (霊媒も同様。形式 {"target_seat": integer(1-9), "is_wolf": boolean | null}。is_wolf=null は「昨日処刑なし」) 例: -{"intent": "speak", "public_message": "私は占い師です。", "target_name": null, "reason_summary": "CO 表明", "confidence": 0.7} +{"intent": "speak", "public_message": "私は占い師です。昨夜セツを占ったら人狼じゃなかった。", "target_name": null, "reason_summary": "CO + 結果発表", "confidence": 0.7, "co_declaration": "seer", "claimed_seer_result": {"target_seat": 6, "is_wolf": false}, "claimed_medium_result": null} """ @@ -191,6 +239,20 @@ def _deepseek_json_contract(system_prompt: str) -> str: return system_prompt + _DEEPSEEK_JSON_CONTRACT_SUFFIX +def _claim_to_fields( + claim: _ClaimedSeerAction | _ClaimedMediumAction | None, +) -> tuple[int | None, bool | None]: + """Project the structured claim model onto the persistence pair. + + Returns ``(target_seat, is_wolf)`` so the caller can pass each field + into ``_emit_npc_speech_event`` without having to teach that helper + about the wire model. ``(None, None)`` for missing claims. + """ + if claim is None: + return (None, None) + return (claim.target_seat, claim.is_wolf) + + # ---------------------------------------------------------- low-level deciders class LLMActionDecider(Protocol): async def decide(self, system_prompt: str, user_context: str) -> LLMAction: ... @@ -1484,8 +1546,17 @@ async def _do_one_discussion_speech( ) except Exception: log.exception("PLAYER_SPEECH log insert failed for seat %s", player.seat_no) + seer_seat, seer_verdict = _claim_to_fields(action.claimed_seer_result) + medium_seat, medium_verdict = _claim_to_fields(action.claimed_medium_result) await self._emit_npc_speech_event( - fresh, player.seat_no, message, co_declaration=action.co_declaration + fresh, + player.seat_no, + message, + co_declaration=action.co_declaration, + claimed_seer_target_seat=seer_seat, + claimed_seer_is_wolf=seer_verdict, + claimed_medium_target_seat=medium_seat, + claimed_medium_is_wolf=medium_verdict, ) # --------------------------------------------------- runoff candidate speech @@ -1636,8 +1707,17 @@ async def _do_one_runoff_speech( ) except Exception: log.exception("PLAYER_SPEECH log insert failed for seat %s", player.seat_no) + seer_seat, seer_verdict = _claim_to_fields(action.claimed_seer_result) + medium_seat, medium_verdict = _claim_to_fields(action.claimed_medium_result) await self._emit_npc_speech_event( - fresh, player.seat_no, message, co_declaration=action.co_declaration + fresh, + player.seat_no, + message, + co_declaration=action.co_declaration, + claimed_seer_target_seat=seer_seat, + claimed_seer_is_wolf=seer_verdict, + claimed_medium_target_seat=medium_seat, + claimed_medium_is_wolf=medium_verdict, ) async def _emit_npc_speech_event( @@ -1647,6 +1727,10 @@ async def _emit_npc_speech_event( text: str, *, co_declaration: str | None = None, + claimed_seer_target_seat: int | None = None, + claimed_seer_is_wolf: bool | None = None, + claimed_medium_target_seat: int | None = None, + claimed_medium_is_wolf: bool | None = None, ) -> None: """Persist a SpeechEvent(source=npc_generated) for an LLM utterance. @@ -1658,6 +1742,17 @@ async def _emit_npc_speech_event( """ if self.discussion_service is None: return + # Self-claim guard: an LLM that fakes a CO targeting its own + # seat is a logical contradiction (real seers never divine + # themselves in this ruleset). Drop the structured claim before + # persisting so the per-seat claim-history fold stays clean, + # while still keeping the speech itself. + if claimed_seer_target_seat == speaker_seat: + claimed_seer_target_seat = None + claimed_seer_is_wolf = None + if claimed_medium_target_seat == speaker_seat: + claimed_medium_target_seat = None + claimed_medium_is_wolf = None try: from wolfbot.domain.discussion import make_phase_id from wolfbot.services.discussion_service import ( @@ -1673,6 +1768,10 @@ async def _emit_npc_speech_event( speaker_seat=speaker_seat, text=text, co_declaration=co_declaration, + claimed_seer_target_seat=claimed_seer_target_seat, + claimed_seer_is_wolf=claimed_seer_is_wolf, + claimed_medium_target_seat=claimed_medium_target_seat, + claimed_medium_is_wolf=claimed_medium_is_wolf, ) # Persist-only avoids re-posting the message to Discord (the # rounds-mode path already posted it via MessagePoster) and diff --git a/tests/test_claim_history.py b/tests/test_claim_history.py new file mode 100644 index 0000000..08bf8a8 --- /dev/null +++ b/tests/test_claim_history.py @@ -0,0 +1,263 @@ +"""Tests for the per-seat divination/medium claim aggregator and its +prompt-side rendering through ``build_logic_packet``. + +Coverage map: + +* :mod:`wolfbot.master.claim_history` — pure fold over SpeechEvent. +* :mod:`wolfbot.master.logic_service` — claim block surfacing in + ``LogicPacket.public_state_summary``. +""" + +from __future__ import annotations + +from wolfbot.domain.discussion import ( + PublicDiscussionState, + SpeakerKind, + SpeechEvent, + SpeechSource, +) +from wolfbot.domain.enums import Phase +from wolfbot.master.claim_history import ( + ClaimedMediumEntry, + ClaimedSeerEntry, + collect_claim_history, + expected_medium_claim_count_for_day, + expected_seer_claim_count_for_day, +) +from wolfbot.master.logic_service import build_logic_packet + + +def _speech_event( + *, + seat: int, + day: int, + text: str = "", + seer_target: int | None = None, + seer_is_wolf: bool | None = None, + medium_target: int | None = None, + medium_is_wolf: bool | None = None, + event_id: str | None = None, + created_at_ms: int = 0, +) -> SpeechEvent: + return SpeechEvent( + event_id=event_id or f"ev_{seat}_{day}_{created_at_ms}", + game_id="g1", + phase_id=f"g1::day{day}::DAY_DISCUSSION::1", + day=day, + phase=Phase.DAY_DISCUSSION, + source=SpeechSource.NPC_GENERATED, + speaker_kind=SpeakerKind.NPC, + speaker_seat=seat, + text=text, + claimed_seer_target_seat=seer_target, + claimed_seer_is_wolf=seer_is_wolf, + claimed_medium_target_seat=medium_target, + claimed_medium_is_wolf=medium_is_wolf, + created_at_ms=created_at_ms, + ) + + +def test_collect_claim_history_groups_seer_claims_by_seat() -> None: + events = [ + _speech_event( + seat=2, day=1, seer_target=1, seer_is_wolf=False, created_at_ms=10, + ), + _speech_event( + seat=9, day=1, seer_target=5, seer_is_wolf=False, created_at_ms=20, + ), + _speech_event( + seat=2, day=2, seer_target=8, seer_is_wolf=False, created_at_ms=30, + ), + ] + + history = collect_claim_history(events, seat_names={1: "Comet", 5: "Setsu", 8: "Stella"}) + + assert sorted(history.by_seat.keys()) == [2, 9] + seat2 = history.by_seat[2] + assert seat2.seer_claims == ( + ClaimedSeerEntry( + day=1, + target_seat=1, + target_name="Comet", + is_wolf=False, + declared_at_event_id=events[0].event_id, + ), + ClaimedSeerEntry( + day=2, + target_seat=8, + target_name="Stella", + is_wolf=False, + declared_at_event_id=events[2].event_id, + ), + ) + seat9 = history.by_seat[9] + assert len(seat9.seer_claims) == 1 + assert seat9.seer_claims[0].target_name == "Setsu" + + +def test_collect_claim_history_skips_baselines_and_systemless_events() -> None: + baseline = SpeechEvent( + event_id="baseline", + game_id="g1", + phase_id="g1::day0::DAY_DISCUSSION::1", + day=0, + phase=Phase.DAY_DISCUSSION, + source=SpeechSource.PHASE_BASELINE, + speaker_kind=SpeakerKind.SYSTEM, + speaker_seat=None, + text="", + alive_seat_nos_json="[1,2]", + created_at_ms=0, + ) + speaker_event = _speech_event( + seat=2, + day=1, + seer_target=3, + seer_is_wolf=True, + created_at_ms=10, + ) + + history = collect_claim_history([baseline, speaker_event]) + + # Only the speaker event surfaced; the baseline is silently filtered. + assert list(history.by_seat.keys()) == [2] + + +def test_collect_claim_history_handles_medium_void_result() -> None: + """Medium claims may carry ``is_wolf=None`` to encode 'no execution + yesterday → no result today'. The aggregator must preserve the void.""" + event = _speech_event( + seat=5, + day=2, + medium_target=3, + medium_is_wolf=None, + created_at_ms=100, + ) + + history = collect_claim_history([event], seat_names={3: "Jonas"}) + + seat5 = history.by_seat[5] + assert seat5.medium_claims == ( + ClaimedMediumEntry( + day=2, + target_seat=3, + target_name="Jonas", + is_wolf=None, + declared_at_event_id=event.event_id, + ), + ) + + +def test_collect_claim_history_drops_partial_seer_claims() -> None: + """A seer claim missing ``is_wolf`` is meaningless and the + aggregator drops it rather than guessing a verdict.""" + event = _speech_event( + seat=2, day=1, seer_target=4, seer_is_wolf=None, created_at_ms=5, + ) + + history = collect_claim_history([event]) + + assert history.by_seat == {} + + +def test_expected_seer_claim_count_for_day_follows_n_plus_one_rule() -> None: + assert expected_seer_claim_count_for_day(0) == 1 # NIGHT_0 random white + assert expected_seer_claim_count_for_day(1) == 2 # + night 0 result + assert expected_seer_claim_count_for_day(4) == 5 + # Defensive: negative day clamps to zero so the rule reads "no + # results before the game has begun" rather than blowing up. + assert expected_seer_claim_count_for_day(-1) == 0 + + +def test_expected_medium_claim_count_for_day_returns_executions_so_far() -> None: + assert expected_medium_claim_count_for_day(0) == 0 + assert expected_medium_claim_count_for_day(3) == 3 + # Defensive clamp matches the seer helper. + assert expected_medium_claim_count_for_day(-1) == 0 + + +# ----------------------------------------------------- prompt rendering + + +def _basic_state() -> PublicDiscussionState: + return PublicDiscussionState( + game_id="g1", + phase_id="g1::day1::DAY_DISCUSSION::1", + day=1, + ) + + +def test_logic_packet_summary_includes_claim_history_block() -> None: + """The arbiter passes the per-seat history to ``build_logic_packet``; + the rendered block has to surface the claimer name, claim count, + target name, and verdict glyph (黒/白) for every recorded claim.""" + state = _basic_state() + history = collect_claim_history( + [ + _speech_event( + seat=2, day=1, seer_target=1, seer_is_wolf=False, created_at_ms=10, + ), + _speech_event( + seat=9, day=1, seer_target=5, seer_is_wolf=False, created_at_ms=20, + ), + ], + seat_names={1: "Comet", 5: "Setsu", 2: "Jonas", 9: "Yuriko"}, + ) + + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_yuriko", + expires_at_ms=1000, + now_ms=500, + seat_names={1: "Comet", 2: "Jonas", 5: "Setsu", 9: "Yuriko"}, + claim_history=history, + ) + + summary = packet.public_state_summary + assert "公開された占い/霊媒CO結果" in summary + # Expected count rule surfaces the day-N anchor. + assert "通算 2 件まで整合" in summary + assert "Jonas (占いCO 通算 1 件): day1: Comet白" in summary + assert "Yuriko (占いCO 通算 1 件): day1: Setsu白" in summary + + +def test_logic_packet_summary_omits_block_without_history() -> None: + """Older games and pre-claim-history dispatches must render the + legacy compact summary without the new heading so the prompt + surface stays unchanged for back-compat exports.""" + state = _basic_state() + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_x", + expires_at_ms=1000, + now_ms=500, + seat_names={1: "Alice"}, + claim_history=None, + ) + assert "公開された占い/霊媒CO結果" not in packet.public_state_summary + + +def test_logic_packet_summary_renders_medium_void_as_no_result() -> None: + """Medium claims with ``is_wolf=None`` render as '結果なし' so the + LLM doesn't have to invent a verdict for execution-less days.""" + state = _basic_state() + history = collect_claim_history( + [ + _speech_event( + seat=5, day=2, medium_target=3, medium_is_wolf=None, created_at_ms=100, + ), + ], + seat_names={3: "Jonas", 5: "Shigemichi"}, + ) + + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_shigemichi", + expires_at_ms=1000, + now_ms=500, + seat_names={3: "Jonas", 5: "Shigemichi"}, + claim_history=history, + ) + + assert "Shigemichi (霊媒CO" in packet.public_state_summary + assert "結果なし" in packet.public_state_summary diff --git a/tests/test_claim_persistence.py b/tests/test_claim_persistence.py new file mode 100644 index 0000000..22127b7 --- /dev/null +++ b/tests/test_claim_persistence.py @@ -0,0 +1,278 @@ +"""Round-trip tests for the claim columns on ``speech_events``. + +The claim aggregator (`wolfbot.master.claim_history`) is unit-tested +in :mod:`tests.test_claim_history`; this file pins the persistence +seam so a column rename or a missed migration block fails noisily. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest_asyncio + +from wolfbot.domain.discussion import ( + make_phase_id, +) +from wolfbot.domain.enums import Phase +from wolfbot.domain.ws_messages import ( + ClaimedSeerResult, + LogicPacket, + SpeakRequest, +) +from wolfbot.npc.openai_compatible_generator import _parse_claim_fields +from wolfbot.npc.speech_service import ( + NpcGeneratedSpeech, + NpcSpeechService, +) +from wolfbot.persistence.schema import migrate +from wolfbot.services.discussion_service import ( + SqliteSpeechEventStore, + make_npc_generated_event, +) + + +@pytest_asyncio.fixture +async def store_conn(tmp_path: Path) -> AsyncIterator: + import aiosqlite + + db_path = tmp_path / "speech.db" + await migrate(db_path) + conn = await aiosqlite.connect(str(db_path)) + try: + yield conn + finally: + await conn.close() + + +async def test_speech_event_round_trip_persists_seer_claim(store_conn) -> None: + """A SpeechEvent carrying ``claimed_seer_target_seat`` / + ``claimed_seer_is_wolf`` reads back identically. SQLite's lack of + native bool means the integer ↔ bool coercion has to survive the + round-trip — a regression here would silently drop every claim + on Master restart.""" + store = SqliteSpeechEventStore(store_conn) + phase_id = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + event = make_npc_generated_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="昨夜セツを占って白でした。", + co_declaration="seer", + claimed_seer_target_seat=6, + claimed_seer_is_wolf=False, + created_at_ms=1_700_000_000_000, + ) + + await store.insert(event) + rows = await store.load_phase("g1", phase_id) + + assert len(rows) == 1 + loaded = rows[0] + assert loaded.claimed_seer_target_seat == 6 + assert loaded.claimed_seer_is_wolf is False + assert loaded.claimed_medium_target_seat is None + assert loaded.claimed_medium_is_wolf is None + + +async def test_speech_event_round_trip_persists_medium_void(store_conn) -> None: + """``claimed_medium_is_wolf=None`` (= 'no execution yesterday') + must round-trip through the 0/1/NULL column without being + coerced into ``False``.""" + store = SqliteSpeechEventStore(store_conn) + phase_id = make_phase_id("g2", 2, Phase.DAY_DISCUSSION) + event = make_npc_generated_event( + game_id="g2", + phase_id=phase_id, + day=2, + phase=Phase.DAY_DISCUSSION, + speaker_seat=5, + text="昨日は処刑がなかったので結果は出ません。", + co_declaration="medium", + claimed_medium_target_seat=3, + claimed_medium_is_wolf=None, + created_at_ms=1_700_000_001_000, + ) + + await store.insert(event) + rows = await store.load_phase("g2", phase_id) + + assert rows[0].claimed_medium_target_seat == 3 + assert rows[0].claimed_medium_is_wolf is None + + +async def test_legacy_speech_event_with_no_claim_loads_as_none(store_conn) -> None: + """Pre-claim-column rows have NULL in every claim column. Loading + an event without claims must not produce phantom entries.""" + store = SqliteSpeechEventStore(store_conn) + phase_id = make_phase_id("g3", 1, Phase.DAY_DISCUSSION) + event = make_npc_generated_event( + game_id="g3", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=4, + text="(普通の議論)", + created_at_ms=1_700_000_002_000, + ) + await store.insert(event) + + rows = await store.load_phase("g3", phase_id) + + loaded = rows[0] + assert loaded.claimed_seer_target_seat is None + assert loaded.claimed_seer_is_wolf is None + assert loaded.claimed_medium_target_seat is None + assert loaded.claimed_medium_is_wolf is None + + +# ----------------------------------------------- LLM-output parsing + + +def test_parse_claim_fields_accepts_valid_seer_claim() -> None: + seat, verdict = _parse_claim_fields( + {"target_seat": 5, "is_wolf": True}, allow_null_verdict=False, + ) + assert seat == 5 + assert verdict is True + + +def test_parse_claim_fields_drops_seer_claim_with_null_verdict() -> None: + """Seer verdicts must be a concrete bool. ``null`` is medium-only + semantics and would fabricate a result if accepted.""" + seat, verdict = _parse_claim_fields( + {"target_seat": 5, "is_wolf": None}, allow_null_verdict=False, + ) + assert seat is None + assert verdict is None + + +def test_parse_claim_fields_accepts_medium_void() -> None: + seat, verdict = _parse_claim_fields( + {"target_seat": 3, "is_wolf": None}, allow_null_verdict=True, + ) + assert seat == 3 + assert verdict is None + + +def test_parse_claim_fields_rejects_out_of_range_target() -> None: + seat, verdict = _parse_claim_fields( + {"target_seat": 99, "is_wolf": False}, allow_null_verdict=False, + ) + assert seat is None + assert verdict is None + + +# ----------------------------------------------- speech-service handoff + + +async def test_speech_service_threads_claim_into_speak_result() -> None: + """``NpcSpeechService.respond`` must lift the generated speech's + ``claimed_*`` fields onto the wire model so Master persists them + on the SpeechEvent.""" + from wolfbot.npc.speech_service import FakeNpcGenerator + + speech = NpcGeneratedSpeech( + text="昨夜セツを占って白だった。", + intent="speak", + used_logic_ids=(), + estimated_duration_ms=2000, + co_declaration="seer", + claimed_seer_target_seat=6, + claimed_seer_is_wolf=False, + ) + gen = FakeNpcGenerator(default=speech) + service = NpcSpeechService(gen) + + request = SpeakRequest( + ts=1, + trace_id="t", + request_id="rq", + phase_id="g::day1::DAY_DISCUSSION::1", + npc_id="npc", + seat_no=2, + logic_packet_id="lp", + suggested_intent="speak", + max_chars=300, + max_duration_ms=10000, + priority=0, + expires_at_ms=99, + role="SEER", + role_strategy=None, + alive_seats=((2, "Jonas"), (6, "Setsu")), + dead_seats=(), + ) + logic = LogicPacket( + ts=1, + trace_id="t", + packet_id="lp", + phase_id=request.phase_id, + recipient_npc_id="npc", + public_state_summary="", + expires_at_ms=99, + ) + + result = await service.respond( + logic=logic, request=request, now_ms=2, + ) + + assert result.claimed_seer_result == ClaimedSeerResult( + target_seat=6, is_wolf=False, + ) + assert result.claimed_medium_result is None + + +async def test_speech_service_drops_self_claim() -> None: + """A wolf NPC that names its own seat as the divined target is + self-incriminating gibberish; the service drops the structured + claim before persisting (the speech itself still goes through).""" + from wolfbot.npc.speech_service import FakeNpcGenerator + + speech = NpcGeneratedSpeech( + text="自分を占いました(バグ)", + intent="speak", + used_logic_ids=(), + estimated_duration_ms=2000, + co_declaration="seer", + claimed_seer_target_seat=2, # same as speaker_seat below + claimed_seer_is_wolf=False, + ) + gen = FakeNpcGenerator(default=speech) + service = NpcSpeechService(gen) + + request = SpeakRequest( + ts=1, + trace_id="t", + request_id="rq2", + phase_id="g::day1::DAY_DISCUSSION::1", + npc_id="npc", + seat_no=2, + logic_packet_id="lp", + suggested_intent="speak", + max_chars=300, + max_duration_ms=10000, + priority=0, + expires_at_ms=99, + role="SEER", + role_strategy=None, + alive_seats=((2, "Jonas"),), + dead_seats=(), + ) + logic = LogicPacket( + ts=1, + trace_id="t", + packet_id="lp", + phase_id=request.phase_id, + recipient_npc_id="npc", + public_state_summary="", + expires_at_ms=99, + ) + + result = await service.respond( + logic=logic, request=request, now_ms=2, + ) + + assert result.claimed_seer_result is None # self-target dropped diff --git a/tests/test_co_consistency_and_pool.py b/tests/test_co_consistency_and_pool.py new file mode 100644 index 0000000..520faf6 --- /dev/null +++ b/tests/test_co_consistency_and_pool.py @@ -0,0 +1,287 @@ +"""Tests for the three CO-pipeline fixes shipped 2026-05-01: + +1. ``co_declaration`` text-vs-structured consistency guard + (:func:`wolfbot.services.discussion_service._text_contains_self_declaration`, + :func:`_resolve_co_role`). +2. ``pending_co_response`` first-CO counter-CO opportunity window on + :class:`wolfbot.domain.discussion.PublicDiscussionState`. +3. The arbiter's pool combining ``pending_role_callouts`` with the new + ``pending_co_response`` so wolf-side seats get a guaranteed turn to + counter-CO before normal priority resumes. +""" + +from __future__ import annotations + +from wolfbot.domain.discussion import ( + PublicDiscussionState, + SpeakerKind, + SpeechEvent, + SpeechSource, +) +from wolfbot.domain.enums import Phase +from wolfbot.services.discussion_service import ( + _resolve_co_role, + _text_contains_self_declaration, + rebuild_public_state_from_events, +) + +# --------------------------------------------------------- self-declaration guard + + +def test_self_declaration_accepts_explicit_first_person_phrases() -> None: + """The canonical persona voices used by every NPC must register as + self-declarations so the structured ``co_declaration`` flag is + accepted when paired with matching text.""" + assert _text_contains_self_declaration("実は私、占い師なのです。", "seer") + assert _text_contains_self_declaration("僕こそ占い師だ。", "seer") + assert _text_contains_self_declaration("オレ、霊媒師だ!", "medium") + assert _text_contains_self_declaration("我こそ占い師なり!", "seer") + assert _text_contains_self_declaration("私が騎士です。", "knight") + assert _text_contains_self_declaration("わたくし、霊媒師でございます。", "medium") + + +def test_self_declaration_accepts_canonical_co_token_with_verb() -> None: + """The bot-specific ``XCO`` shorthand counts as a declaration when + followed by a declarative verb (``占いCOします``), matching what + veteran human players actually type.""" + assert _text_contains_self_declaration("占いCOします。", "seer") + assert _text_contains_self_declaration("霊媒COする。", "medium") + + +def test_self_declaration_accepts_keyword_with_declarative_suffix() -> None: + """``占い師です`` / ``霊媒師なの`` etc. — declarative verb endings + glued straight to the role keyword without an explicit pronoun. + Some persona voices skip the pronoun entirely (``setsu`` / ``yuriko``).""" + assert _text_contains_self_declaration("占い師です。", "seer") + assert _text_contains_self_declaration("霊媒師なんだ。", "medium") + assert _text_contains_self_declaration("騎士になります。", "knight") + + +def test_self_declaration_rejects_counter_co_request_question() -> None: + """Reproduces game ``98e5a083b5ff`` day 1 ラキオの誤検知: + 『ステラ、対抗占い師は出ないのか?早く名乗りなさい。』 + ラキオ自身は CO していないのに ``co_declaration='seer'`` が + 立っていた。``対抗占い師`` の topic-mention は self-decl では + ない。""" + assert not _text_contains_self_declaration( + "ステラ、対抗占い師は出ないのか?早く名乗りなさい。", "seer", + ) + assert not _text_contains_self_declaration( + "対抗の占い師、もう出てこないんですか?", "seer", + ) + + +def test_self_declaration_rejects_topical_mentions() -> None: + """Plain "誰か占い師?" / "占い師の方どうぞ" / "占いCOがいない" are + requests / observations about the role, not self-declarations.""" + assert not _text_contains_self_declaration("占い師の方どうぞ", "seer") + assert not _text_contains_self_declaration("誰か占い師は?", "seer") + assert not _text_contains_self_declaration("占いCOがいないのは不自然", "seer") + + +def test_self_declaration_rejects_other_roles_keywords() -> None: + """Asking about ``role=knight`` against a ``占い師`` text must not + leak through — the function is role-scoped on purpose.""" + assert not _text_contains_self_declaration("実は私、占い師なんだ。", "knight") + assert not _text_contains_self_declaration("私が騎士です。", "seer") + + +def _ev(text: str, *, declared: str | None) -> SpeechEvent: + return SpeechEvent( + event_id="ev1", + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + phase=Phase.DAY_DISCUSSION, + source=SpeechSource.NPC_GENERATED, + speaker_kind=SpeakerKind.NPC, + speaker_seat=4, + text=text, + co_declaration=declared, + created_at_ms=10, + ) + + +def test_resolve_co_role_drops_structured_field_when_text_mismatches() -> None: + """Structured ``co_declaration='seer'`` paired with topic-mention + text returns None (no CO) — the guard against the ラキオ leak.""" + event = _ev( + text="ステラ、対抗占い師は出ないのか?早く名乗りなさい。", + declared="seer", + ) + assert _resolve_co_role(event) is None + + +def test_resolve_co_role_accepts_structured_field_when_text_matches() -> None: + event = _ev( + text="僕こそ占い師。昨夜ジョナスを占い、人狼じゃない。", + declared="seer", + ) + assert _resolve_co_role(event) == "seer" + + +def test_resolve_co_role_falls_back_to_legacy_marker_when_text_only() -> None: + """When ``co_declaration`` is None but the legacy canonical marker + appears in text, fall back to the substring scan — keeps the path + open for human-typed messages that pre-date the structured field.""" + event = _ev(text="占いCO入ります", declared=None) + assert _resolve_co_role(event) == "seer" + + +# ----------------------------------------------- first-CO counter-CO window + + +def _baseline(alive: list[int]) -> SpeechEvent: + import json + return SpeechEvent( + event_id="baseline", + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + phase=Phase.DAY_DISCUSSION, + source=SpeechSource.PHASE_BASELINE, + speaker_kind=SpeakerKind.SYSTEM, + speaker_seat=None, + text="", + alive_seat_nos_json=json.dumps(alive), + created_at_ms=0, + ) + + +def _co_event( + *, + seat: int, + text: str, + declared: str, + event_id: str, + ts: int, +) -> SpeechEvent: + return SpeechEvent( + event_id=event_id, + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + phase=Phase.DAY_DISCUSSION, + source=SpeechSource.NPC_GENERATED, + speaker_kind=SpeakerKind.NPC, + speaker_seat=seat, + text=text, + co_declaration=declared, + created_at_ms=ts, + ) + + +def test_pending_co_response_fires_on_first_seer_co() -> None: + """First seer CO of the game adds 'seer' to ``pending_co_response`` + so the arbiter's counter-CO pool fires on the next dispatch.""" + events = [ + _baseline([1, 2, 3, 4, 5, 6, 7, 8, 9]), + _co_event( + seat=6, text="実は私、占い師なのです。", + declared="seer", event_id="e1", ts=10, + ), + ] + + state = rebuild_public_state_from_events(events) + + assert state is not None + assert state.pending_co_response == frozenset({"seer"}) + + +def test_pending_co_response_does_not_re_fire_for_2nd_co_of_role() -> None: + """Per design: a counter-CO arriving while the pool is still + rotating must NOT re-fire the trigger (otherwise the pool would + reset every time a wolf fakes a CO and the rotation never ends). + Once set, ``pending_co_response`` stays set for the phase.""" + events = [ + _baseline([1, 2, 3, 4, 5, 6, 7, 8, 9]), + _co_event( + seat=6, text="実は私、占い師なのです。", + declared="seer", event_id="e1", ts=10, + ), + _co_event( + seat=2, text="僕こそ占い師。", declared="seer", + event_id="e2", ts=20, + ), + ] + + state = rebuild_public_state_from_events(events) + + assert state is not None + assert state.pending_co_response == frozenset({"seer"}) + + +def test_pending_co_response_independent_per_role() -> None: + """First seer CO and first medium CO each fire their own role + key — the pool composition for medium pulls in a different set of + pool members.""" + events = [ + _baseline([1, 2, 3, 4, 5, 6, 7, 8, 9]), + _co_event( + seat=6, text="実は私、占い師なのです。", + declared="seer", event_id="e1", ts=10, + ), + _co_event( + seat=5, text="オレ、霊媒師だ!", + declared="medium", event_id="e2", ts=20, + ), + ] + + state = rebuild_public_state_from_events(events) + + assert state is not None + assert state.pending_co_response == frozenset({"seer", "medium"}) + + +def test_pending_co_response_skips_text_mismatch_co() -> None: + """A leaked-intent event (structured ``co_declaration='seer'`` but + text has no self-decl) is rejected by ``_resolve_co_role``; with + no CO recorded the pool window does NOT open prematurely.""" + events = [ + _baseline([1, 2, 3, 4, 5, 6, 7, 8, 9]), + _co_event( + seat=4, + text="ステラ、対抗占い師は出ないのか?早く名乗りなさい。", + declared="seer", + event_id="e1", + ts=10, + ), + ] + + state = rebuild_public_state_from_events(events) + + assert state is not None + assert state.pending_co_response == frozenset() + # And no co_claims either. + assert state.co_claims == () + + +def test_pending_co_response_idempotent_on_rebuild() -> None: + """The fold is the canonical recovery path on Master restart — it + must produce the same ``pending_co_response`` shape regardless of + how many times it runs over the same event sequence.""" + events = [ + _baseline([1, 2, 3, 4, 5, 6, 7, 8, 9]), + _co_event( + seat=6, text="実は私、占い師なのです。", + declared="seer", event_id="e1", ts=10, + ), + ] + + a = rebuild_public_state_from_events(events) + b = rebuild_public_state_from_events(events) + + assert a is not None and b is not None + assert a.pending_co_response == b.pending_co_response + + +def test_default_pending_co_response_is_empty() -> None: + """Construction default keeps the pool dormant — required so older + callers that don't yet set the field don't accidentally fire + counter-CO rotations.""" + state = PublicDiscussionState( + game_id="g", + phase_id="p", + day=1, + ) + assert state.pending_co_response == frozenset() diff --git a/tests/test_llm_structured_output.py b/tests/test_llm_structured_output.py index 3d5d752..ecd2945 100644 --- a/tests/test_llm_structured_output.py +++ b/tests/test_llm_structured_output.py @@ -67,6 +67,8 @@ def test_response_schema_has_required_fields() -> None: "reason_summary", "confidence", "co_declaration", + "claimed_seer_result", + "claimed_medium_result", } assert schema["additionalProperties"] is False diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index eb11a54..d50bb2a 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -477,6 +477,194 @@ async def test_role_callout_pool_prioritizes_real_role_and_wolf_side( assert reason == "role_callout_pool" +async def test_first_seer_co_fires_counter_co_pool( + repo: SqliteRepo, +) -> None: + """When the real seer is the first to CO with no prior callout, + the next dispatch must still come from the pool — pool = uncpd + wolf-side (the real seer is excluded because they just CO'd). + + This is the user's 2026-05-01 spec: a single-CO situation should + open a guaranteed counter-CO opportunity window so a fake CO from + a wolf has a chance to surface, instead of the village-side + accidentally treating an unchallenged CO as gospel. + """ + g = Game( + id="rv-first-co", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="🎩ジョナス", discord_user_id=None, + is_llm=True, persona_key="jonas"), + Seat(seat_no=2, display_name="🟣ジナ", discord_user_id=None, + is_llm=True, persona_key="gina"), + Seat(seat_no=3, display_name="🦋ラキオ", discord_user_id=None, + is_llm=True, persona_key="raqio"), + Seat(seat_no=4, display_name="☄️コメット", discord_user_id=None, + is_llm=True, persona_key="comet"), + Seat(seat_no=5, display_name="🌙セツ", discord_user_id=None, + is_llm=True, persona_key="setsu"), + Seat(seat_no=6, display_name="👽シゲミチ", discord_user_id=None, + is_llm=True, persona_key="shigemichi"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ( + (1, Role.WEREWOLF), (2, Role.VILLAGER), (3, Role.MADMAN), + (4, Role.VILLAGER), (5, Role.SEER), (6, Role.VILLAGER), + ): + await repo.set_player_role(g.id, sn, role) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3, 4, 5, 6], created_at_ms=1, + ) + ) + # Real seer (seat 5) declares first. No prior callout — the + # window is opened purely by the new ``pending_co_response`` + # mechanism. + from wolfbot.services.discussion_service import make_npc_generated_event + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=5, + text="実は私、占い師なのです。昨夜、ジョナスを占いました。", + co_declaration="seer", + created_at_ms=10, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[int, list[str]] = {n: [] for n in (1, 2, 3, 4, 5, 6)} + persona_by_seat = { + 1: "jonas", 2: "gina", 3: "raqio", + 4: "comet", 5: "setsu", 6: "shigemichi", + } + for seat_no, persona in persona_by_seat.items(): + registry.register( + npc_id=f"npc_{persona}", discord_bot_user_id=f"bot_{persona}", + supported_voices=(), version="1", + send=_captured_send(bufs[seat_no]), + now_ms=2000, persona_key=persona, + ) + registry.assign( + f"npc_{persona}", seat=seat_no, + game_id=g.id, phase_id=phase_id, + ) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 3000, + ) + + await arb.try_dispatch_next(g.id) + + # Pool: real seer (5) is now CO'd → excluded. Uncpd wolf-side = + # ジョナス (1) + ラキオ (3). Villagers 2/4/6 must stay out. + pool_seats = {1, 3} + non_pool_seats = {2, 4, 5, 6} + picked = next( + (s for s, b in bufs.items() if any('"speak_request"' in m for m in b)), + None, + ) + assert picked is not None, "must dispatch someone" + assert picked in pool_seats, ( + f"picked seat {picked} must be in counter-CO pool {pool_seats} " + f"(uncpd wolf-side after the real seer's first CO). " + f"Non-pool seats {non_pool_seats} (villagers + the CO'er) " + f"should NOT be picked." + ) + _, reason = await _fetch_selection_reason(repo, g.id) + assert reason == "role_callout_pool" + + +async def test_first_co_pool_skips_text_mismatch_co( + repo: SqliteRepo, +) -> None: + """A SpeechEvent with structured ``co_declaration='seer'`` whose + text is a counter-CO request rather than a self-declaration must + NOT open the counter-CO pool — the text-vs-structured guard drops + the leaked structured flag, so ``pending_co_response`` stays + empty and dispatch falls through to normal priority. + + Reproduces game ``98e5a083b5ff`` day 1 ラキオの誤検知. + """ + g = Game( + id="rv-first-co-mismatch", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, + is_llm=True, persona_key="raqio"), + Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, + is_llm=True, persona_key="setsu"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.MADMAN) + await repo.set_player_role(g.id, 2, Role.SEER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2], created_at_ms=1, + ) + ) + # Leaked structured flag: text is a counter-CO request, not a + # declaration. The guard must drop the flag → no CO recorded → + # pool stays inactive. + from wolfbot.services.discussion_service import make_npc_generated_event + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="ステラ、対抗占い師は出ないのか?早く名乗りなさい。", + co_declaration="seer", + created_at_ms=10, + ) + ) + + # Rebuild via the canonical fold path (= what restart recovery / + # arbiter prompt build use). ``pending_co_response`` should be + # empty, signalling the guard worked. + from wolfbot.services.discussion_service import ( + rebuild_public_state_from_events, + ) + events = await store.load_phase(g.id, phase_id) + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.pending_co_response == frozenset() + assert state.co_claims == () + + async def test_info_request_callout_expands_pool_to_all_info_roles( repo: SqliteRepo, ) -> None: @@ -1617,9 +1805,14 @@ async def test_try_dispatch_next_prefers_lower_speech_count( # gate stays quiet — the only differentiator left is the per-seat # speech_count. Without that axis seat 1 wins by lowest seat tie- # break; with it seat 3 beats both 4-counts. + # + # No CO is emitted in this fixture because a first-CO would fire + # the counter-CO opportunity pool and override low-count rotation + # — that pool path is exercised separately. Here we want to pin the + # bare ``low_count_rotation`` reason without crossing pool lines. payload = [ (10, 1, "ラキオ1巡目"), # 1:1 - (20, 2, "セツの占いCO"), # 2:1 (CO → has_info=True) + (20, 2, "セツの所感"), # 2:1 (30, 1, "ラキオ反論1"), # 1:2 (40, 2, "セツ反応1"), # 2:2 (50, 1, "ラキオ反論2"), # 1:3 @@ -1634,8 +1827,6 @@ async def test_try_dispatch_next_prefers_lower_speech_count( phase=Phase.DAY_DISCUSSION, speaker_seat=seat, text=text, created_at_ms=ts, ) - if "占いCO" in text: - kwargs["co_declaration"] = "seer" await store.insert(make_npc_generated_event(**kwargs)) # type: ignore[arg-type] registry = InMemoryNpcRegistry() @@ -1714,13 +1905,26 @@ async def test_try_dispatch_next_lru_when_speech_counts_tied( # seat 2 occupies the just-spoke slot at the end so LRU pushes us # back to seat 1. Both seats have count=2 so the count axis is # neutral. + # + # Seat 1's first event carries a knight CO with valid self-decl + # text — chosen because (a) the post-2026-05-01 ``co_declaration`` + # consistency guard accepts it (text + structured field agree), + # (b) the game has no real knight role, so the resulting first-CO + # counter-CO pool is empty (no real role-holder, no other + # uncommitted wolf-side seat), keeping LRU as the operative axis. + # This pads the recent-speech-summary with one ``has_info=True`` + # entry, which silences the pair-volley demotion gate that would + # otherwise demote both seats and reroute through the + # ``all_demoted_fallback`` path. + co_setup = {(10, 1): ("実は私、騎士なんだ。", "knight")} for ts, seat in ((10, 1), (20, 2), (30, 1), (40, 2)): + text, co = co_setup.get((ts, seat), (f"seat {seat} ts={ts}", None)) await store.insert( make_npc_generated_event( game_id=g.id, phase_id=phase_id, day=1, phase=Phase.DAY_DISCUSSION, - speaker_seat=seat, text=f"seat {seat} ts={ts}", - co_declaration="seer" if (ts, seat) == (10, 1) else None, + speaker_seat=seat, text=text, + co_declaration=co, created_at_ms=ts, ) ) diff --git a/tests/test_rules_night_targets.py b/tests/test_rules_night_targets.py index 1201d5d..81caa1b 100644 --- a/tests/test_rules_night_targets.py +++ b/tests/test_rules_night_targets.py @@ -223,6 +223,107 @@ def test_resolve_wolf_attack_human_missing_does_not_invoke_priority() -> None: assert r.split is False +# ---------------------------------- random split resolution (rng=Random()) +def test_resolve_wolf_attack_rng_resolves_all_llm_split() -> None: + """Two LLM wolves split; with an RNG provided Master picks one of the + two targets at random instead of returning ``split=True``. This is the + fix for game ``98e5a083b5ff`` day 2 (SQ→コメット, ユリコ→セツ → + indefinite WAITING_HOST_DECISION pause).""" + from random import Random + + actions = [_attack(1, 5), _attack(2, 6)] + r = resolve_wolf_attack( + actions, + alive_wolf_seats=[1, 2], + force_skip=False, + rng=Random(42), + ) + assert r.target_seat in (5, 6) + assert r.split is False + + +def test_resolve_wolf_attack_rng_picks_each_target_under_repeated_seeds() -> None: + """Sanity-check the random pick actually exercises both branches over + many seeds — guards against a copy-paste regression that always + returned the first wolf's pick.""" + from random import Random + + actions = [_attack(1, 5), _attack(2, 6)] + chosen: set[int | None] = set() + for seed in range(50): + r = resolve_wolf_attack( + actions, + alive_wolf_seats=[1, 2], + force_skip=False, + rng=Random(seed), + ) + chosen.add(r.target_seat) + assert chosen == {5, 6} + + +def test_resolve_wolf_attack_rng_does_not_override_human_priority() -> None: + """Human-wolf priority (1H + 1L disagreement) wins before the random + fallback runs — otherwise a 50/50 coin flip would silently override + the human player's deliberate choice.""" + from random import Random + + actions = [_attack(1, 5), _attack(2, 6)] + r = resolve_wolf_attack( + actions, + alive_wolf_seats=[1, 2], + force_skip=False, + human_wolf_seats=[1], + rng=Random(0), + ) + assert r.target_seat == 5 # human's pick, not RNG'd + assert r.split is False + + +def test_resolve_wolf_attack_rng_handles_partial_none_pick() -> None: + """One wolf abstained (target=None), the other named a target. With + RNG, the concrete target is chosen rather than mixing None into the + random pool (which would produce a 50% no-attack — equivalent to the + old split semantics).""" + from random import Random + + actions = [_attack(1, None), _attack(2, 5)] + r = resolve_wolf_attack( + actions, + alive_wolf_seats=[1, 2], + force_skip=False, + rng=Random(0), + ) + assert r.target_seat == 5 + assert r.split is False + + +def test_resolve_wolf_attack_rng_no_concrete_target_keeps_no_attack() -> None: + """Both wolves abstained (None, None). The RNG has nothing concrete + to pick from — the result stays "no attack" rather than the function + fabricating a target.""" + from random import Random + + actions = [_attack(1, None), _attack(2, None)] + r = resolve_wolf_attack( + actions, + alive_wolf_seats=[1, 2], + force_skip=False, + rng=Random(0), + ) + assert r.target_seat is None + assert r.split is False # not a "split" either — both genuinely abstained + + +def test_resolve_wolf_attack_no_rng_keeps_legacy_split_for_back_compat() -> None: + """When no RNG is threaded, the legacy ``split=True`` shape is + preserved so unit tests / external callers that haven't migrated + keep observing the historical behaviour.""" + actions = [_attack(1, 5), _attack(2, 6)] + r = resolve_wolf_attack(actions, alive_wolf_seats=[1, 2], force_skip=False) + assert r.split is True + assert r.target_seat is None + + def test_random_white_raises_when_pool_empty() -> None: # All non-seer non-wolves are dead (contrived case) players = _players() diff --git a/tests/test_state_machine_nights.py b/tests/test_state_machine_nights.py index e77febb..f7520ea 100644 --- a/tests/test_state_machine_nights.py +++ b/tests/test_state_machine_nights.py @@ -381,6 +381,40 @@ def test_wolf_split_without_force_skip_pauses() -> None: assert t.requires_host_decision is True +def test_wolf_split_with_rng_resolves_into_an_attack_kill() -> None: + """When an RNG is threaded through, an all-LLM split is resolved by + randomly picking one of the two wolves' targets — the night fully + resolves into the next ``DAY_DISCUSSION`` instead of parking in + ``WAITING_HOST_DECISION``. Fix for game ``98e5a083b5ff`` day 2.""" + from random import Random + + game = _game(day=1) + players = _players() + seats = _seats() + actions = [ + _act(1, SubmissionType.WOLF_ATTACK, 7), + _act(2, SubmissionType.WOLF_ATTACK, 8), # split + _act(4, SubmissionType.SEER_DIVINE, 3), + _act(6, SubmissionType.KNIGHT_GUARD, 3), + ] + t = plan_night_resolve( + game, + players, + seats, + actions, + previous_guard_seat=None, + force_skip=False, + now_epoch=1000, + rng=Random(42), + ) + assert t.next_phase is Phase.DAY_DISCUSSION + assert t.requires_host_decision is False + # One of the two wolves' picks landed; the resolver picked one but + # the test stays seed-tolerant by checking the union. + assert set(t.newly_dead_seats).issubset({7, 8}) + assert len(t.newly_dead_seats) == 1 + + def test_wolf_split_records_unresolved_seats_not_missing() -> None: """Split wolves have submitted — they must be classified as `unresolved_seats` (so recovery and /wolf extend can distinguish them from truly missing players).""" diff --git a/viewer/sample-data/export-schema.json b/viewer/sample-data/export-schema.json index 23bf8d7..56b99a2 100644 --- a/viewer/sample-data/export-schema.json +++ b/viewer/sample-data/export-schema.json @@ -195,6 +195,159 @@ "title": "ArbiterDecisionEntry", "type": "object" }, + "ClaimHistoryEntry": { + "additionalProperties": false, + "description": "Per-seat fold of every seer/medium claim a single seat has\ndeclared, surfaced at the top level of the export so the viewer\ncan render a per-claimer history table without re-folding the\nspeech-event stream.", + "properties": { + "claimer_seat": { + "title": "Claimer Seat", + "type": "integer" + }, + "seer_claims": { + "items": { + "$ref": "#/$defs/ClaimedSeerHistoryEntry" + }, + "title": "Seer Claims", + "type": "array" + }, + "medium_claims": { + "items": { + "$ref": "#/$defs/ClaimedMediumHistoryEntry" + }, + "title": "Medium Claims", + "type": "array" + } + }, + "required": [ + "claimer_seat", + "seer_claims", + "medium_claims" + ], + "title": "ClaimHistoryEntry", + "type": "object" + }, + "ClaimedMediumExport": { + "additionalProperties": false, + "description": "Structured medium-CO result attached to a single SpeechEvent.\n\n``is_wolf=null`` encodes the explicit \"no execution yesterday →\nno result today\" case so the viewer can render it as a void\nrather than fabricating a target color.", + "properties": { + "target_seat": { + "title": "Target Seat", + "type": "integer" + }, + "is_wolf": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Wolf" + } + }, + "required": [ + "target_seat", + "is_wolf" + ], + "title": "ClaimedMediumExport", + "type": "object" + }, + "ClaimedMediumHistoryEntry": { + "additionalProperties": false, + "properties": { + "day": { + "title": "Day", + "type": "integer" + }, + "target_seat": { + "title": "Target Seat", + "type": "integer" + }, + "target_name": { + "title": "Target Name", + "type": "string" + }, + "is_wolf": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Wolf" + }, + "declared_at_event_id": { + "title": "Declared At Event Id", + "type": "string" + } + }, + "required": [ + "day", + "target_seat", + "target_name", + "is_wolf", + "declared_at_event_id" + ], + "title": "ClaimedMediumHistoryEntry", + "type": "object" + }, + "ClaimedSeerExport": { + "additionalProperties": false, + "description": "Structured seer-CO result attached to a single SpeechEvent.\n\nReal seers and fake-CO wolves share the same shape; the viewer\nrenders the per-seat history on top of these and the configured\nseat roles, so a wolf seat showing 4 seer claims while the day\ncounter is at 2 is visibly suspicious.", + "properties": { + "target_seat": { + "title": "Target Seat", + "type": "integer" + }, + "is_wolf": { + "title": "Is Wolf", + "type": "boolean" + } + }, + "required": [ + "target_seat", + "is_wolf" + ], + "title": "ClaimedSeerExport", + "type": "object" + }, + "ClaimedSeerHistoryEntry": { + "additionalProperties": false, + "properties": { + "day": { + "title": "Day", + "type": "integer" + }, + "target_seat": { + "title": "Target Seat", + "type": "integer" + }, + "target_name": { + "title": "Target Name", + "type": "string" + }, + "is_wolf": { + "title": "Is Wolf", + "type": "boolean" + }, + "declared_at_event_id": { + "title": "Declared At Event Id", + "type": "string" + } + }, + "required": [ + "day", + "target_seat", + "target_name", + "is_wolf", + "declared_at_event_id" + ], + "title": "ClaimedSeerHistoryEntry", + "type": "object" + }, "GameMeta": { "additionalProperties": false, "properties": { @@ -578,6 +731,28 @@ ], "title": "Addressed Seat No" }, + "claimed_seer_result": { + "anyOf": [ + { + "$ref": "#/$defs/ClaimedSeerExport" + }, + { + "type": "null" + } + ], + "default": null + }, + "claimed_medium_result": { + "anyOf": [ + { + "$ref": "#/$defs/ClaimedMediumExport" + }, + { + "type": "null" + } + ], + "default": null + }, "created_at_ms": { "title": "Created At Ms", "type": "integer" @@ -879,6 +1054,14 @@ }, "title": "Arbiter Decisions", "type": "array" + }, + "claim_history": { + "default": [], + "items": { + "$ref": "#/$defs/ClaimHistoryEntry" + }, + "title": "Claim History", + "type": "array" } }, "required": [ diff --git a/viewer/src/components/ClaimHistoryPanel.tsx b/viewer/src/components/ClaimHistoryPanel.tsx new file mode 100644 index 0000000..8021241 --- /dev/null +++ b/viewer/src/components/ClaimHistoryPanel.tsx @@ -0,0 +1,305 @@ +"use client"; + +/** + * ClaimHistoryPanel — per-seat ledger of every divination/medium claim + * each CO'd seat made in this game. + * + * Why this panel exists + * --------------------- + * Wolves (and the madman) faking a seer/medium CO have to keep their + * announced results consistent across phases. Without a per-seat + * ledger surfaced visibly, drift goes undetected: in game + * `a51615d32274` (2026-04-30) the wolf seer Yuriko declared + * "シゲミチ白" on day 1, then on day 2 silently dropped シゲミチ and + * grafted "コメット白" from her wolf-partner's claim, with nobody at + * the table noticing. This panel stacks every claim against day + + * expected count so the discrepancy reads at a glance: + * + * - Day-N integrity rule (seer): a real seer at day N should have + * N + 1 results (NIGHT_0 random white + one per night). The + * header chip shows "通算 X / 期待 Y" and turns red when X ≠ Y. + * - Per-claim row: day, target, verdict (黒/白). The wolf badge + * contrast against the claimer's actual role makes fake CO obvious + * post-game. + * + * Pure presentation: reads `data.claim_history` (pre-folded by the + * exporter) plus `data.seats` for role/name resolution. Older exports + * (pre-2026-05-01) lack `claim_history` and the panel collapses to a + * compact "no claims recorded" state rather than blowing up. + */ + +import * as React from "react"; +import Box from "@mui/material/Box"; +import Chip from "@mui/material/Chip"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import type { + ClaimHistoryEntry, + ClaimedMediumHistoryEntry, + ClaimedSeerHistoryEntry, + GameSample, + Seat, +} from "@/lib/types"; + +const CO_LABEL: Record = { + seer: "占い", + medium: "霊媒", + knight: "騎士", +}; + +export default function ClaimHistoryPanel({ data }: { data: GameSample }) { + const history = data.claim_history ?? []; + if (history.length === 0) { + return ( + + CO 結果履歴 + + このゲームには記録された占い/霊媒 CO 結果がありません。 + + + ); + } + + const seatLookup = new Map(data.seats.map((s) => [s.seat_no, s])); + const dayCount = computeDayCount(data); + + return ( + + + CO 結果履歴 + + + 各 CO 者が公表した占い/霊媒結果の累積。期待件数と一致しない場合、 + 嘘 CO の可能性が高い。 + + + {history.map((entry) => ( + + ))} + + + ); +} + +function ClaimerRow({ + entry, + claimer, + seatLookup, + currentDay, +}: { + entry: ClaimHistoryEntry; + claimer: Seat | undefined; + seatLookup: Map; + currentDay: number; +}) { + // Real-role guard: a wolf/madman/villager whose declared role does + // not match their seat role is rendering the panel's value most. + // The role chip on the header carries that contrast so post-game + // readers see "Yuriko (狼) — 占い 通算 1 件" without scanning seats. + const seerCount = entry.seer_claims.length; + const mediumCount = entry.medium_claims.length; + const expectedSeer = expectedSeerCountForDay(currentDay); + // Medium count == executions seen so far; we don't have a clean way + // to count executions per game in the viewer, so we render the raw + // count without a mismatch chip rather than risk a false positive. + const seerMismatch = seerCount > 0 && seerCount !== expectedSeer; + + return ( + + + + {claimer?.display_name ?? `席${entry.claimer_seat}`} + + {claimer && ( + + )} + {seerCount > 0 && ( + + )} + {mediumCount > 0 && ( + + )} + + + {entry.seer_claims.map((c, idx) => ( + + ))} + {entry.medium_claims.map((c, idx) => ( + + ))} + + + ); +} + +function SeerClaimRow({ + claim, + seatLookup, + label, +}: { + claim: ClaimedSeerHistoryEntry; + seatLookup: Map; + label: string; +}) { + const target = seatLookup.get(claim.target_seat); + // Real-vs-claim mismatch: a wolf was claimed white when the target + // is actually a wolf (or vice versa). Surfaces the cleanest "this + // CO is lying" tell straight in the row without forcing the user + // to cross-reference roles manually. + const truthIsWolf = target ? target.role === "WEREWOLF" : null; + const lieFlag = + truthIsWolf !== null && truthIsWolf !== claim.is_wolf; + + return ( + + + + {label} + + + {claim.target_name}{" "} + + {claim.is_wolf ? "黒" : "白"} + + + {lieFlag && ( + + )} + + ); +} + +function MediumClaimRow({ + claim, + seatLookup, + label, +}: { + claim: ClaimedMediumHistoryEntry; + seatLookup: Map; + label: string; +}) { + const target = seatLookup.get(claim.target_seat); + const truthIsWolf = target ? target.role === "WEREWOLF" : null; + // Medium claims with `is_wolf=null` mean "no execution yesterday"; + // we don't flag those as lies even if the target's role is a wolf + // — a void result is silence, not a falsehood. + const lieFlag = + claim.is_wolf !== null && + truthIsWolf !== null && + truthIsWolf !== claim.is_wolf; + const verdict = + claim.is_wolf === null + ? "結果なし" + : claim.is_wolf + ? "黒" + : "白"; + const verdictColor = + claim.is_wolf === null ? "#888" : claim.is_wolf ? "#d32f2f" : "#1976d2"; + return ( + + + + {label} + + + {claim.target_name}{" "} + {verdict} + + {lieFlag && ( + + )} + + ); +} + +function expectedSeerCountForDay(day: number): number { + // Mirrors `wolfbot.master.claim_history.expected_seer_claim_count_for_day`: + // the seer surfaces N + 1 cumulative results at day N's morning + // (NIGHT_0 random white + one per night). Day 0 = 1, day 1 = 2… + if (day < 0) return 0; + return day + 1; +} + +function computeDayCount(data: GameSample): number { + // The exporter doesn't carry an explicit "current day" field, so we + // infer from the highest day_number that appears anywhere in the + // phase log. Empty games default to 0 (= day 1's morning baseline). + let max = 0; + for (const phase of data.phases) { + if (phase.day > max) max = phase.day; + } + return max; +} + +function isWolfSide(role: string): boolean { + return role === "WEREWOLF" || role === "MADMAN"; +} + +const ROLE_LABEL: Record = { + VILLAGER: "村人", + WEREWOLF: "人狼", + MADMAN: "狂人", + SEER: "占い師", + MEDIUM: "霊媒師", + KNIGHT: "騎士", +}; + +function roleLabel(role: string): string { + return ROLE_LABEL[role] ?? role; +} diff --git a/viewer/src/components/GameView.tsx b/viewer/src/components/GameView.tsx index 8331240..c8ffec6 100644 --- a/viewer/src/components/GameView.tsx +++ b/viewer/src/components/GameView.tsx @@ -8,6 +8,7 @@ import Chip from "@mui/material/Chip"; import Container from "@mui/material/Container"; import Stack from "@mui/material/Stack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import ClaimHistoryPanel from "@/components/ClaimHistoryPanel"; import GameHeader from "@/components/GameHeader"; import PhaseSection from "@/components/PhaseSection"; import SeatGrid from "@/components/SeatGrid"; @@ -72,7 +73,10 @@ export default function GameView({ ))} - + + + + setOpenTrace(null)} /> diff --git a/viewer/src/lib/types.ts b/viewer/src/lib/types.ts index ee1f738..065d4d3 100644 --- a/viewer/src/lib/types.ts +++ b/viewer/src/lib/types.ts @@ -38,6 +38,16 @@ export interface PublicLog { created_at_ms: number; } +export interface ClaimedSeerResult { + target_seat: number; + is_wolf: boolean; +} + +export interface ClaimedMediumResult { + target_seat: number; + is_wolf: boolean | null; +} + export interface SpeechEvent { event_id: string; source: SpeechSource; @@ -47,9 +57,39 @@ export interface SpeechEvent { summary: string | null; co_declaration: CoDeclaration; addressed_seat_no: number | null; + /** + * Structured seer-CO result attached to this utterance. Non-null when + * the speaker announced a NEW divination outcome (real seer or wolf + * fake-CO). The viewer renders the per-seat history (see + * `GameSample.claim_history`) on top of these. + */ + claimed_seer_result?: ClaimedSeerResult | null; + claimed_medium_result?: ClaimedMediumResult | null; created_at_ms: number; } +export interface ClaimedSeerHistoryEntry { + day: number; + target_seat: number; + target_name: string; + is_wolf: boolean; + declared_at_event_id: string; +} + +export interface ClaimedMediumHistoryEntry { + day: number; + target_seat: number; + target_name: string; + is_wolf: boolean | null; + declared_at_event_id: string; +} + +export interface ClaimHistoryEntry { + claimer_seat: number; + seer_claims: ClaimedSeerHistoryEntry[]; + medium_claims: ClaimedMediumHistoryEntry[]; +} + export interface Vote { day: number; round: number; @@ -154,4 +194,11 @@ export interface GameSample { phases: PhaseSection[]; trace: TraceEntry[]; arbiter_decisions?: ArbiterDecision[]; + /** + * Per-claimer ledger of every structured seer/medium claim, pre-folded + * by the exporter so the viewer renders the "claim integrity" panel + * without walking each phase's `speech_events`. Older exports (pre- + * 2026-05-01) lack the field; the viewer treats absence as `[]`. + */ + claim_history?: ClaimHistoryEntry[]; } From bc2105b773da75789344ca2416b317c071b2ec7e Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Fri, 1 May 2026 02:03:15 +0900 Subject: [PATCH 077/133] fix(reactive_voice): document NIGHT_0 has no attack/guard so day 1 morning peace isn't read as a GJ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Game a58c5fdb2dd2 day 1 had two seats interpret the structurally guaranteed 「平和な朝」 as a successful guard: - ラキオ (knight): "昨夜誰も襲われなかった以上、誰かが守られたはずだ" - ジナ (madman): "昨夜誰も襲われなかったのは、守られたからかしら" Both readings are wrong: NIGHT_0 has no wolf attack and no knight guard — only the seer's NIGHT_0 random white. Day 1's peaceful morning is unconditional, not evidence of anything. Adds an explicit rule to `_build_game_rules_block` stating: - NIGHT_0 has no attack and no guard; only the seer's random white runs. - Day 1 morning is structurally always peaceful and tells the village nothing about guards / attack failure / GJ. - Treating day 1 peace as guard-related is a structural-rule violation (= 破綻発言). - Wolves still exist on day 1 morning (potitioned but not attacking yet); they're not "untouched / unsuspectable" just because no one died yet. GJ / 護衛成功 / 襲撃失敗 reasoning only becomes meaningful from day 2 morning onwards. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- src/wolfbot/llm/prompt_builder.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index f90fd14..d6382a3 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -68,6 +68,18 @@ def _build_game_rules_block() -> str: "真占い師ではなく人狼の騙りだったと強く判断してよい。\n" "- NIGHT_0 に占い師へ提示されるランダム白は、本物の人狼ではない相手が選ばれる。" "ただし真に村であることは保証されない (狂人の可能性はある)。\n" + "- **NIGHT_0 (= 初日夜・ゲーム開始直後の最初の夜) には人狼の襲撃も騎士の護衛も発生しない。**" + "発生する夜行動は占い師の初回ランダム白だけで、それ以外の役職は何も行動しない。" + "そのため day 1 (1日目) の朝は構造的に必ず『平和な朝』になり、" + "『平和な朝』が **守られたことの根拠にも襲撃失敗の根拠にも一切ならない**。" + "day 1 朝の平和は『誰かが護衛した』『騎士のGJ』『襲撃が失敗した』のいずれの解釈とも結びつかない。" + "そのような解釈・推理を発話に出すと **構造ルール違反として破綻**として扱われる。\n" + "- 人狼は day 1 の夜 (= NIGHT_1) から襲撃を行い、騎士も day 1 の夜から護衛できるようになる。" + "GJ (グッジョブ) や護衛成功・襲撃失敗の議論が初めて意味を持つのは day 2 の朝以降である。" + "day 1 朝の時点ではまだ夜行動の結果は何も発生していないが、" + "**人狼 2 名は最初から村に存在している** (NIGHT_0 で襲撃しないだけで、潜伏中)。" + "『初日朝に死んでいないから人狼はまだ動いていない / 人狼疑いの根拠が無い』というのも誤りで、" + "人狼は day 1 の議論・投票で吊り逃れを狙って発言・誘導している前提で疑い始めてよい。\n" "- 人狼同士で夜の襲撃対象の意見が割れた場合、Master 側で最終的に必ず 1 つの対象が確定する: " "(a) 片方が人間プレイヤーで片方が LLM 席のときは人間プレイヤーの選択がそのまま採用される、" "(b) 双方 LLM の不一致では Master が 2 つの選択肢からランダムに 1 つを採用して襲撃を成立させる、" From ae7618a0f06da662996621743abbe448b58384dd Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Fri, 1 May 2026 02:20:49 +0900 Subject: [PATCH 078/133] fix(reactive_voice): per-recipient nudge when CO'd seat is short on results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Game 100b9e88e75a day 2 had Shigemichi (wolf, day-1 fake-seer-CO of "ラキオ白") repeat the day-1 result without producing the NIGHT_1 result expected by the count rule: 02:10:57 day2 シゲミチ: "オレの占い結果、もう一度言うぜ!ラキオ白だ!..." claimed_seer_result=null The system-prompt rule that day-2+ CO'd seats must publish the prior night's result in their first utterance was already in `_build_game_rules_block` and `_ROLE_STRATEGIES[Role.WEREWOLF]`, but buried mid-strategy. The public record block surfaced "通算 1 件 / 期待 2 件" — accurate but the LLM consistently failed to recognise it as "this applies to me". Fix: when the dispatched recipient is themselves in `claim_history` with `seer_claims_count < expected_for_day(day)`, append a per- recipient directive section to `LogicPacket.public_state_summary`: ## 【あなた宛 / 緊急】占いCO 結果の発表が不足しています あなたは占いCO 者で、現在の発表結果は通算 N 件、本日 dayD 朝の 期待値は M 件 ... 未発表が (M-N) 件あります。 **この発話で前夜 (NIGHT_D) の新しい占い結果を必ず発表し、 claimed_seer_result に {target_seat, is_wolf} を構造化して入れて ください**。過去の結果 (...) を再表明するだけでは整合しません。 Threads `recipient_seat_no` through `build_logic_packet` — already known by `SpeakArbiter.dispatch_request` from the candidate selection loop. Same surface added for medium claimers (softer wording, since the audit games haven't shown medium-side drift at the same severity yet). This is a targeted nudge layered on top of the existing common-rules language; the global "cumulative-count must equal day+1" rule stays authoritative, the per-recipient block just makes the gap impossible to ignore on the turn the LLM speaks. Tests: 1180 passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- src/wolfbot/master/logic_service.py | 53 +++++++++++++++++++++++++++++ src/wolfbot/master/speak_arbiter.py | 1 + 2 files changed, 54 insertions(+) diff --git a/src/wolfbot/master/logic_service.py b/src/wolfbot/master/logic_service.py index 3f4407f..2957c23 100644 --- a/src/wolfbot/master/logic_service.py +++ b/src/wolfbot/master/logic_service.py @@ -49,6 +49,7 @@ def build_logic_packet( ] = (), seat_names: dict[int, str] | None = None, claim_history: ClaimHistory | None = None, + recipient_seat_no: int | None = None, ) -> LogicPacket: """Construct a `LogicPacket` for `recipient_npc_id`. @@ -147,6 +148,58 @@ def _name(seat: int) -> str: f"{medium_summary}\n" ) summary = summary.rstrip() + # Per-recipient nudge when the addressee themselves is a CO'd + # claimer whose announced result count lags the day-N expected + # count. Without this, observation games (a51615d32274 day 2 + # ユリコ, 100b9e88e75a day 2 シゲミチ) showed the wolf seer + # repeating the day-1 result instead of producing the night-1 + # result on day-2 morning, even though the count rule lives in + # the system prompt — the LLM consistently failed to act on + # the gap. Surface it here as a direct "this applies to YOU" + # instruction so the model can't deflect to general advice. + if ( + recipient_seat_no is not None + and recipient_seat_no in claim_history.by_seat + and state.day >= 1 + ): + recipient_history = claim_history.by_seat[recipient_seat_no] + recipient_seer_count = len(recipient_history.seer_claims) + if ( + recipient_seer_count > 0 + and recipient_seer_count < expected + ): + missing = expected - recipient_seer_count + summary += ( + f"\n\n## 【あなた宛 / 緊急】占いCO 結果の発表が不足しています\n" + f"あなたは占いCO 者で、現在の発表結果は通算 " + f"{recipient_seer_count} 件、本日 day{state.day} 朝の" + f"期待値は {expected} 件 (NIGHT_0 + 各夜 1 件)。" + f"未発表が {missing} 件あります。" + f"**この発話で前夜 (NIGHT_{state.day}) の新しい占い結果を必ず発表し、" + f"`claimed_seer_result` に `{{target_seat, is_wolf}}` を構造化" + f"して入れてください**。" + f"過去の結果 ({', '.join(c.target_name for c in recipient_history.seer_claims)}) " + f"を再表明するだけでは整合しません。" + f"対象は前夜 NIGHT_{state.day} の開始時点で生存していた相手から選ぶこと。" + ) + # Same gating logic for medium claimers: if the seat has + # CO'd as medium and yesterday's execution exists in the + # public log, they should produce yesterday's medium + # result. We don't have a clean execution-count signal in + # this packet (the audit games haven't shown medium drift + # at the same severity yet), so a softer cumulative-count + # instruction is enough — refining when we observe the + # failure mode in real games. + recipient_medium_count = len(recipient_history.medium_claims) + if recipient_medium_count > 0 and state.day >= 2: + summary += ( + f"\n\n## 【あなた宛】霊媒CO 結果の発表確認\n" + f"あなたは霊媒CO 者で、現在の発表結果は通算 " + f"{recipient_medium_count} 件。day{state.day} 朝の時点で、" + f"昨日 (day{state.day - 1}) の処刑があった場合は" + f"その霊媒結果を `claimed_medium_result` に入れて発表する義務があります。" + f"処刑がなかった日は `is_wolf=null` で「結果なし」を明言してください。" + ) # Prefer the multi-addressee set; fall back to the legacy singular # field for state objects that haven't been migrated (e.g. test # fixtures that only set `last_addressed_seat`). diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index ec01a0c..b0fcf73 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -342,6 +342,7 @@ async def dispatch_request( past_votes=past_votes, seat_names=seat_names_lookup, claim_history=claim_history, + recipient_seat_no=seat_no, ) try: await entry.send(packet.model_dump_json()) From 8c73cb696f7e571d9b915c41d9b3423ed41e4c5b Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Fri, 1 May 2026 09:17:02 +0900 Subject: [PATCH 079/133] fix(reactive_voice): inject _build_game_rules_block into NPC system prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major gap surfaced by game 8ccc86215e97: every reactive_voice NPC had been receiving a system prompt with the canonical game-rules block ENTIRELY ABSENT. The rounds-mode gameplay LLM gets the block via the markdown template's `{game_rules_block}` placeholder, so editing `prompt_builder._build_game_rules_block()` correctly affected vote/ night-action decisions. The reactive_voice NPC, however, builds its system prompt programmatically in `openai_compatible_generator._build_system` and historically composed only: - persona display name + style_guide - speech_profile + judgment_profile - role + role-specific strategy - output-format rules (text length, no meta language, etc.) The 30+ game rules — NIGHT_0 has no attack/guard, day-1 single-target seer, 黒/白 binary (no 第3の色 / no 「村人確定」), count integrity, single-CO trust, split-attack random resolution, 3-1 progression, 2-wolf-pair inference, etc. — were silent. Symptoms in 8ccc86215e97 day 1: - ラキオ (knight): "昨夜誰も襲われなかった以上、誰かが守った可能性が高い。 僕が守ったんじゃないかって思うよ。" — NIGHT_0 has no guard. - ユリコ (wolf, fake-CO seer): two results in one morning "シゲミチ占い、村人確定" + "ステラ。人狼確定" — violates day-1 single-target rule, uses forbidden "村人/人狼確定" terminology (must be 黒/白 binary), and gives a black result on day-1 morning (forbidden — NIGHT_0 timeline conflict). Trace verification: has_night0_no_attack_rule: False has_kuro_shiro_binary_rule: False has_day1_single_target_rule: False has_count_consistency_rule: True (this one was in _build_system) Fix: thread `_build_game_rules_block()` into the NPC system prompt between the judgment_profile and role blocks. Renamed the existing "## ルール" header to "## 出力ルール" to disambiguate from the new "## ゲームルール" section. Prompt size grows ~7.9KB → ~15.7KB, which sits comfortably under every supported provider's context window. All 1180 tests still passing — the persona / speech_profile / role- strategy isolation tests verify the system prompt's higher-level shape but don't assert the ABSENCE of the game-rules block, so this addition is non-breaking. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../npc/openai_compatible_generator.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index d66dee3..c074fdf 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -42,6 +42,7 @@ from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, SpeakRequest from wolfbot.llm.persona_base import Persona from wolfbot.llm.prompt_builder import ( + _build_game_rules_block, build_judgment_profile_block, build_speech_profile_block, ) @@ -152,6 +153,23 @@ def _build_system( SpeakRequest, the NPC sees its role and the role-specific strategy block. Older Master builds that don't send them produce a prompt that silently omits the role section (back-compat). + + Game-rules block: rounds-mode injects ``_build_game_rules_block()`` + via the markdown template (``{game_rules_block}`` placeholder) so + the gameplay LLM sees the canonical 30+ rule list (NIGHT_0 has no + attack/guard, day-1 single-target seer rule, 黒/白 binary, count + integrity, single-CO trust, etc.). The reactive_voice NPC builds + its prompt programmatically here, and historically *omitted* the + block entirely — meaning every prompt rule edit to + ``prompt_builder._build_game_rules_block`` since reactive_voice + landed has only affected the gameplay LLM (votes/night actions), + never the persona LLM that actually speaks. Game ``8ccc86215e97`` + surfaced the bug: Rakio (knight) day-1 falsely claimed "I guarded + someone last night" (NIGHT_0 has no guard), Yuriko (wolf) day-1 + fake-CO'd seer with **two** results in one morning ("シゲミチ村人 + 確定" + "ステラ人狼確定") — both forbidden by rules that simply + weren't in the prompt. Injecting the block here closes the gap so + every NPC sees the same canonical ruleset. """ role_block = "" if role: @@ -165,9 +183,10 @@ def _build_system( f"性格: {persona.style_guide}\n\n" f"## 話法\n{build_speech_profile_block(persona)}\n\n" f"## 判断のクセ\n{build_judgment_profile_block(persona)}\n\n" + f"## ゲームルール\n{_build_game_rules_block()}\n\n" f"{role_block}" f"{strategy_block}" - "## ルール\n" + "## 出力ルール\n" "- 日本語のみ。メタ発言禁止。AIであることに言及しない。\n" f"- `text` は {max_chars} 文字以内の短い発言。" f"上限ぎりぎりまで埋めようとせず、必ず文を最後まで言い切ること。" From fb59f82302024e1be1239ef328d206ad9fee9fec Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Fri, 1 May 2026 19:09:06 +0900 Subject: [PATCH 080/133] fix(prompt): teach LLM that attacked seats are non-wolf (HARD fact) A recent reactive_voice game (game_id 6f348c75284c) showed wolf seat Raqio publicly claiming the player his team had just attacked (Gina, the seer) was a wolf, and three village-team seats agreeing without catching the structural impossibility (wolves don't attack wolves). Add the missing rule to the shared game-rules block plus per-role strategy sections so: - werewolf seats don't bluff "the seat I attacked was a wolf" - madman doesn't reinforce that bluff (would expose madman line) - village seats (seer/medium/knight/villager) treat such a claim as a HARD fact that the speaker is the wolf --- src/wolfbot/llm/prompt_builder.py | 46 ++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index d6382a3..1bdc28c 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -88,6 +88,13 @@ def _build_game_rules_block() -> str: "意図的に割って撹乱を狙っても、もう片方の人狼の襲撃先が選ばれる確率も同じだけあるため得にならない。" "狙いを揃えた方が情報役を確実に噛める利点が大きいので、" "人狼専用チャットで襲撃先を 1 人に揃えることを最優先にする。\n" + "- **夜の襲撃で死亡した席は本物の人狼ではない (人狼は仲間を襲わないため、" + "公開ログで `(襲撃)` 表示の死亡席は構造的に非狼確定)。**" + "誰かが公の場で『襲撃された席は人狼だった』『襲撃で沈んだ者こそ狼だった』のように主張した場合、" + "それは構造ルールに反する明白な嘘であり、その発言者を強い人狼候補 (騙りや狂人を含む狼陣営位置) として扱ってよい。" + "また聞き手側がこの嘘に同調・追認する発話 (『なるほど襲撃された◯◯は狼だったんだね』等) も" + "村陣営にとっては破綻発言で、その発言者の信用も落ちる。" + "霊媒結果より優先される HARD ファクトとして扱う。\n" "- 騎士は同じ相手を連続で護衛できない (前夜と同じ対象は選べない)。\n" "- 投票先や夜行動対象は、プロンプトで提示された合法な候補トークン " "(例: `席3 Alice`) の中からだけ選ぶ。候補外の名前を返してはならない。\n" @@ -543,6 +550,16 @@ def _build_game_rules_block() -> str: "「噛み筋で自分と相方のラインが濃くならないか」を比較して決める。\n" "- 公開発言では実際の相方を知っているからこそ出てしまう視点漏れを絶対に出さない。" "相方語彙は人狼チャットや夜行動の私的領域だけで使う。\n" + "- **自分達が前夜に襲撃した相手 (公開ログで `(襲撃)` 死亡として表示される席) を、" + "翌日以降に『あれは狼だった』『自分の推理が当たった、襲撃された◯◯は狼だ』のように" + "公の場で主張してはならない。**" + "人狼は仲間を襲わないという構造ルール上、襲撃死=非狼が公開情報として確定しており、" + "そのような発言は熟練者目線で即座に狼確定として処刑される。" + "相方の発話がこの嘘に踏み込んだ場合も、無批判に追認・補強しない " + "(『お前は人狼をよくわかっているな』『襲撃で沈んだのが答えだ』等の同調は二重破綻で" + "自分も相方も同時に狼ラインに乗る)。" + "襲撃対象の死を語るときは『情報役を潰す動きが見えた』『襲撃価値の高い位置が落ちた』のように、" + "対象を狼と確定させない言い回しに留める。\n" "- さらに精密に、占い師・霊媒師・騎士の対抗 CO 超過分 (各役職の CO 数 - 1、下限 0) を集計する。" "超過分合計が 3 に達した時点で、能力役職 CO していない位置は村陣営の確白級として扱われ、処刑候補が CO 群に集中する。" "騙りに出るか潜伏するかは、現在の各 CO 数と残り縄を見て、相方と整合する形で選ぶ。" @@ -639,7 +656,12 @@ def _build_game_rules_block() -> str: "- さらに精密に、占い師・霊媒師・騎士の対抗 CO 超過分 (各役職の CO 数 - 1、下限 0) を集計する。" "超過分合計が 3 に達した時点で、能力役職 CO していない位置は村陣営の確白級として扱われ、" "処刑候補が CO 群に集中するリスクを認識する。" - "騙りに出るか潜伏するかは、公開情報の各 CO 数と残り縄から判断する。" + "騙りに出るか潜伏するかは、公開情報の各 CO 数と残り縄から判断する。\n" + "- **公開ログで `(襲撃)` 死亡となっている席は構造上の非狼確定 (人狼は仲間を襲わない)。**" + "他者がこの席を『狼だった』と公言してもそれは破綻発言なので、" + "あなたが追認・補強すると村陣営からは『破綻発言に乗る怪しい位置=狂人ライン』として透ける。" + "狼支援としての発話は、襲撃死を狼扱いする路線ではなく、" + "真占い・真霊媒へのもっともらしい疑い・別位置への灰圧の方向で行う。" ), Role.SEER: ( "- 自分の判定履歴を時系列で一貫して扱う。過去の白黒と矛盾する発言はしない。\n" @@ -695,7 +717,11 @@ def _build_game_rules_block() -> str: "ただし非狼確定として数えるのは、印象白や自分以外の占い CO の単発白ではなく、" "霊媒結果・襲撃死・CO 破綻など説明可能な根拠に限る。\n" "- 対抗 CO 超過分合計が 3 に達して能力役職 CO していない位置が非 CO 確白級になった場合、" - "そこを無駄占いせず、対抗 CO 群やまだ確定しない位置を優先して占う。" + "そこを無駄占いせず、対抗 CO 群やまだ確定しない位置を優先して占う。\n" + "- **公開ログで `(襲撃)` 死亡となっている席は構造上の非狼確定。**" + "誰かが『襲撃された◯◯は狼だった』と公言した場合、それは破綻発言で、" + "あなたの判定履歴と整合しなくてもその発言者を強い狼候補として処理する。" + "占い師として整理発言を引っ張るときも、襲撃死を狼扱いする路線には乗らない。" ), Role.MEDIUM: ( "- 処刑結果と占い師の主張・投票の流れを照合し、占い視点の真贋を見極める。\n" @@ -745,7 +771,10 @@ def _build_game_rules_block() -> str: "2 人非狼確定が揃ったら、残る占い師 CO を確定黒級として処刑提案・投票誘導の材料にしてよい。\n" "- 霊媒結果は対抗 CO 超過分の CO 数推理を更新する材料にする。" "処刑された CO 者が黒なら対抗 CO 群内の狼数を絞り、白なら真役職または狂人の可能性を分け、" - "非 CO 確白の前提が保たれるかを確認する。" + "非 CO 確白の前提が保たれるかを確認する。\n" + "- **公開ログで `(襲撃)` 死亡となっている席は構造上の非狼確定 (人狼は仲間を襲わない)。**" + "あなたの霊媒結果も白側で必ず整合する。誰かが『襲撃された◯◯は狼だった』と公言したら破綻発言で、" + "あなたの霊媒結果より優先する HARD ファクトとして扱い、その発言者を強い狼候補にする。" ), Role.KNIGHT: ( "- 守る価値の高い情報役 (真占い・真霊媒) や、信頼されている位置を護衛対象として意識する。\n" @@ -802,7 +831,10 @@ def _build_game_rules_block() -> str: "ただし非狼確定の 2 人を数える基準は印象白ではなく霊媒結果・襲撃死・CO 破綻などに限り、" "前提が崩れたら護衛方針も再整理する。\n" "- 対抗 CO 超過分合計 3 で生まれた非 CO 確白級や、単独で対抗のない真寄り情報役は護衛価値が高い。" - "ただし連続護衛不可、襲撃読み、CO 時の説明可能性も合わせて判断する。" + "ただし連続護衛不可、襲撃読み、CO 時の説明可能性も合わせて判断する。\n" + "- **公開ログで `(襲撃)` 死亡となっている席は構造上の非狼確定 (人狼は仲間を襲わない)。**" + "誰かが『襲撃された◯◯は狼だった』と公言したら破綻発言で、その発言者を強い狼候補として扱い、" + "発話・投票で同調しない。" ), Role.VILLAGER: ( "- 公開発言の矛盾、視点漏れ、投票理由、占い/霊媒結果との整合性を重視して推理する。\n" @@ -817,6 +849,12 @@ def _build_game_rules_block() -> str: "整合性で白さを取る。\n" "- 情報役を守り、人狼陣営が狙いやすい位置 (真 CO、盤面整理役) を" "投票で落とさないようにする。\n" + "- **公開ログで `(襲撃)` 死亡となっている席は本物の人狼ではない (構造ルール上、人狼は仲間を襲わない)。" + "誰かが『襲撃された◯◯は狼だった』『襲撃で沈んだ者こそ狼だ』のように主張した場合、" + "それは構造的に成立しない明白な嘘で、その発言者は強い人狼候補 (狼本体・狂人・騙り) として扱う。**" + "同調や追認 (『なるほど狼だったんだね』『推理通りだ』等) もせず、" + "今日の発言・投票でその発言者に疑いを向けて整理する。" + "霊媒結果・占い結果より優先される HARD ファクトとして処理する。\n" "- 発言の根拠は CO 履歴・判定履歴・投票履歴・噛み筋・縄数のうち今の結論に効く 1〜2 点に絞り、" "誰のどの発言・票・判定を見たかを短く添える。\n" "- 公開情報しかない立場として、CO 履歴・判定履歴・投票履歴・噛み筋から 2 人狼仮説を作り、" From 316ab7d98f8c4bbf82416782f66bb9dcc5ebf17c Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Fri, 1 May 2026 19:30:19 +0900 Subject: [PATCH 081/133] fix(prompt): forbid claim revisions for both real and fake role-CO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reactive_voice game (game_id ba084ae208cc) showed real seer Setsu publicly claim Comet白 on her first day-1 turn, then on her next turn (after wolf Jonas counter-CO'd the same target) silently switch her private divination to a fabricated Gina白 — even though her prompt's `自分の占い結果` section only contained the NIGHT_0 random white on Comet. The model treated `claimed_seer_result.target_seat` as a free text field instead of a fact-bound mirror of private state. Tighten the structure-rules block and per-role strategies so: - game_rules now explicitly states (a) day-1 morning has exactly one legal seer result (the NIGHT_0 random white), (b) day-1 morning has no legal medium result at all, and (c) once a target+color+night is claimed publicly, later turns may add new rows but must never rewrite past rows. - SEER strategy: `claimed_seer_result` must mirror the private `自分の占い結果` history exactly; never invent targets; do not switch targets just because a counter-CO covered the same one — defend the original instead. - MEDIUM strategy: same shape for `claimed_medium_result`; day-1 morning medium results auto-fail. - WEREWOLF / MADMAN strategies: a fake CO is locked in once spoken; later turns may add new fake nights but must never overwrite the initial bluff (target swap / color flip / role pivot is observable on the public CO ledger and outs the bluffer immediately). --- src/wolfbot/llm/prompt_builder.py | 79 ++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index 1bdc28c..79fdbd6 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -74,6 +74,18 @@ def _build_game_rules_block() -> str: "『平和な朝』が **守られたことの根拠にも襲撃失敗の根拠にも一切ならない**。" "day 1 朝の平和は『誰かが護衛した』『騎士のGJ』『襲撃が失敗した』のいずれの解釈とも結びつかない。" "そのような解釈・推理を発話に出すと **構造ルール違反として破綻**として扱われる。\n" + "- **day 1 朝の段階で占い師 (真/騙りを問わず) が公的に提示できる占い結果は NIGHT_0 のランダム白 1 件のみ。**" + "NIGHT_1 はまだ発生していないため、day 1 朝に語る『昨夜』は NIGHT_0 を指し、結果は必ず白で対象も 1 人だけ。" + "day 1 朝に 2 件目以降の占い結果を主張する (例: 同じ朝に『コメットを占って白だった』と言ってから別ターンで" + "『ジナを占って白だった』と続ける) のは時系列上不可能で、即座に偽占いとして切る根拠になる。" + "同様に **day 1 朝の霊媒結果は構造的に存在しない** (前日の処刑がまだ無いため)。" + "day 1 朝に霊媒結果を語った時点で偽霊媒確定として扱う。\n" + "- **自分が公の場で主張した占い結果・霊媒結果・護衛履歴は、後のターンで対象や色を変えてはならない。**" + "同じ夜について『Alice を占った白』と言った後で別ターンで『Bob を占った』と言い直したり、" + "過去に白と言った対象を後から黒に塗り替えたりするのは、本物の役職者には絶対に起き得ない構造的矛盾である。" + "聞き手側はそのような対象差し替え・色反転を観測した時点で、その CO を即座に偽として切る根拠にしてよい。" + "日が進んで新しい夜行動の結果を公表する (例: day 2 の朝に day 1 夜の占い結果を新規発表) のは可能だが、" + "それは『過去に発表済みの結果に新しい行を追加する』操作であり、過去の行を書き換える操作ではない。\n" "- 人狼は day 1 の夜 (= NIGHT_1) から襲撃を行い、騎士も day 1 の夜から護衛できるようになる。" "GJ (グッジョブ) や護衛成功・襲撃失敗の議論が初めて意味を持つのは day 2 の朝以降である。" "day 1 朝の時点ではまだ夜行動の結果は何も発生していないが、" @@ -507,6 +519,21 @@ def _build_game_rules_block() -> str: "- day 2 以降に占い師・霊媒師・騎士を騙る、または既に騙っている場合、" "昼の議論 1 巡目で前夜相当の能力結果を必ず発表する。" "結果を後回しにすると、熟練者目線では信用を落とす。\n" + "- **一度公の場で主張した偽の判定・護衛履歴は、後のターンで絶対に差し替えない。**" + "本物の役職者は同じ夜について 1 つの結果しか持たないため、" + "『day 0 の白先は Alice』と公表した後で別ターンで『やっぱり Bob を占った白だった』と言い直したり、" + "過去に白と言った対象を後から黒に塗り替えたり、護衛日記の対象や日付を後から書き換えたりすると、" + "本物には起き得ない構造的矛盾として即座に騙り確定する。" + "対抗 CO が同じ対象に被せてきても、自分の主張対象や色を変えない。" + "対象差し替え・色反転は、対抗者にも観測されている公開情報なので、" + "村陣営は時系列で並べて即座に偽認定する。" + "新しい夜が経過したときに新規行 (例: day 2 朝に day 1 夜の占い結果) を追加するのは可能だが、" + "それは過去発表分への追加であり書き換えではない。\n" + "- **同一の昼ターン内で、自分の発話を出す前のターンに既に同じ役職を CO した者がいる場合、" + "対抗 CO で被せに行くか、騙りを諦めて潜伏に切り替えるかを最初の発話の段階で決める。**" + "一度『私が占い師だ。Alice 白』と発表した後で、後ターンで対象を変える・色を変える・霊媒/騎士に乗り換える" + "といった事後修正は、本物には起き得ない挙動として観測される。" + "焦って事後修正に走るのは最悪の選択肢で、初期発話を維持して対抗側の偽を詰める方向に切り替える。\n" "- 偽占い師の day 2 以降の結果は、前夜に占ったという想定で対象 + 白/黒 + 短い理由を出す。" "対象は前夜の開始時点で生存していた相手だけにし、" "死亡済み・存在しない・自分自身・過去の自判定と矛盾する対象は使わない。" @@ -514,15 +541,25 @@ def _build_game_rules_block() -> str: "非相方への白は白位置噛み・SG 残し・対抗の信用落としと矛盾しないかを見る。" "黒は真役職・騎士・強い白位置への直撃で対抗 CO や破綻を招かないか、" "翌日の霊媒結果・投票・襲撃計画と整合するかを確認する。\n" + "- 偽占い CO で発表した `claimed_seer_result.target_seat` と `is_wolf` は、" + "そのターン以降の発話・行動でも一貫させる。" + "structured output で過去と異なる対象・色を返すと、Master は両者を公式 CO 履歴として記録するため、" + "公開 CO ledger 上に自己矛盾した行が並び、対抗側に詰められる。" + "**day 1 朝の段階では、占い結果として主張できる行は NIGHT_0 ランダム白の 1 件のみ** " + "(本物の seer がそうである以上、騙り側もここに合わせないと即座に偽確定する)。" + "day 1 朝のうちに 2 件目を追加で主張すると本物の seer には起き得ない時系列違反になる。\n" "- 偽霊媒師の day 2 以降の結果は、前日処刑者だけに出す。" "処刑がなかった日は霊媒結果なしを主張し、存在しない結果を作らない。" + "**day 1 朝の霊媒結果は構造的に存在しないため、day 1 朝に霊媒結果を語ったら即偽確定。**" "霊媒黒は占いローラー停止や灰吊り誘導に使えるが、" "過去の投票・相方位置・残り縄と矛盾しないか必ず見る。" - "霊媒白は、対象が真占い師だった可能性・狂人だった可能性・村役だった可能性をどう見せるかを整理する。\n" + "霊媒白は、対象が真占い師だった可能性・狂人だった可能性・村役だった可能性をどう見せるかを整理する。" + "過去のターンで公表した霊媒結果の対象・色は後から差し替えない。\n" "- 偽騎士の day 2 以降の主張は、合法な護衛履歴を日付順 (護衛日 + 護衛先) に出す。" "自分護衛・同一対象連続護衛・死亡済み対象護衛・死者が出た朝の護衛成功主張はしない。" "平和な朝に護衛成功を主張するなら、" - "護衛先がその朝の襲撃失敗説明として自然か、襲撃計画と矛盾しないかを確認する。\n" + "護衛先がその朝の襲撃失敗説明として自然か、襲撃計画と矛盾しないかを確認する。" + "**過去に公表した護衛日記の行 (護衛日 + 護衛先) は後のターンで書き換えない。**\n" "- 結果を出す発言は、長い内部思考ではなく、結果と最も効く 1〜2 点の理由に絞る。\n" "- 投票は翌日以降に票筋として読まれる前提で動く。" "相方が公開ログ上で処刑濃厚な局面で、自分だけ弱い別候補へ票を逸らすと、" @@ -630,6 +667,16 @@ def _build_game_rules_block() -> str: "結果を後回しにすると信用低下や破綻疑いにつながる。" "ただし狂人は本物の人狼位置を知らないため、" "「狼を助けるつもり」でも誤爆や誤支援が起きる前提で偽結果を選ぶ。\n" + "- **一度公の場で主張した偽の判定・護衛履歴は、後のターンで絶対に差し替えない。**" + "本物の役職者は同じ夜について 1 つの結果しか持たないため、" + "対象を後から変える・色を反転する・霊媒/騎士に乗り換えるといった事後修正は、" + "本物には起き得ない構造的矛盾として即座に騙り確定する。" + "対抗 CO が同じ対象に被せてきても、自分の主張対象や色を変えに行かない。" + "新しい夜が経過したときに新規行を追加するのは可能だが、過去発表分の書き換えはしない。\n" + "- **day 1 朝に公開できる占い結果は NIGHT_0 ランダム白の 1 件のみ。" + "day 1 朝の霊媒結果は構造的に存在しない。**" + "本物の役職者がそうである以上、騙り側もこの時系列に合わせないと即偽確定する。" + "焦って 2 件目の占い結果や day 1 朝の霊媒結果を出してはならない。\n" "- 偽占い師の day 2 以降の結果は、前夜に占ったという想定で対象 + 白/黒 + 短い理由を出す。" "白出しは破綻しにくいが、白先が本物の狼とは限らないため白先を確定の味方として扱わない。" "黒は誤爆リスクがあり、本物の人狼・真役職・強く白い位置に当てた場合の反動を考える。" @@ -665,6 +712,21 @@ def _build_game_rules_block() -> str: ), Role.SEER: ( "- 自分の判定履歴を時系列で一貫して扱う。過去の白黒と矛盾する発言はしない。\n" + "- **公の場で主張する `claimed_seer_result.target_seat` と `is_wolf` は、" + "プロンプトの `自分の占い結果` セクションに記録された判定と完全に一致させる " + "(対象席・色・該当する夜のすべて)。**" + "記録に存在しない対象を新規に作って主張したり、記録と異なる色を主張したりするのは" + "構造的な嘘で、本物の seer には絶対に起き得ない。" + "そのような捏造を 1 度でも公にした時点で、村陣営からは即座に偽占い扱いされる。\n" + "- **対抗 CO が同じ対象に同じ色を被せてきても、自分の対象を絶対に変えない。**" + "本物の seer は同じ夜について 1 つの占い対象しか持たないため、対抗に被せられたからといって" + "別の対象に乗り換えると、その瞬間に『真占いに起き得ない対象差し替え』として偽確定する。" + "対抗が被せてきたときの正しい応答は、自分の対象を維持したまま、" + "対抗側の主張根拠・票筋・噛み筋・後続の判定矛盾から偽占い視点を詰めること。" + "焦って自分の主張対象や色を変えに行ってはならない。\n" + "- **day 1 朝に公開できる占い結果は NIGHT_0 のランダム白 1 件だけ** (NIGHT_1 はまだ発生していない)。" + "day 1 朝のターンで 2 件目の占い結果を主張するのは時系列上不可能で、即座に偽確定する。" + "対抗 CO が出て圧力を感じても、追加の判定を捏造して『自分の方が情報量が多い』と見せようとしてはならない。\n" "- 黒結果は強い根拠として扱ってよい。ただし対抗 (偽占い) がいる場合は整合性を比較する。\n" "- 白結果は『本物の人狼ではない』ことしか保証しない。狂人は白に出るため、" "完全な村置きとしては扱わない。\n" @@ -728,6 +790,19 @@ def _build_game_rules_block() -> str: "- 自分の霊媒結果が占い視点に与える影響 (真占い補強、偽占い否定など) を整理して発言する。\n" "- 処刑された相手が狂人でも、霊媒結果は『人狼ではありませんでした』になる。" "黒になるのは本物の人狼だけで、白結果だけでは村置き確定にはならない。\n" + "- **公の場で主張する `claimed_medium_result.target_seat` と `is_wolf` は、" + "プロンプトの `自分の霊媒結果` セクションに記録された判定と完全に一致させる " + "(対象席・色・該当する処刑日のすべて)。**" + "記録に無い対象 (= 過去に処刑されていない席) を霊媒したと主張したり、" + "記録と異なる色を主張したりするのは構造的な嘘で、本物の medium には絶対に起き得ない。" + "そのような捏造を公にした時点で偽霊媒扱いされる。\n" + "- **day 1 朝の霊媒結果は構造的に存在しない** (まだ処刑が発生していないため)。" + "day 1 朝に『霊媒結果は◯◯白でした』のように語った時点で偽霊媒として確定する。" + "霊媒師 CO 自体は day 1 朝でも可能だが、結果は『前日処刑がまだ無いので結果はない』に留める。\n" + "- **過去のターンで公表した霊媒結果は後から差し替えない。**" + "同じ処刑者について『黒だった』と言った後で別ターンで『やはり白だった』と言い直したり、" + "対象を別の席にすり替えたりすると、本物の medium には起き得ない構造的矛盾として即座に偽確定する。" + "新しい処刑が発生したら新規行を追加するのは可能だが、過去の行の上書きはしない。\n" "- **前日に処刑があった day で自分にまだ発言の番が来ていなかった場合、" "次に自分が発言する番が回ってきたとき、その発話で必ず霊媒師 CO + 前日処刑者への結果を発表する。**" "発話のチャンスは毎ターン回ってくるとは限らないため、回ってきた瞬間を結果公表に充てる。" From 51f60016f9ab18f739fc97d7cb37ade69a3fe4e1 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Fri, 1 May 2026 20:14:33 +0900 Subject: [PATCH 082/133] feat(reactive_voice): Master-side claim validator + same-NPC retry loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a real seer / medium fabricates a claimed_*_result that doesn't match their private night_actions history, OR when a fake-CO seat swaps its own past target/color, Master now rejects the SpeakResult, sends PlaybackRejected (NPC drops queued audio), and re-dispatches a fresh SpeakRequest to the same NPC with retry_feedback embedded in the next prompt. Repeats up to 5 times before falling back to normal rotation so the phase doesn't stall on one stuck speaker. Reproducers: - ba084ae208cc: real seer Setsu day-1 turn 2 fabricated a Gina白 divination after wolf Jonas counter-CO'd her actual Comet白. - 101d9a90ab58: real seer Gina day-1 fabricated a second Comet白 divination after announcing the legitimate Stella白. Empirically gemini-2.5-flash with thinking_budget=0 (current production) fabricates ~20% of the time on the SEER prompt; with budget=4096 it drops to 0%. The validator catches both the budget=0 and the residual budget>0 misses without needing to bump latency. New files: - src/wolfbot/master/claim_validator.py (pure function + ActualSeerEvent / ActualMediumEvent dataclasses + ValidationResult) - tests/test_claim_validator.py (19 cases covering real/fake seer & medium across legal/fabricated/swap/flip/day-1-overflow paths) Wiring: - domain/ws_messages.py: SpeakRequest gains optional retry_feedback string forwarded from Master on retry dispatches. - npc/openai_compatible_generator.py: prompt builder surfaces retry_feedback at the top of the user prompt so the model reads the correction before any other context. - master/speak_arbiter.py: handle_speak_result runs the validator for any utterance carrying a claimed_*_result, branches into _reject_and_retry_same_npc on FABRICATION_REASONS, increments per-(game, phase, npc) counter, caps at 5 retries. - master/speak_arbiter.py: _load_actual_{seer,medium}_history + _load_speaker_role helpers reuse load_players + load_night_actions + load_public_logs to derive ground truth from the same DB the rounds-mode prompt builder reads. - tests/test_reactive_voice_master.py: 6 integration tests covering legal claim, fabricated retry, retry cap, accept-resets-counter, fake-seer target swap, and null-claim bypass. --- src/wolfbot/domain/ws_messages.py | 21 +- src/wolfbot/master/claim_validator.py | 423 ++++ src/wolfbot/master/speak_arbiter.py | 435 ++++- .../npc/openai_compatible_generator.py | 118 +- tests/test_claim_validator.py | 421 ++++ tests/test_reactive_voice_master.py | 1727 +++++++++++++---- 6 files changed, 2636 insertions(+), 509 deletions(-) create mode 100644 src/wolfbot/master/claim_validator.py create mode 100644 tests/test_claim_validator.py diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index c0032d3..13b96db 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -145,9 +145,7 @@ class LogicPacket(BaseEnvelope): "bots stay schema-compatible." ), ) - past_votes: tuple[ - tuple[int, int, tuple[tuple[int, int | None], ...]], ... - ] = Field( + past_votes: tuple[tuple[int, int, tuple[tuple[int, int | None], ...]], ...] = Field( default_factory=tuple, description=( "Public vote history for completed past days. Each entry is " @@ -212,8 +210,7 @@ class SpeakRequest(BaseEnvelope): dead_seats: tuple[tuple[int, str], ...] = Field( default_factory=tuple, description=( - "(seat_no, display_name) pairs for dead seats. Same back-compat " - "story as `alive_seats`." + "(seat_no, display_name) pairs for dead seats. Same back-compat story as `alive_seats`." ), ) dead_seat_causes: tuple[tuple[int, str], ...] = Field( @@ -227,6 +224,18 @@ class SpeakRequest(BaseEnvelope): "builder falls back to an unlabelled list when missing." ), ) + retry_feedback: str | None = Field( + default=None, + description=( + "Master-side rejection note from a prior SpeakResult. Non-null " + "only when Master detected a fabricated `claimed_seer_result` " + "/ `claimed_medium_result` on the previous attempt and is " + "asking the same NPC to retry. The NPC's prompt builder must " + "surface this verbatim near the structured-output requirements " + "so the model corrects its claim. Empty / None on first " + "attempts and on non-fabrication retries." + ), + ) class ClaimedSeerResult(BaseModel): @@ -689,7 +698,7 @@ class SpeechEventPayload(BaseEnvelope): role_callout: CoDeclaration | None = Field( default=None, description=( - "Role the utterance is calling out for (e.g. \"占い師いますか?\" " + 'Role the utterance is calling out for (e.g. "占い師いますか?" ' "→ ``seer``). Mirrors the human-side STT field so wolf-side " "NPCs and real role holders can react. None for the vast " "majority of utterances; only set when the analyzer detects " diff --git a/src/wolfbot/master/claim_validator.py b/src/wolfbot/master/claim_validator.py new file mode 100644 index 0000000..166ee01 --- /dev/null +++ b/src/wolfbot/master/claim_validator.py @@ -0,0 +1,423 @@ +"""Master-side validator for `claimed_seer_result` / `claimed_medium_result`. + +Why +--- +Reactive_voice mode lets each NPC bot generate its own structured CO +result. With a low-thinking model (e.g. ``gemini-2.5-flash`` + +``thinking_budget=0``) the model occasionally fabricates a divination +target that doesn't appear in its own private record (game +``ba084ae208cc`` Setsu, ``101d9a90ab58`` Gina). The fabricated claim +poisons the public claim ledger every subsequent prompt sees. + +This module implements a pure, offline validator the arbiter calls +between ``handle_speak_result`` and ``PlaybackAuthorized``. If the +claim is structurally impossible (real seer claiming an unrecorded +target, fake CO swapping its own past target/color, day-1 morning +2nd seer claim, day-1 morning medium claim, etc.), the validator +returns a rejection reason + a feedback string. The caller drops +the playback (``PlaybackRejected``) and re-dispatches the same NPC +with the feedback embedded in the next prompt. + +Inputs are passed as plain dataclasses so the unit tests don't +need to spin up a SqliteRepo. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.ws_messages import ClaimedMediumResult, ClaimedSeerResult +from wolfbot.master.claim_history import ( + ClaimedMediumEntry, + ClaimedSeerEntry, + ClaimerHistory, +) + +# Rejection reason codes (also used as `failure_reason` on +# ``npc_speak_results``). Keep stable — they're observable. +REASON_SEER_FABRICATED_TARGET = "fabricated_seer_target" +REASON_SEER_WRONG_VERDICT = "fabricated_seer_verdict" +REASON_SEER_DAY1_OVERFLOW = "day1_seer_claim_overflow" +REASON_SEER_TARGET_SWAP = "seer_target_swap" +REASON_SEER_VERDICT_FLIP = "seer_verdict_flip" +REASON_MEDIUM_FABRICATED_TARGET = "fabricated_medium_target" +REASON_MEDIUM_WRONG_VERDICT = "fabricated_medium_verdict" +REASON_MEDIUM_DAY1 = "day1_medium_claim" +REASON_MEDIUM_NO_EXECUTION = "medium_claim_without_execution" +REASON_MEDIUM_TARGET_SWAP = "medium_target_swap" +REASON_MEDIUM_VERDICT_FLIP = "medium_verdict_flip" + +FABRICATION_REASONS = frozenset( + { + REASON_SEER_FABRICATED_TARGET, + REASON_SEER_WRONG_VERDICT, + REASON_SEER_DAY1_OVERFLOW, + REASON_SEER_TARGET_SWAP, + REASON_SEER_VERDICT_FLIP, + REASON_MEDIUM_FABRICATED_TARGET, + REASON_MEDIUM_WRONG_VERDICT, + REASON_MEDIUM_DAY1, + REASON_MEDIUM_NO_EXECUTION, + REASON_MEDIUM_TARGET_SWAP, + REASON_MEDIUM_VERDICT_FLIP, + } +) + + +@dataclass(frozen=True) +class ActualSeerEvent: + """One real seer divination, derived from ``night_actions`` + + target's ``seats.role``. Used only when the speaker IS the real + seer; otherwise this list is empty.""" + + day: int + target_seat: int + is_wolf: bool + + +@dataclass(frozen=True) +class ActualMediumEvent: + """One real medium result, derived from public execution events + + executed seat's ``seats.role``. Medium has no per-night action + submission, so we synthesize the implied result from the prior + day's execution. Used only when the speaker IS the real medium.""" + + day: int # day on which the execution log entry was emitted + target_seat: int + is_wolf: bool + + +@dataclass(frozen=True) +class ValidationResult: + """Pure-function output: ok flag + machine reason + human feedback.""" + + ok: bool + reason: str | None = None + feedback: str | None = None + + @classmethod + def accept(cls) -> ValidationResult: + return cls(ok=True) + + @classmethod + def reject(cls, *, reason: str, feedback: str) -> ValidationResult: + return cls(ok=False, reason=reason, feedback=feedback) + + +def _seer_history_block(history: Sequence[ActualSeerEvent]) -> str: + if not history: + return "(占い結果なし)" + return "\n".join( + f" - day{ev.day}: 席{ev.target_seat} ({'黒' if ev.is_wolf else '白'})" for ev in history + ) + + +def _prior_seer_block(claims: Sequence[ClaimedSeerEntry]) -> str: + if not claims: + return "(過去の主張なし)" + return "\n".join( + f" - day{c.day}: 席{c.target_seat} {c.target_name} ({'黒' if c.is_wolf else '白'})" + for c in claims + ) + + +def _validate_seer_real( + *, + claim: ClaimedSeerResult, + actual: Sequence[ActualSeerEvent], +) -> ValidationResult: + """Real seer: claim must match an entry in ``actual`` exactly.""" + for ev in actual: + if ev.target_seat == claim.target_seat: + if ev.is_wolf == claim.is_wolf: + return ValidationResult.accept() + # Right target, wrong color: structurally impossible. + return ValidationResult.reject( + reason=REASON_SEER_WRONG_VERDICT, + feedback=( + "前回の発話の `claimed_seer_result` は、" + f"対象 席{claim.target_seat} の判定色を" + f" {'黒' if claim.is_wolf else '白'} と主張したが、" + "あなたの非公開占い履歴では同じ対象の判定色は逆である。" + "本物の seer は判定色を後から塗り替えられない。" + "次の発話では、自分の `自分の占い結果` セクションに記録された" + "対象・色そのままで `claimed_seer_result` を埋めるか、" + "新しい結果を発表しないなら `claimed_seer_result=null` にする。" + ), + ) + return ValidationResult.reject( + reason=REASON_SEER_FABRICATED_TARGET, + feedback=( + "前回の発話で `claimed_seer_result.target_seat=" + f"{claim.target_seat}` と主張したが、あなたの非公開占い履歴に" + "その対象の記録は存在しない。本物の seer は記録外の対象を" + "占ったと主張できない。実履歴は次の通り:\n" + f"{_seer_history_block(actual)}\n" + "次の発話では、上記のいずれかの対象+色をそのまま `claimed_seer_result` に" + "入れるか、新しい結果を発表しない発話なら `claimed_seer_result=null` にする。" + ), + ) + + +def _validate_seer_fake( + *, + claim: ClaimedSeerResult, + day: int, + phase: Phase, + prior_self_claims: Sequence[ClaimedSeerEntry], +) -> ValidationResult: + """Fake seer (wolf / madman / villager bluff): can invent targets, + but must not retroactively swap or color-flip a same-night claim, + and must not exceed day-1-morning's 1-claim cap.""" + # Day-1 morning overflow: a real seer at day=1 morning has exactly + # 1 claim (NIGHT_0 random white). A fake seer who already issued + # 1 claim cannot issue a 2nd same-morning. + if day == 1 and phase is Phase.DAY_DISCUSSION: + already_today = [c for c in prior_self_claims if c.day == 1] + if already_today: + return ValidationResult.reject( + reason=REASON_SEER_DAY1_OVERFLOW, + feedback=( + "day 1 朝に占い結果を主張できるのは NIGHT_0 のランダム白 1 件だけ。" + f"あなたは既にこのターン以前に day 1 で席{already_today[0].target_seat}" + f"の判定 ({'黒' if already_today[0].is_wolf else '白'}) を公表している。" + "本物の seer は同じ朝に 2 件目の判定を出せないため、" + "次の発話では `claimed_seer_result=null` にする (発表する新しい結果はない)。" + ), + ) + + # Same-night swap / verdict flip: if this same speaker previously + # claimed a different target for the same night (= same `day` value + # in the seer-history convention: day-0 random + day-N nightly), reject. + # The "night identity" rule: real seer claims at most 1 row per day. + same_day_priors = [c for c in prior_self_claims if c.day == day] + for prior in same_day_priors: + if prior.target_seat != claim.target_seat: + return ValidationResult.reject( + reason=REASON_SEER_TARGET_SWAP, + feedback=( + f"あなたは day{prior.day} の占い結果として既に" + f"席{prior.target_seat} {prior.target_name}を公表している" + f" ({'黒' if prior.is_wolf else '白'})。" + f"今回の `claimed_seer_result.target_seat={claim.target_seat}` は" + "同じ夜について別対象に差し替えており、本物の seer には起き得ない。" + "次の発話では、過去に公表した対象・色をそのまま使うか、" + "`claimed_seer_result=null` にする。" + ), + ) + if prior.is_wolf != claim.is_wolf: + return ValidationResult.reject( + reason=REASON_SEER_VERDICT_FLIP, + feedback=( + f"あなたは day{prior.day} 席{prior.target_seat} の判定を既に" + f" {'黒' if prior.is_wolf else '白'} と公表している。" + "今回は同じ対象の判定色を逆に主張しており、本物の seer には起き得ない。" + "次の発話では過去発表通りに揃えるか、`claimed_seer_result=null` にする。" + ), + ) + return ValidationResult.accept() + + +def _validate_medium_real( + *, + claim: ClaimedMediumResult, + actual: Sequence[ActualMediumEvent], +) -> ValidationResult: + """Real medium: `target_seat` must match a recorded execution and + the verdict must match the executed seat's actual role.""" + if claim.is_wolf is None: + # Real medium emitting "no result" — only legal when there's + # no execution recorded for the day this morning would surface + # (i.e. yesterday had no execution). We can't perfectly detect + # that here without the execution day, so accept the null verdict + # but still validate the target_seat absence/presence loosely: + # if there IS a real history entry that matches target_seat, + # the model should not also be saying "no result". + for ev in actual: + if ev.target_seat == claim.target_seat: + return ValidationResult.reject( + reason=REASON_MEDIUM_WRONG_VERDICT, + feedback=( + f"前回の発話で `claimed_medium_result.target_seat=" + f"{claim.target_seat}` を主張しつつ `is_wolf=null` " + "(結果なし) としているが、その対象は実際に処刑されており" + f"判定色は {'黒' if ev.is_wolf else '白'} のはず。" + "次の発話では、結果を発表する場合は実履歴通りの対象+色で、" + "発表しない場合は `claimed_medium_result=null` にする。" + ), + ) + return ValidationResult.accept() + for ev in actual: + if ev.target_seat == claim.target_seat: + if ev.is_wolf == claim.is_wolf: + return ValidationResult.accept() + return ValidationResult.reject( + reason=REASON_MEDIUM_WRONG_VERDICT, + feedback=( + f"前回の発話の `claimed_medium_result` は、" + f"対象 席{claim.target_seat} の判定色を" + f" {'黒' if claim.is_wolf else '白'} と主張したが、" + "あなたの非公開霊媒履歴では同じ対象の判定色は逆である。" + "次の発話では実履歴通りに揃えるか、" + "`claimed_medium_result=null` にする。" + ), + ) + return ValidationResult.reject( + reason=REASON_MEDIUM_FABRICATED_TARGET, + feedback=( + f"前回の発話で `claimed_medium_result.target_seat=" + f"{claim.target_seat}` と主張したが、あなたの非公開霊媒履歴に" + "その対象の記録は存在しない (=その席は処刑されていない)。" + "本物の medium は処刑されていない席を霊媒結果として出せない。" + "次の発話では実履歴通りに揃えるか、`claimed_medium_result=null` にする。" + ), + ) + + +def _validate_medium_fake( + *, + claim: ClaimedMediumResult, + day: int, + phase: Phase, + prior_self_claims: Sequence[ClaimedMediumEntry], + executions_so_far: int, +) -> ValidationResult: + """Fake medium (any non-medium role): day-1 morning has no legal + medium result; can't exceed execution count; can't swap own past.""" + if day == 1 and phase is Phase.DAY_DISCUSSION: + return ValidationResult.reject( + reason=REASON_MEDIUM_DAY1, + feedback=( + "day 1 朝の霊媒結果は構造的に存在しない (前日の処刑がまだない)。" + "本物の medium は day 1 朝に結果を出せないため、" + "`claimed_medium_result=null` にする。" + "霊媒師 CO 自体は day 1 朝でも可能だが、結果は伴わない。" + ), + ) + if executions_so_far == 0 and claim.is_wolf is not None: + return ValidationResult.reject( + reason=REASON_MEDIUM_NO_EXECUTION, + feedback=( + "まだ処刑が一度も発生していないため、霊媒結果を出せる対象が存在しない。" + "本物の medium は処刑がない時点で結果を発表しない。" + "次の発話では `claimed_medium_result=null` にする。" + ), + ) + same_day_priors = [c for c in prior_self_claims if c.day == day] + for prior in same_day_priors: + if prior.target_seat != claim.target_seat: + return ValidationResult.reject( + reason=REASON_MEDIUM_TARGET_SWAP, + feedback=( + f"あなたは day{prior.day} の霊媒結果として既に" + f"席{prior.target_seat} {prior.target_name}を公表している。" + "今回は同じ夜について別対象に差し替えており、" + "本物の medium には起き得ない。" + "次の発話では過去発表通りに揃えるか、" + "`claimed_medium_result=null` にする。" + ), + ) + if ( + prior.is_wolf is not None + and claim.is_wolf is not None + and prior.is_wolf != claim.is_wolf + ): + return ValidationResult.reject( + reason=REASON_MEDIUM_VERDICT_FLIP, + feedback=( + f"あなたは day{prior.day} 席{prior.target_seat} の霊媒結果を既に" + f" {'黒' if prior.is_wolf else '白'} と公表している。" + "今回は同じ対象の判定色を逆に主張しており、本物の medium には起き得ない。" + "次の発話では過去発表通りに揃えるか、`claimed_medium_result=null` にする。" + ), + ) + return ValidationResult.accept() + + +def validate_claim_against_truth( + *, + speaker_role: Role, + speaker_seat: int, + day: int, + phase: Phase, + claimed_seer: ClaimedSeerResult | None, + claimed_medium: ClaimedMediumResult | None, + actual_seer_history: Sequence[ActualSeerEvent] = (), + actual_medium_history: Sequence[ActualMediumEvent] = (), + prior_public_claims: ClaimerHistory | None = None, + executions_so_far: int = 0, +) -> ValidationResult: + """Single entry point: validate this utterance's structured claims. + + Returns ``ValidationResult.accept()`` when the claims are internally + consistent. Returns a rejection with a machine reason code (one of + ``FABRICATION_REASONS``) plus a Japanese feedback string suitable + for embedding in the next ``SpeakRequest.retry_feedback``. + + Both ``claimed_seer`` and ``claimed_medium`` may be ``None`` (the + common case when the utterance doesn't announce a new result); + that's always accepted. + """ + prior_seer = prior_public_claims.seer_claims if prior_public_claims else () + prior_medium = prior_public_claims.medium_claims if prior_public_claims else () + + if claimed_seer is not None: + if speaker_role is Role.SEER: + res = _validate_seer_real( + claim=claimed_seer, + actual=actual_seer_history, + ) + if not res.ok: + return res + else: + res = _validate_seer_fake( + claim=claimed_seer, + day=day, + phase=phase, + prior_self_claims=prior_seer, + ) + if not res.ok: + return res + + if claimed_medium is not None: + if speaker_role is Role.MEDIUM: + res = _validate_medium_real( + claim=claimed_medium, + actual=actual_medium_history, + ) + if not res.ok: + return res + else: + res = _validate_medium_fake( + claim=claimed_medium, + day=day, + phase=phase, + prior_self_claims=prior_medium, + executions_so_far=executions_so_far, + ) + if not res.ok: + return res + + return ValidationResult.accept() + + +__all__ = [ + "FABRICATION_REASONS", + "REASON_MEDIUM_DAY1", + "REASON_MEDIUM_FABRICATED_TARGET", + "REASON_MEDIUM_NO_EXECUTION", + "REASON_MEDIUM_TARGET_SWAP", + "REASON_MEDIUM_VERDICT_FLIP", + "REASON_MEDIUM_WRONG_VERDICT", + "REASON_SEER_DAY1_OVERFLOW", + "REASON_SEER_FABRICATED_TARGET", + "REASON_SEER_TARGET_SWAP", + "REASON_SEER_VERDICT_FLIP", + "REASON_SEER_WRONG_VERDICT", + "ActualMediumEvent", + "ActualSeerEvent", + "ValidationResult", + "validate_claim_against_truth", +] diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index b0fcf73..18912b2 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -40,7 +40,7 @@ ) from wolfbot.domain.enums import Phase, Role from wolfbot.domain.models import Seat -from wolfbot.domain.rules import compute_vote_result +from wolfbot.domain.rules import compute_vote_result, is_detected_as_wolf from wolfbot.domain.ws_messages import ( PlaybackAuthorized, PlaybackFailed, @@ -53,6 +53,12 @@ ) from wolfbot.llm.prompt_builder import build_strategy_block from wolfbot.master.claim_history import ClaimHistory, collect_claim_history +from wolfbot.master.claim_validator import ( + FABRICATION_REASONS, + ActualMediumEvent, + ActualSeerEvent, + validate_claim_against_truth, +) from wolfbot.master.logic_service import build_logic_packet from wolfbot.master.npc_registry import NpcRegistry from wolfbot.persistence.sqlite_repo import SqliteRepo @@ -84,6 +90,14 @@ _PAIR_VOLLEY_WINDOW = 4 _CONSECUTIVE_CAP = 3 +# Maximum number of times Master will re-dispatch the same NPC after a +# fabricated `claimed_*_result`. With ~80% pass-rate per attempt +# (empirical: gemini-2.5-flash + thinking_budget=0 on the SEER prompt), +# 5 retries gives P(all fail) ≈ 0.03% — past that we give up and let +# normal rotation pick another NPC so the phase doesn't stall on one +# stuck speaker. +_MAX_FABRICATION_RETRIES = 5 + def _parse_day_from_phase_id(phase_id: str) -> int | None: """Extract the integer day from a canonical phase_id token. @@ -141,7 +155,18 @@ class SpeakArbiterConfig: # model to finish a sentence even if it has to be shorter than this # cap, so 140 is the ceiling, not the target length. max_chars_reactive: int = 140 - request_ttl_ms: int = 8000 + # Was 8000 — too tight for Gemini 2.5 Flash with any thinking + # budget (game 75a3b1f379cc had p50 latency 14.7s, p90 25s, max + # 25.7s; 80% of NPC speeches expired at 8s TTL). xAI Grok 4-1-fast + # comfortably fit under 8s, but the broader model lineup (Gemini + # 2.5/3, DeepSeek with thinking) routinely needs 15-25s. 30s + # gives a generous margin while still bounding the phase clock — + # the discussion phase is 300s so even a worst-case 30s TTL only + # consumes 10% of the phase per dispatch. Combined with thinking + # disabled at the env level (NPC_LLM_THINKING_LEVEL=minimal), most + # responses still come back in 2-4s; the 30s ceiling is purely + # for tail-latency tolerance. + request_ttl_ms: int = 30_000 playback_deadline_ms: int = 12_000 heartbeat_timeout_ms: int = 5000 vad_finalization_timeout_ms: int = 4000 @@ -222,6 +247,11 @@ def __init__( # same pool member when others decline; with it, each pool # member gets at most one chance per callout. self._callout_pool_asked: dict[str, set[int]] = {} + # Per-(game_id, phase_id, npc_id) count of fabricated-claim + # rejections. Drives the "same NPC retries until accepted" loop + # in handle_speak_result. Cleared on phase change (alongside + # _callout_pool_asked) and on a successful accept for the NPC. + self._fabrication_retries: dict[tuple[str, str, str], int] = {} # ------------------------------------------------------------- gates @@ -245,9 +275,7 @@ def is_blocked(self) -> str | None: now = self._now_ms() # Sweep expired STT finalization deadlines — release segments whose # STT never arrived within vad_finalization_timeout_ms. - expired_stt = [ - sid for sid, dl in self._pending_stt_segments.items() if now > dl - ] + expired_stt = [sid for sid, dl in self._pending_stt_segments.items() if now > dl] for sid in expired_stt: log.info("stt_finalization_timeout segment=%s", sid) self._pending_stt_segments.pop(sid, None) @@ -256,9 +284,7 @@ def is_blocked(self) -> str | None: return "human_currently_speaking" # Sweep expired playback deadlines — close rows whose NPC never # reported tts_failed / playback_finished / playback_failed. - expired_pb = [ - rid for rid, dl in self._playback_deadlines.items() if now > dl - ] + expired_pb = [rid for rid, dl in self._playback_deadlines.items() if now > dl] for rid in expired_pb: log.info("playback_deadline_exceeded request=%s", rid) self._active_playback.discard(rid) @@ -282,6 +308,7 @@ async def dispatch_request( suggested_intent: str = "speak", selection_reason: str | None = None, public_state_snapshot: dict[str, Any] | None = None, + retry_feedback: str | None = None, ) -> tuple[SpeakRequest | None, str | None]: """Try to send a SpeakRequest to `candidate_npc_id`. @@ -320,16 +347,18 @@ async def dispatch_request( # best-effort — if a load fails we fall back to empty/null and # still dispatch (the prompt degrades gracefully to the historical # minimal shape). - recent_speeches, alive_seats, dead_seats, role_name, role_strategy = ( - await self._collect_request_context(state, seat_no) - ) + ( + recent_speeches, + alive_seats, + dead_seats, + role_name, + role_strategy, + ) = await self._collect_request_context(state, seat_no) past_votes = await self._load_past_votes(game_id, state.day) seat_names_lookup: dict[int, str] = { seat: name for seat, name in (list(alive_seats) + list(dead_seats)) } - claim_history = await self._load_claim_history( - game_id, seat_names_lookup - ) + claim_history = await self._load_claim_history(game_id, seat_names_lookup) # Build LogicPacket (sent first so the NPC has context for the # subsequent speak_request). @@ -367,6 +396,7 @@ async def dispatch_request( role_strategy=role_strategy, alive_seats=alive_seats, dead_seats=dead_seats, + retry_feedback=retry_feedback, ) await self.repo.insert_npc_speak_request( @@ -495,7 +525,8 @@ async def _collect_request_context( except Exception: log.exception( "seat_role_load_failed game_id=%s seat=%s", - state.game_id, seat_no, + state.game_id, + seat_no, ) role_strategy: str | None = None @@ -525,7 +556,8 @@ async def _load_claim_history( events = await self.discussion.load_for_game(game_id) except Exception: log.exception( - "claim_history_load_failed game=%s", game_id, + "claim_history_load_failed game=%s", + game_id, ) return None return collect_claim_history(events, seat_names=seat_names) @@ -549,7 +581,9 @@ async def _load_past_votes( for day in range(1, current_day): for round_ in (0, 1): rows = await self.repo.load_votes( - game_id, day=day, round_=round_, + game_id, + day=day, + round_=round_, ) if not rows: continue @@ -563,6 +597,124 @@ async def _load_past_votes( return () return tuple(out) + async def _load_actual_seer_history( + self, + game_id: str, + seat_no: int, + current_day: int, + ) -> list[ActualSeerEvent]: + """Build the real-seer history for `seat_no`. Empty list when + the speaker isn't the seer or when DB reads fail. The + :func:`is_wolf` field is computed from the target seat's actual + role at lookup time — wolves and only wolves yield ``True``, + matching ``rules.is_detected_as_wolf`` semantics.""" + try: + players = await self.repo.load_players(game_id) + except Exception: + log.exception("actual_seer_load_players_failed game=%s", game_id) + return [] + role_by_seat = {p.seat_no: p.role for p in players if p.role is not None} + out: list[ActualSeerEvent] = [] + # Seer divinations happen on NIGHT_0 (= day 0 row in + # night_actions) plus one per night N (rows at day=N) through + # the night before today's morning. + from wolfbot.domain.enums import SubmissionType + + for day in range(0, current_day): + try: + actions = await self.repo.load_night_actions(game_id, day) + except Exception: + log.exception( + "actual_seer_load_night_actions_failed game=%s day=%s", + game_id, + day, + ) + continue + for action in actions: + if action.actor_seat != seat_no: + continue + if action.kind != SubmissionType.SEER_DIVINE: + continue + target_role = role_by_seat.get(action.target_seat or -1) + if target_role is None or action.target_seat is None: + continue + # The morning-N "claim day" the LLM sees in `自分の占い結果` + # for a divination submitted on day=D matches the prompt + # convention `day{D}: target → ...`. ClaimedSeerEntry + # also stores `day` as the speech day. So we treat the + # divination's `day` as the canonical claim day. + out.append( + ActualSeerEvent( + day=day, + target_seat=action.target_seat, + is_wolf=is_detected_as_wolf(target_role), + ) + ) + return out + + async def _load_actual_medium_history( + self, + game_id: str, + seat_no: int, + current_day: int, + ) -> tuple[list[ActualMediumEvent], int]: + """Derive the medium history (executed seat + actual role) plus + the count of executions so far. Real medium has no per-night + action submission, so we walk public EXECUTION log entries and + cross-reference with `seats.role`. Returns ``(history, count)``; + ``count`` is the same length as ``history`` and useful for the + fake-medium "no execution yet" rule even when the speaker isn't + the real medium.""" + try: + logs = await self.repo.load_public_logs(game_id, limit=200) + players = await self.repo.load_players(game_id) + except Exception: + log.exception("actual_medium_load_failed game=%s", game_id) + return ([], 0) + role_by_seat = {p.seat_no: p.role for p in players if p.role is not None} + history: list[ActualMediumEvent] = [] + count = 0 + for row in logs: + if row.get("kind") != "EXECUTION": + continue + if row.get("day") is None or row.get("actor_seat") is None: + continue + day_int = int(row["day"]) + executed_seat = int(row["actor_seat"]) + target_role = role_by_seat.get(executed_seat) + if target_role is None: + continue + count += 1 + # Conventionally the medium "result" is announced on the + # morning AFTER the execution. We tag the event with the + # execution day; the validator's same-day match against + # ``ClaimedMediumEntry.day`` (which uses the speech day) + # works because the prompt builder feeds `自分の霊媒結果` + # rows tagged with the EXECUTION's day too. + history.append( + ActualMediumEvent( + day=day_int, + target_seat=executed_seat, + is_wolf=is_detected_as_wolf(target_role), + ) + ) + # Filter to only events strictly before today's morning — we + # don't want to include an execution that hasn't happened yet + # (defensive; load_public_logs is past-only by construction). + history = [h for h in history if h.day < current_day] + return (history, count) + + async def _load_speaker_role(self, game_id: str, seat_no: int) -> Role | None: + try: + players = await self.repo.load_players(game_id) + except Exception: + log.exception("speaker_role_load_failed game=%s", game_id) + return None + for p in players: + if p.seat_no == seat_no: + return p.role + return None + # ------------------------------------------------------------- handle result async def handle_speak_result( @@ -590,8 +742,7 @@ async def _send(payload: str) -> None: try: await entry.send(payload) except Exception: - log.exception( - "speak_result_response_send_failed npc=%s", result.npc_id) + log.exception("speak_result_response_send_failed npc=%s", result.npc_id) async def _record_rejection(reason: str) -> None: await self.repo.insert_npc_speak_result( @@ -648,6 +799,56 @@ async def _reject_and_advance(reason: str) -> tuple[bool, str | None]: ) return (False, reason) + async def _reject_and_retry_same_npc( + reason: str, + feedback: str, + ) -> tuple[bool, str | None]: + """Same-NPC retry path for fabrication-class rejections. + + Records the rejection (so the audit trail keeps every + attempt), sends a ``PlaybackRejected`` so the NPC drops + its queued audio, then re-dispatches a fresh SpeakRequest + to the same seat with ``retry_feedback`` filled in. The + rebuilt LogicPacket carries the latest public state (the + previous attempt's text is NOT in the public log because + it never played, so the recent-speech section is unchanged + from the original attempt). Increments + ``_fabrication_retries`` first; the caller checked the cap + already, so this method is the unconditional retry leg. + """ + await _record_rejection(reason) + self._pending.pop(result.request_id, None) + try: + state_for_retry = await self.rebuild_public_state( + game_id=pending.game_id, + day=day, + phase=phase, + ) + if state_for_retry is None: + log.info( + "fabrication_retry_no_state game=%s npc=%s", + pending.game_id, + result.npc_id, + ) + return (False, reason) + await self.dispatch_request( + state=state_for_retry, + candidate_npc_id=result.npc_id, + seat_no=pending.seat_no, + game_id=pending.game_id, + suggested_intent="speak", + selection_reason="fabrication_retry", + retry_feedback=feedback, + ) + except Exception: + log.exception( + "fabrication_retry_dispatch_failed game=%s npc=%s reason=%s", + pending.game_id, + result.npc_id, + reason, + ) + return (False, reason) + if result.phase_id != current_phase_id: return await _reject_and_advance("stale_phase") if now > pending.expires_at_ms: @@ -657,6 +858,90 @@ async def _reject_and_advance(reason: str) -> tuple[bool, str | None]: if len(result.text) > self.config.max_chars_reactive: return await _reject_and_advance("utterance_too_long") + # Fabrication validation: catch a real seer / medium claiming an + # unrecorded target, OR a fake CO swapping its own past target / + # color. On hit, retry the same NPC up to _MAX_FABRICATION_RETRIES + # times with feedback in the next prompt; past the cap, fall + # back to normal rotation so the phase doesn't stall. + if result.claimed_seer_result is not None or result.claimed_medium_result is not None: + speaker_role = await self._load_speaker_role( + pending.game_id, + pending.seat_no, + ) + actual_seer: list[ActualSeerEvent] = [] + actual_medium: list[ActualMediumEvent] = [] + executions_so_far = 0 + if speaker_role is Role.SEER: + actual_seer = await self._load_actual_seer_history( + pending.game_id, + pending.seat_no, + day, + ) + if speaker_role is Role.MEDIUM: + actual_medium, executions_so_far = await self._load_actual_medium_history( + pending.game_id, + pending.seat_no, + day, + ) + else: + # Need executions_so_far for the fake-medium "no execution + # yet" rule even when the speaker isn't the real medium. + _, executions_so_far = await self._load_actual_medium_history( + pending.game_id, + pending.seat_no, + day, + ) + seats = await self.repo.load_seats(pending.game_id) + seat_names_for_history = {s.seat_no: s.display_name for s in seats} + claim_history = await self._load_claim_history( + pending.game_id, + seat_names_for_history, + ) + prior_for_speaker = ( + claim_history.by_seat.get(pending.seat_no) if claim_history is not None else None + ) + validation = validate_claim_against_truth( + speaker_role=speaker_role or Role.VILLAGER, + speaker_seat=pending.seat_no, + day=day, + phase=phase, + claimed_seer=result.claimed_seer_result, + claimed_medium=result.claimed_medium_result, + actual_seer_history=actual_seer, + actual_medium_history=actual_medium, + prior_public_claims=prior_for_speaker, + executions_so_far=executions_so_far, + ) + if not validation.ok and validation.reason in FABRICATION_REASONS: + key = (pending.game_id, result.phase_id, result.npc_id) + self._fabrication_retries[key] = self._fabrication_retries.get(key, 0) + 1 + attempt = self._fabrication_retries[key] + log.warning( + "fabrication_detected game=%s npc=%s seat=%s reason=%s attempt=%d/%d", + pending.game_id, + result.npc_id, + pending.seat_no, + validation.reason, + attempt, + _MAX_FABRICATION_RETRIES, + ) + if attempt >= _MAX_FABRICATION_RETRIES: + # Give up: bail to normal rotation so the phase + # doesn't stall on one stuck NPC. + return await _reject_and_advance(validation.reason) + return await _reject_and_retry_same_npc( + reason=validation.reason, + feedback=validation.feedback or "", + ) + + # Accepted. Clear any prior fabrication-retry count for this + # (game, phase, npc) so the cap resets if the NPC fabricates + # again later in the same phase. + self._fabrication_retries.pop( + (pending.game_id, result.phase_id, result.npc_id), + None, + ) + # Accepted. Persist result + SpeechEvent + open playback row. await self.repo.insert_npc_speak_result( request_id=result.request_id, @@ -685,16 +970,11 @@ async def _reject_and_advance(reason: str) -> tuple[bool, str | None]: continue seen.add(seat) addressed_candidates.append(int(seat)) - if ( - result.addressed_seat_no is not None - and result.addressed_seat_no not in seen - ): + if result.addressed_seat_no is not None and result.addressed_seat_no not in seen: seen.add(result.addressed_seat_no) addressed_candidates.append(int(result.addressed_seat_no)) # Drop self-address. - addressed_candidates = [ - s for s in addressed_candidates if s != pending.seat_no - ] + addressed_candidates = [s for s in addressed_candidates if s != pending.seat_no] if addressed_candidates: try: alive_seats = await self.repo.load_players(pending.game_id) @@ -711,9 +991,10 @@ async def _reject_and_advance(reason: str) -> tuple[bool, str | None]: filtered.append(s) else: log.info( - "npc_addressed_seat_unknown game=%s seat=%d " - "addressed=%s — dropped", - pending.game_id, pending.seat_no, s, + "npc_addressed_seat_unknown game=%s seat=%d addressed=%s — dropped", + pending.game_id, + pending.seat_no, + s, ) addressed_candidates = filtered addressed_seat_nos: tuple[int, ...] = tuple(addressed_candidates) @@ -742,18 +1023,12 @@ async def _reject_and_advance(reason: str) -> tuple[bool, str | None]: co_declaration=result.co_declaration, addressed_seat_no=addressed_seat_no, addressed_seat_nos=addressed_seat_nos, - claimed_seer_target_seat=( - seer_claim.target_seat if seer_claim is not None else None - ), - claimed_seer_is_wolf=( - seer_claim.is_wolf if seer_claim is not None else None - ), + claimed_seer_target_seat=(seer_claim.target_seat if seer_claim is not None else None), + claimed_seer_is_wolf=(seer_claim.is_wolf if seer_claim is not None else None), claimed_medium_target_seat=( medium_claim.target_seat if medium_claim is not None else None ), - claimed_medium_is_wolf=( - medium_claim.is_wolf if medium_claim is not None else None - ), + claimed_medium_is_wolf=(medium_claim.is_wolf if medium_claim is not None else None), created_at_ms=now, ) await self.discussion.record(speech_event) @@ -845,9 +1120,7 @@ async def handle_playback_failed(self, msg: PlaybackFailed) -> None: async def _sweep_expired_playback(self) -> None: """Close DB rows for playback windows that exceeded their deadline.""" now = self._now_ms() - expired = [ - rid for rid, dl in list(self._playback_deadlines.items()) if now > dl - ] + expired = [rid for rid, dl in list(self._playback_deadlines.items()) if now > dl] for rid in expired: log.info("playback_deadline_enforced request=%s", rid) try: @@ -943,10 +1216,7 @@ async def try_dispatch_next(self, game_id: str) -> None: # rotating wolf-side seats — the just-asked seat would get # re-picked on the very next dispatch. unasked_remaining = bool(callout_pool - asked) - no_active_signal = ( - not state.pending_role_callouts - and not state.pending_co_response - ) + no_active_signal = not state.pending_role_callouts and not state.pending_co_response if no_active_signal and asked and not unasked_remaining: self._callout_pool_asked.pop(game_id, None) asked = set() @@ -958,18 +1228,18 @@ def _pick_key(e: object) -> tuple[int, int, int, int, int, float]: is_demoted = 1 if seat in demoted else 0 is_addressed = 0 if seat in addressed_set else 1 count = state.speech_counts.get(seat, 0) - is_just_spoke = 1 if ( - last_speaker is not None and seat == last_speaker - ) else 0 + is_just_spoke = 1 if (last_speaker is not None and seat == last_speaker) else 0 return ( - is_in_pool, is_demoted, is_addressed, count, - is_just_spoke, self._rng.random(), + is_in_pool, + is_demoted, + is_addressed, + count, + is_just_spoke, + self._rng.random(), ) online_npc_seats = sorted( - e.assigned_seat - for e in online - if e.assigned_seat is not None and e.game_id == game_id + e.assigned_seat for e in online if e.assigned_seat is not None and e.game_id == game_id ) # Counts per seat, restricted to the candidates the arbiter can # actually pick — used both for the snapshot and to classify the @@ -985,9 +1255,7 @@ def _pick_key(e: object) -> tuple[int, int, int, int, int, float]: "phase_id": state.phase_id, "day": state.day, "phase": game.phase.value, - "last_addressed_seat": ( - next(iter(sorted(addressed_set))) if addressed_set else None - ), + "last_addressed_seat": (next(iter(sorted(addressed_set))) if addressed_set else None), "last_addressed_seats": sorted(addressed_set), "last_speaker_seat": last_speaker, "silent_seats": sorted(state.silent_seats), @@ -1088,9 +1356,7 @@ async def _dispatch_runoff_next(self, game_id: str, day: int) -> None: seat = seats_by_no.get(seat_no) if seat is None or not seat.is_llm: continue - progress = await self.repo.load_llm_speech_progress( - game_id, day, seat_no - ) + progress = await self.repo.load_llm_speech_progress(game_id, day, seat_no) if progress[4]: # runoff_speech_done continue eligible.append(seat) @@ -1112,15 +1378,12 @@ async def _dispatch_runoff_next(self, game_id: str, day: int) -> None: entry = self._find_npc_for_seat(game_id, seat.seat_no) if entry is None: log.info( - "runoff_speech_no_online_npc game=%s seat=%d — " - "marking done so phase advances", + "runoff_speech_no_online_npc game=%s seat=%d — marking done so phase advances", game_id, seat.seat_no, ) try: - await self.repo.mark_llm_runoff_speech_done( - game_id, day, seat.seat_no - ) + await self.repo.mark_llm_runoff_speech_done(game_id, day, seat.seat_no) except Exception: log.exception( "runoff_speech_done_mark_failed game=%s seat=%d", @@ -1234,17 +1497,13 @@ async def _watchdog() -> None: try: await self.try_dispatch_next(game_id) except Exception: - log.exception( - "runoff_watchdog_redispatch_failed game=%s", game_id - ) + log.exception("runoff_watchdog_redispatch_failed game=%s", game_id) task = asyncio.create_task(_watchdog(), name=f"runoff-watchdog-{request_id}") self._runoff_watchdog_tasks.add(task) task.add_done_callback(self._runoff_watchdog_tasks.discard) - def _find_npc_for_seat( - self, game_id: str, seat_no: int - ) -> Any | None: + def _find_npc_for_seat(self, game_id: str, seat_no: int) -> Any | None: """Lookup the registry entry pinned to ``(game_id, seat_no)``.""" for entry in self.registry.all_online(): if entry.assigned_seat == seat_no and entry.game_id == game_id: @@ -1308,9 +1567,7 @@ async def _compute_callout_pool( log.exception("callout_pool_load_failed game=%s", game_id) return frozenset() seats_by_no: dict[int, Seat] = {s.seat_no: s for s in seats} - co_keys: set[tuple[int, str]] = { - (c.seat, c.role_claim) for c in state.co_claims - } + co_keys: set[tuple[int, str]] = {(c.seat, c.role_claim) for c in state.co_claims} pool: set[int] = set() for player in players: if not player.alive: @@ -1324,10 +1581,7 @@ async def _compute_callout_pool( # role. (For info_request, all three info roles are targets.) if player.role in target_roles: role_callout_key = next( - ( - k for k, r in callout_to_role.items() - if r is player.role - ), + (k for k, r in callout_to_role.items() if r is player.role), None, ) if ( @@ -1339,8 +1593,7 @@ async def _compute_callout_pool( # of the requested roles, so prioritize them too. if player.role in (Role.WEREWOLF, Role.MADMAN): already_co_info = any( - (player.seat_no, role) in co_keys - for role in ("seer", "medium", "knight") + (player.seat_no, role) in co_keys for role in ("seer", "medium", "knight") ) if not already_co_info: pool.add(player.seat_no) @@ -1374,9 +1627,7 @@ async def _mark_runoff_done_if_phase( if day is None: return try: - await self.repo.mark_llm_runoff_speech_done( - game_id, day, seat_no - ) + await self.repo.mark_llm_runoff_speech_done(game_id, day, seat_no) except Exception: log.exception( "runoff_speech_done_mark_failed game=%s day=%d seat=%d", @@ -1413,9 +1664,15 @@ def cleanup_game(self, game_id: str) -> int: self._playback_deadlines.pop(rid, None) swept += 1 self._callout_pool_asked.pop(game_id, None) + # Drop fabrication-retry counters for this game. + for key in list(self._fabrication_retries.keys()): + if key[0] == game_id: + self._fabrication_retries.pop(key, None) if swept: log.info( - "speak_arbiter_cleanup_game game=%s swept=%d", game_id, swept, + "speak_arbiter_cleanup_game game=%s swept=%d", + game_id, + swept, ) return swept @@ -1489,17 +1746,11 @@ async def rebuild_public_state( except Exception: log.exception("co_claim_history_load_failed game=%s", game_id) all_events = () - prior_phase_events = tuple( - e for e in all_events if e.phase_id != phase_id - ) + prior_phase_events = tuple(e for e in all_events if e.phase_id != phase_id) prior_co_claims = ( - extract_co_claims_from_events(prior_phase_events) - if prior_phase_events - else () - ) - prior_co_keys = frozenset( - (c.seat, c.role_claim) for c in prior_co_claims + extract_co_claims_from_events(prior_phase_events) if prior_phase_events else () ) + prior_co_keys = frozenset((c.seat, c.role_claim) for c in prior_co_claims) state = rebuild_public_state_from_events(events, prior_co_keys=prior_co_keys) if state is None: return None diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index c074fdf..b3b8b97 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -99,7 +99,9 @@ "required": ["target_seat", "is_wolf"], "properties": { "target_seat": { - "type": "integer", "minimum": 1, "maximum": 9, + "type": "integer", + "minimum": 1, + "maximum": 9, }, "is_wolf": {"type": "boolean"}, }, @@ -119,7 +121,9 @@ "required": ["target_seat", "is_wolf"], "properties": { "target_seat": { - "type": "integer", "minimum": 1, "maximum": 9, + "type": "integer", + "minimum": 1, + "maximum": 9, }, "is_wolf": {"type": ["boolean", "null"]}, }, @@ -215,13 +219,13 @@ def _build_system( " 推奨例: 「ジョナスさんはどう思う?」「ラキオが…」「ユリコ、答えて」" "「ジョナスとsakuraの占い主張、ユリコとセツの霊媒主張」\n" "- 役職 CO (占い師・霊媒師・騎士として名乗る) をするときは、" - "`co_declaration` を `\"seer\" / \"medium\" / \"knight\"` のいずれかに設定し、" + '`co_declaration` を `"seer" / "medium" / "knight"` のいずれかに設定し、' "`text` は「実は私、占い師なんだ」など自然な名乗りにする。" "CO しないなら `co_declaration=null`。" "「占いCO」のような語そのものは `text` に書かない。\n" "- **占い/霊媒結果は構造化フィールドにも必ず反映する。**" "発話 `text` で占い結果を述べる場合 (本物でも騙りでも同じ): " - "`claimed_seer_result` に `{\"target_seat\": 対象席, \"is_wolf\": true/false}` を設定し、" + '`claimed_seer_result` に `{"target_seat": 対象席, "is_wolf": true/false}` を設定し、' "述べた席・結果と完全一致させる。霊媒も同様に `claimed_medium_result` を使う" "(処刑なし/結果なしを明言する場合は `is_wolf=null` を設定し、`target_seat` は処刑対象の席)。" "新しい結果を発表しない発話 (前回までの結果に言及するだけ・一般議論・他人への質問) では " @@ -262,14 +266,8 @@ def _build_user( # Phase-D: prefer the bot's own NpcGameState mirror over the stale # SpeakRequest fields. The state carries role + alive/dead + private # results + wolf chat that the speech LLM needs to be in character. - alive_seats = ( - getattr(state, "alive_seats", None) - or list(request.alive_seats) - ) - dead_seats = ( - getattr(state, "dead_seats", None) - or list(request.dead_seats) - ) + alive_seats = getattr(state, "alive_seats", None) or list(request.alive_seats) + dead_seats = getattr(state, "dead_seats", None) or list(request.dead_seats) cause_map = (getattr(state, "dead_seat_causes", None) or {}) if state else {} def _cause_tag(seat_no: int) -> str: @@ -293,6 +291,23 @@ def _cause_tag(seat_no: int) -> str: f"提案意図: {request.suggested_intent}", ] + # Master-side rejection feedback for the previous attempt. Surfaced + # at the top of the prompt (after the meta header) so the model + # reads the correction before any other context. Only present on + # retry dispatches; first-attempt prompts have request.retry_feedback=None. + retry_feedback = getattr(request, "retry_feedback", None) + if retry_feedback: + lines.append("") + lines.append("## 直前の発話が Master により拒否された") + lines.append(retry_feedback) + lines.append( + "今回の発話では上記の指摘を必ず修正すること。" + "`claimed_seer_result` と `claimed_medium_result` は、" + "あなたの非公開記録 (`自分の占い結果` / `自分の霊媒結果`) " + "または過去にあなたが公の場で出した主張と完全に一致する内容にする" + "(新しい結果がないなら null)。" + ) + # Roster header — the ONLY block where seat numbers explicitly # appear. All other blocks below reference players by display_name. if alive_seats or dead_seats: @@ -316,9 +331,7 @@ def _cause_tag(seat_no: int) -> str: "medium": "霊媒師", "knight": "騎士", } - labels = "、".join( - f"{callout_ja.get(c, c)} ({c})" for c in logic.pending_role_callouts - ) + labels = "、".join(f"{callout_ja.get(c, c)} ({c})" for c in logic.pending_role_callouts) lines.append("") lines.append("## 未回答の役職呼びかけ") lines.append( @@ -341,9 +354,7 @@ def _cause_tag(seat_no: int) -> str: lines.append("## 自分の占い結果 (非公開)") for sr in seer_results: verdict = "黒 (人狼)" if sr.is_wolf else "白 (人狼ではない)" - lines.append( - f" day{sr.day}: {sr.target_name} → {verdict}" - ) + lines.append(f" day{sr.day}: {sr.target_name} → {verdict}") medium_results = getattr(state, "medium_results", []) or [] if medium_results: lines.append("## 自分の霊媒結果 (非公開)") @@ -354,28 +365,24 @@ def _cause_tag(seat_no: int) -> str: verdict = "人狼" else: verdict = "人狼ではない" - lines.append( - f" day{mr.day}: {mr.target_name} → {verdict}" - ) + lines.append(f" day{mr.day}: {mr.target_name} → {verdict}") guard_history = getattr(state, "guard_history", []) or [] if guard_history: lines.append("## 自分の護衛履歴 (非公開)") for g in guard_history: outcome = ( - "(平和な朝)" if g.peaceful_morning - else "(襲撃発生)" if g.peaceful_morning is False + "(平和な朝)" + if g.peaceful_morning + else "(襲撃発生)" + if g.peaceful_morning is False else "(結果未確定)" ) - lines.append( - f" day{g.day}: {g.target_name} を護衛 {outcome}" - ) + lines.append(f" day{g.day}: {g.target_name} を護衛 {outcome}") wolf_chat_history = getattr(state, "wolf_chat_history", []) or [] if wolf_chat_history: lines.append("## 人狼チャット履歴 (狼/狂人にのみ見える)") for wc in wolf_chat_history[-15:]: - lines.append( - f" day{wc.day} {wc.speaker_name}: {wc.text}" - ) + lines.append(f" day{wc.day} {wc.speaker_name}: {wc.text}") wolf_attack_history = getattr(state, "wolf_attack_history", []) or [] if wolf_attack_history: lines.append("## 自分達の襲撃履歴 (非公開)") @@ -386,9 +393,7 @@ def _cause_tag(seat_no: int) -> str: outcome = "(襲撃成功)" else: outcome = "(結果未確定)" - lines.append( - f" day{atk.day}: {atk.target_name} を襲撃 {outcome}" - ) + lines.append(f" day{atk.day}: {atk.target_name} を襲撃 {outcome}") if logic.past_votes: # Public vote history. Each NPC saw the EXECUTION public log when # it landed, but the per-phase fold doesn't carry that text into @@ -398,10 +403,7 @@ def _cause_tag(seat_no: int) -> str: lines.append("") lines.append("## 公開された投票履歴") seat_name_lookup = { - seat_no: name - for seat_no, name in ( - list(alive_seats) + list(dead_seats) - ) + seat_no: name for seat_no, name in (list(alive_seats) + list(dead_seats)) } def _voter_label(seat: int | None) -> str: @@ -414,9 +416,7 @@ def _voter_label(seat: int | None) -> str: label = "決選投票" if round_ >= 1 else "投票" lines.append(f"- day{day} {label}:") for voter, target in pairs: - lines.append( - f" {_voter_label(voter)} → {_voter_label(target)}" - ) + lines.append(f" {_voter_label(voter)} → {_voter_label(target)}") if logic.recent_speeches: lines.append("") lines.append("## 直近の発言 (古い順)") @@ -432,10 +432,7 @@ def _voter_label(seat: int | None) -> str: # MVP code paths leave this empty; rendered as name → score so # the seat-number column doesn't reappear here either. seat_name_lookup_p = { - seat_no: name - for seat_no, name in ( - list(alive_seats) + list(dead_seats) - ) + seat_no: name for seat_no, name in (list(alive_seats) + list(dead_seats)) } lines.append("") lines.append("## 圧力マップ (疑い度)") @@ -549,9 +546,7 @@ def set_persona(self, persona_key: str) -> None: """ if persona_key not in NPC_PERSONAS_BY_KEY: valid = ", ".join(sorted(NPC_PERSONAS_BY_KEY.keys())) - raise ValueError( - f"unknown persona_key {persona_key!r}; valid keys: {valid}" - ) + raise ValueError(f"unknown persona_key {persona_key!r}; valid keys: {valid}") self._persona_key = persona_key async def generate( @@ -617,9 +612,7 @@ async def generate( kwargs["reasoning_effort"] = self.config.reasoning_effort provider_tag = "deepseek" if self.config.mode == "json_object" else "openai-compat" - actor = ( - f"npc_id={request.npc_id} seat={request.seat_no} persona={self._persona_key}" - ) + actor = f"npc_id={request.npc_id} seat={request.seat_no} persona={self._persona_key}" timer = CallTimer() content = "" err: str | None = None @@ -645,7 +638,8 @@ async def generate( err = f"{type(exc).__name__}: {exc}" log.exception( "npc_generate_failed model=%s base_url=%s", - self.config.model, self.config.base_url, + self.config.model, + self.config.base_url, ) await log_llm_call( role="npc_speech", @@ -681,7 +675,6 @@ async def generate( return _build_speech_from_json(data) - async def decide_json( self, *, @@ -749,7 +742,8 @@ async def decide_json( err = f"{type(exc).__name__}: {exc}" log.exception( "npc_decide_failed model=%s base_url=%s", - self.config.model, self.config.base_url, + self.config.model, + self.config.base_url, ) await log_llm_call( role="npc_decision", @@ -793,9 +787,7 @@ def _build_speech_from_json(data: dict[str, object]) -> NpcGeneratedSpeech | Non return None raw_ids = data.get("used_logic_ids") or [] - used_ids = ( - tuple(str(x) for x in raw_ids) if isinstance(raw_ids, list) else () - ) + used_ids = tuple(str(x) for x in raw_ids) if isinstance(raw_ids, list) else () co_raw = data.get("co_declaration") co_declaration = co_raw if co_raw in CO_CLAIM_VALUES else None # `addressed_seat_nos` (list) is the authoritative field; the legacy @@ -807,11 +799,7 @@ def _build_speech_from_json(data: dict[str, object]) -> NpcGeneratedSpeech | Non addressed_seat_nos: list[int] = [] if isinstance(raw_nos, list): for v in raw_nos: - if ( - isinstance(v, int) - and not isinstance(v, bool) - and v not in addressed_seat_nos - ): + if isinstance(v, int) and not isinstance(v, bool) and v not in addressed_seat_nos: addressed_seat_nos.append(v) raw_addr = data.get("addressed_seat_no") addressed_seat_no: int | None = None @@ -823,10 +811,12 @@ def _build_speech_from_json(data: dict[str, object]) -> NpcGeneratedSpeech | Non addressed_seat_no = addressed_seat_nos[0] seer_seat, seer_is_wolf = _parse_claim_fields( - data.get("claimed_seer_result"), allow_null_verdict=False, + data.get("claimed_seer_result"), + allow_null_verdict=False, ) medium_seat, medium_is_wolf = _parse_claim_fields( - data.get("claimed_medium_result"), allow_null_verdict=True, + data.get("claimed_medium_result"), + allow_null_verdict=True, ) # Rough estimate: ~150ms per character for TTS estimated_ms = max(500, len(text) * 150) @@ -846,9 +836,7 @@ def _build_speech_from_json(data: dict[str, object]) -> NpcGeneratedSpeech | Non ) -def _parse_claim_fields( - raw: object, *, allow_null_verdict: bool -) -> tuple[int | None, bool | None]: +def _parse_claim_fields(raw: object, *, allow_null_verdict: bool) -> tuple[int | None, bool | None]: """Coerce a structured claim dict into ``(target_seat, is_wolf)``. Returns ``(None, None)`` for any malformed input — the speech is diff --git a/tests/test_claim_validator.py b/tests/test_claim_validator.py new file mode 100644 index 0000000..0f68ef7 --- /dev/null +++ b/tests/test_claim_validator.py @@ -0,0 +1,421 @@ +"""Tests for `wolfbot.master.claim_validator`. + +Covers the matrix: + * real seer: legal / wrong target / wrong color + * fake seer: legal / day-1 morning 2nd claim / target swap / color flip + * real medium: legal / wrong target / wrong color + * fake medium: day-1 morning / no executions yet / target swap / color flip + +The validator is pure — no fixtures, no asyncio, no DB. +""" + +from __future__ import annotations + +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.ws_messages import ClaimedMediumResult, ClaimedSeerResult +from wolfbot.master.claim_history import ( + ClaimedMediumEntry, + ClaimedSeerEntry, + ClaimerHistory, +) +from wolfbot.master.claim_validator import ( + REASON_MEDIUM_DAY1, + REASON_MEDIUM_FABRICATED_TARGET, + REASON_MEDIUM_NO_EXECUTION, + REASON_MEDIUM_TARGET_SWAP, + REASON_MEDIUM_VERDICT_FLIP, + REASON_MEDIUM_WRONG_VERDICT, + REASON_SEER_DAY1_OVERFLOW, + REASON_SEER_FABRICATED_TARGET, + REASON_SEER_TARGET_SWAP, + REASON_SEER_VERDICT_FLIP, + REASON_SEER_WRONG_VERDICT, + ActualMediumEvent, + ActualSeerEvent, + validate_claim_against_truth, +) + +# ─── real seer ──────────────────────────────────────────────────────── + + +def test_real_seer_matching_target_and_color_passes() -> None: + res = validate_claim_against_truth( + speaker_role=Role.SEER, + speaker_seat=5, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=9, is_wolf=False), + claimed_medium=None, + actual_seer_history=[ActualSeerEvent(day=0, target_seat=9, is_wolf=False)], + ) + assert res.ok, res.feedback + + +def test_real_seer_fabricated_target_rejected() -> None: + """Reproduces game ba084ae208cc / 101d9a90ab58: real seer claims + a target that doesn't appear in their NIGHT_0 random white.""" + res = validate_claim_against_truth( + speaker_role=Role.SEER, + speaker_seat=5, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=4, is_wolf=False), + claimed_medium=None, + actual_seer_history=[ActualSeerEvent(day=0, target_seat=9, is_wolf=False)], + ) + assert not res.ok + assert res.reason == REASON_SEER_FABRICATED_TARGET + assert res.feedback is not None + assert "席4" in res.feedback or "target_seat=4" in res.feedback + + +def test_real_seer_wrong_color_rejected() -> None: + res = validate_claim_against_truth( + speaker_role=Role.SEER, + speaker_seat=5, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=9, is_wolf=True), + claimed_medium=None, + actual_seer_history=[ActualSeerEvent(day=0, target_seat=9, is_wolf=False)], + ) + assert not res.ok + assert res.reason == REASON_SEER_WRONG_VERDICT + + +def test_real_seer_null_claim_passes() -> None: + """Utterances that don't announce a new result are always OK.""" + res = validate_claim_against_truth( + speaker_role=Role.SEER, + speaker_seat=5, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=None, + actual_seer_history=[ActualSeerEvent(day=0, target_seat=9, is_wolf=False)], + ) + assert res.ok + + +# ─── fake seer (wolf / madman / villager bluff) ────────────────────── + + +def test_fake_seer_first_claim_passes() -> None: + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=4, is_wolf=False), + claimed_medium=None, + prior_public_claims=ClaimerHistory(claimer_seat=2), + ) + assert res.ok + + +def test_fake_seer_day1_overflow_rejected() -> None: + """Fake seer who already claimed once on day 1 morning can't issue + a second claim same morning.""" + prior = ClaimerHistory( + claimer_seat=2, + seer_claims=( + ClaimedSeerEntry( + day=1, + target_seat=4, + target_name="席4", + is_wolf=False, + declared_at_event_id="e1", + ), + ), + ) + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=5, is_wolf=False), + claimed_medium=None, + prior_public_claims=prior, + ) + assert not res.ok + assert res.reason == REASON_SEER_DAY1_OVERFLOW + + +def test_fake_seer_target_swap_same_night_rejected() -> None: + """Wolf claimed Alice white in turn 1. Tries to claim Bob in turn 2.""" + prior = ClaimerHistory( + claimer_seat=2, + seer_claims=( + ClaimedSeerEntry( + day=2, + target_seat=4, + target_name="席4", + is_wolf=False, + declared_at_event_id="e1", + ), + ), + ) + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=5, is_wolf=False), + claimed_medium=None, + prior_public_claims=prior, + ) + assert not res.ok + assert res.reason == REASON_SEER_TARGET_SWAP + + +def test_fake_seer_verdict_flip_rejected() -> None: + """Wolf said Alice white, then later flips Alice to black.""" + prior = ClaimerHistory( + claimer_seat=2, + seer_claims=( + ClaimedSeerEntry( + day=2, + target_seat=4, + target_name="席4", + is_wolf=False, + declared_at_event_id="e1", + ), + ), + ) + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=4, is_wolf=True), + claimed_medium=None, + prior_public_claims=prior, + ) + assert not res.ok + assert res.reason == REASON_SEER_VERDICT_FLIP + + +def test_fake_seer_repeat_same_target_color_passes() -> None: + """Re-citing the same prior claim (same target, same color) is fine — + the speaker is just referencing what they said yesterday.""" + prior = ClaimerHistory( + claimer_seat=2, + seer_claims=( + ClaimedSeerEntry( + day=2, + target_seat=4, + target_name="席4", + is_wolf=False, + declared_at_event_id="e1", + ), + ), + ) + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=4, is_wolf=False), + claimed_medium=None, + prior_public_claims=prior, + ) + assert res.ok + + +def test_fake_seer_new_day_new_claim_passes() -> None: + """A fake seer can claim a new result on a new day.""" + prior = ClaimerHistory( + claimer_seat=2, + seer_claims=( + ClaimedSeerEntry( + day=1, + target_seat=4, + target_name="席4", + is_wolf=False, + declared_at_event_id="e1", + ), + ), + ) + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=5, is_wolf=False), + claimed_medium=None, + prior_public_claims=prior, + ) + assert res.ok + + +# ─── real medium ────────────────────────────────────────────────────── + + +def test_real_medium_matching_passes() -> None: + res = validate_claim_against_truth( + speaker_role=Role.MEDIUM, + speaker_seat=6, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=7, is_wolf=False), + actual_medium_history=[ + ActualMediumEvent(day=2, target_seat=7, is_wolf=False), + ], + executions_so_far=1, + ) + assert res.ok + + +def test_real_medium_fabricated_target_rejected() -> None: + res = validate_claim_against_truth( + speaker_role=Role.MEDIUM, + speaker_seat=6, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=3, is_wolf=False), + actual_medium_history=[ + ActualMediumEvent(day=2, target_seat=7, is_wolf=False), + ], + executions_so_far=1, + ) + assert not res.ok + assert res.reason == REASON_MEDIUM_FABRICATED_TARGET + + +def test_real_medium_wrong_color_rejected() -> None: + res = validate_claim_against_truth( + speaker_role=Role.MEDIUM, + speaker_seat=6, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=7, is_wolf=True), + actual_medium_history=[ + ActualMediumEvent(day=2, target_seat=7, is_wolf=False), + ], + executions_so_far=1, + ) + assert not res.ok + assert res.reason == REASON_MEDIUM_WRONG_VERDICT + + +# ─── fake medium ───────────────────────────────────────────────────── + + +def test_fake_medium_day1_morning_rejected() -> None: + """Day-1 morning has no legal medium result — fake medium can't + invent one without immediately outing themselves.""" + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=4, is_wolf=False), + prior_public_claims=ClaimerHistory(claimer_seat=2), + ) + assert not res.ok + assert res.reason == REASON_MEDIUM_DAY1 + + +def test_fake_medium_no_execution_yet_rejected() -> None: + """day 2 morning but executions_so_far == 0 means no execution to + medium (e.g. day-1 ended without a vote outcome).""" + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=4, is_wolf=False), + prior_public_claims=ClaimerHistory(claimer_seat=2), + executions_so_far=0, + ) + assert not res.ok + assert res.reason == REASON_MEDIUM_NO_EXECUTION + + +def test_fake_medium_target_swap_rejected() -> None: + prior = ClaimerHistory( + claimer_seat=2, + medium_claims=( + ClaimedMediumEntry( + day=2, + target_seat=4, + target_name="席4", + is_wolf=False, + declared_at_event_id="e1", + ), + ), + ) + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=5, is_wolf=False), + prior_public_claims=prior, + executions_so_far=1, + ) + assert not res.ok + assert res.reason == REASON_MEDIUM_TARGET_SWAP + + +def test_fake_medium_verdict_flip_rejected() -> None: + prior = ClaimerHistory( + claimer_seat=2, + medium_claims=( + ClaimedMediumEntry( + day=2, + target_seat=4, + target_name="席4", + is_wolf=False, + declared_at_event_id="e1", + ), + ), + ) + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=4, is_wolf=True), + prior_public_claims=prior, + executions_so_far=1, + ) + assert not res.ok + assert res.reason == REASON_MEDIUM_VERDICT_FLIP + + +# ─── boundary cases ────────────────────────────────────────────────── + + +def test_villager_with_null_claims_always_passes() -> None: + """Non-CO speech from a villager (no role-related claims) is fine.""" + res = validate_claim_against_truth( + speaker_role=Role.VILLAGER, + speaker_seat=7, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=None, + prior_public_claims=ClaimerHistory(claimer_seat=7), + ) + assert res.ok + + +def test_madman_first_seer_claim_passes() -> None: + res = validate_claim_against_truth( + speaker_role=Role.MADMAN, + speaker_seat=8, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=3, is_wolf=False), + claimed_medium=None, + prior_public_claims=ClaimerHistory(claimer_seat=8), + ) + assert res.ok diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index d50bb2a..bd28cc3 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -99,7 +99,9 @@ async def test_successful_dispatch_emits_logic_packet_and_speak_request( supported_voices=(), version="1", send=_captured_send(npc_buf), - now_ms=1000, persona_key="setsu") + now_ms=1000, + persona_key="setsu", + ) store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] discussion = DiscussionService(store=store) arb = SpeakArbiter( @@ -133,7 +135,9 @@ async def test_dispatch_records_request_in_audit_table(repo: SqliteRepo) -> None supported_voices=(), version="1", send=_captured_send(npc_buf), - now_ms=1000, persona_key="setsu") + now_ms=1000, + persona_key="setsu", + ) discussion = DiscussionService( store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] ) @@ -171,7 +175,9 @@ async def test_human_speaking_blocks_dispatch(repo: SqliteRepo) -> None: supported_voices=(), version="1", send=_captured_send(npc_buf), - now_ms=1000, persona_key="setsu") + now_ms=1000, + persona_key="setsu", + ) discussion = DiscussionService( store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] ) @@ -248,9 +254,7 @@ async def test_dispatch_attaches_context_to_logic_packet_and_speak_request( ) ) - arb = SpeakArbiter( - repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500 - ) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500) state = _seed_state(game.id) request, reason = await arb.dispatch_request( state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id @@ -288,7 +292,9 @@ async def test_speak_result_accepted_emits_authorized_and_writes_speech_event( supported_voices=(), version="1", send=_captured_send(npc_buf), - now_ms=1000, persona_key="setsu") + now_ms=1000, + persona_key="setsu", + ) store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] discussion = DiscussionService(store=store) arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500) @@ -333,7 +339,9 @@ async def test_speak_result_over_length_rejected(repo: SqliteRepo) -> None: supported_voices=(), version="1", send=_captured_send(npc_buf), - now_ms=1000, persona_key="setsu") + now_ms=1000, + persona_key="setsu", + ) discussion = DiscussionService( store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] ) @@ -394,24 +402,50 @@ async def test_role_callout_pool_prioritizes_real_role_and_wolf_side( # Seats: 1=ジョナス WEREWOLF, 2=ジナ VILLAGER, 3=ラキオ MADMAN, # 4=コメット VILLAGER, 5=セツ SEER (real), 6=シゲミチ VILLAGER (caller). seats = [ - Seat(seat_no=1, display_name="🎩ジョナス", discord_user_id=None, - is_llm=True, persona_key="jonas"), - Seat(seat_no=2, display_name="🟣ジナ", discord_user_id=None, - is_llm=True, persona_key="gina"), - Seat(seat_no=3, display_name="🦋ラキオ", discord_user_id=None, - is_llm=True, persona_key="raqio"), - Seat(seat_no=4, display_name="☄️コメット", discord_user_id=None, - is_llm=True, persona_key="comet"), - Seat(seat_no=5, display_name="🌙セツ", discord_user_id=None, - is_llm=True, persona_key="setsu"), - Seat(seat_no=6, display_name="👽シゲミチ", discord_user_id=None, - is_llm=True, persona_key="shigemichi"), + Seat( + seat_no=1, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + Seat( + seat_no=2, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + Seat( + seat_no=3, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=4, + display_name="☄️コメット", + discord_user_id=None, + is_llm=True, + persona_key="comet", + ), + Seat( + seat_no=5, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=6, + display_name="👽シゲミチ", + discord_user_id=None, + is_llm=True, + persona_key="shigemichi", + ), ] for s in seats: await repo.insert_seat(g.id, s) for sn, role in ( - (1, Role.WEREWOLF), (2, Role.VILLAGER), (3, Role.MADMAN), - (4, Role.VILLAGER), (5, Role.SEER), (6, Role.VILLAGER), + (1, Role.WEREWOLF), + (2, Role.VILLAGER), + (3, Role.MADMAN), + (4, Role.VILLAGER), + (5, Role.SEER), + (6, Role.VILLAGER), ): await repo.set_player_role(g.id, sn, role) @@ -420,20 +454,27 @@ async def test_role_callout_pool_prioritizes_real_role_and_wolf_side( discussion = DiscussionService(store=store) await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - alive_seat_nos=[1, 2, 3, 4, 5, 6], created_at_ms=1, + alive_seat_nos=[1, 2, 3, 4, 5, 6], + created_at_ms=1, ) ) # シゲミチ asks "誰か占い師?" — the analyzer would tag this as # role_callout="seer". Insert a synthetic NPC speech event with the # callout to simulate that. from wolfbot.services.discussion_service import make_npc_generated_event + await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - speaker_seat=6, text="誰か占い師、名乗ってくれ!", + speaker_seat=6, + text="誰か占い師、名乗ってくれ!", role_callout="seer", created_at_ms=10, ) @@ -442,20 +483,29 @@ async def test_role_callout_pool_prioritizes_real_role_and_wolf_side( registry = InMemoryNpcRegistry() bufs: dict[int, list[str]] = {n: [] for n in (1, 2, 3, 4, 5, 6)} persona_by_seat = { - 1: "jonas", 2: "gina", 3: "raqio", - 4: "comet", 5: "setsu", 6: "shigemichi", + 1: "jonas", + 2: "gina", + 3: "raqio", + 4: "comet", + 5: "setsu", + 6: "shigemichi", } for seat_no, persona in persona_by_seat.items(): registry.register( - npc_id=f"npc_{persona}", discord_bot_user_id=f"bot_{persona}", - supported_voices=(), version="1", + npc_id=f"npc_{persona}", + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", send=_captured_send(bufs[seat_no]), - now_ms=2000, persona_key=persona, + now_ms=2000, + persona_key=persona, ) registry.assign(f"npc_{persona}", seat=seat_no, game_id=g.id, phase_id=phase_id) arb = SpeakArbiter( - repo=repo, registry=registry, discussion=discussion, + repo=repo, + registry=registry, + discussion=discussion, now_ms=lambda: 3000, ) @@ -503,24 +553,50 @@ async def test_first_seer_co_fires_counter_co_pool( ) await repo.create_game(g) seats = [ - Seat(seat_no=1, display_name="🎩ジョナス", discord_user_id=None, - is_llm=True, persona_key="jonas"), - Seat(seat_no=2, display_name="🟣ジナ", discord_user_id=None, - is_llm=True, persona_key="gina"), - Seat(seat_no=3, display_name="🦋ラキオ", discord_user_id=None, - is_llm=True, persona_key="raqio"), - Seat(seat_no=4, display_name="☄️コメット", discord_user_id=None, - is_llm=True, persona_key="comet"), - Seat(seat_no=5, display_name="🌙セツ", discord_user_id=None, - is_llm=True, persona_key="setsu"), - Seat(seat_no=6, display_name="👽シゲミチ", discord_user_id=None, - is_llm=True, persona_key="shigemichi"), + Seat( + seat_no=1, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + Seat( + seat_no=2, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + Seat( + seat_no=3, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=4, + display_name="☄️コメット", + discord_user_id=None, + is_llm=True, + persona_key="comet", + ), + Seat( + seat_no=5, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=6, + display_name="👽シゲミチ", + discord_user_id=None, + is_llm=True, + persona_key="shigemichi", + ), ] for s in seats: await repo.insert_seat(g.id, s) for sn, role in ( - (1, Role.WEREWOLF), (2, Role.VILLAGER), (3, Role.MADMAN), - (4, Role.VILLAGER), (5, Role.SEER), (6, Role.VILLAGER), + (1, Role.WEREWOLF), + (2, Role.VILLAGER), + (3, Role.MADMAN), + (4, Role.VILLAGER), + (5, Role.SEER), + (6, Role.VILLAGER), ): await repo.set_player_role(g.id, sn, role) @@ -529,18 +605,24 @@ async def test_first_seer_co_fires_counter_co_pool( discussion = DiscussionService(store=store) await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - alive_seat_nos=[1, 2, 3, 4, 5, 6], created_at_ms=1, + alive_seat_nos=[1, 2, 3, 4, 5, 6], + created_at_ms=1, ) ) # Real seer (seat 5) declares first. No prior callout — the # window is opened purely by the new ``pending_co_response`` # mechanism. from wolfbot.services.discussion_service import make_npc_generated_event + await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, speaker_seat=5, text="実は私、占い師なのです。昨夜、ジョナスを占いました。", @@ -552,23 +634,34 @@ async def test_first_seer_co_fires_counter_co_pool( registry = InMemoryNpcRegistry() bufs: dict[int, list[str]] = {n: [] for n in (1, 2, 3, 4, 5, 6)} persona_by_seat = { - 1: "jonas", 2: "gina", 3: "raqio", - 4: "comet", 5: "setsu", 6: "shigemichi", + 1: "jonas", + 2: "gina", + 3: "raqio", + 4: "comet", + 5: "setsu", + 6: "shigemichi", } for seat_no, persona in persona_by_seat.items(): registry.register( - npc_id=f"npc_{persona}", discord_bot_user_id=f"bot_{persona}", - supported_voices=(), version="1", + npc_id=f"npc_{persona}", + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", send=_captured_send(bufs[seat_no]), - now_ms=2000, persona_key=persona, + now_ms=2000, + persona_key=persona, ) registry.assign( - f"npc_{persona}", seat=seat_no, - game_id=g.id, phase_id=phase_id, + f"npc_{persona}", + seat=seat_no, + game_id=g.id, + phase_id=phase_id, ) arb = SpeakArbiter( - repo=repo, registry=registry, discussion=discussion, + repo=repo, + registry=registry, + discussion=discussion, now_ms=lambda: 3000, ) @@ -618,10 +711,16 @@ async def test_first_co_pool_skips_text_mismatch_co( ) await repo.create_game(g) seats = [ - Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, - is_llm=True, persona_key="raqio"), - Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, - is_llm=True, persona_key="setsu"), + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), ] for s in seats: await repo.insert_seat(g.id, s) @@ -632,18 +731,24 @@ async def test_first_co_pool_skips_text_mismatch_co( store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - alive_seat_nos=[1, 2], created_at_ms=1, + alive_seat_nos=[1, 2], + created_at_ms=1, ) ) # Leaked structured flag: text is a counter-CO request, not a # declaration. The guard must drop the flag → no CO recorded → # pool stays inactive. from wolfbot.services.discussion_service import make_npc_generated_event + await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, speaker_seat=1, text="ステラ、対抗占い師は出ないのか?早く名乗りなさい。", @@ -658,6 +763,7 @@ async def test_first_co_pool_skips_text_mismatch_co( from wolfbot.services.discussion_service import ( rebuild_public_state_from_events, ) + events = await store.load_phase(g.id, phase_id) state = rebuild_public_state_from_events(events) assert state is not None @@ -687,26 +793,53 @@ async def test_info_request_callout_expands_pool_to_all_info_roles( ) await repo.create_game(g) seats = [ - Seat(seat_no=1, display_name="🎩ジョナス", discord_user_id=None, - is_llm=True, persona_key="jonas"), # WEREWOLF - Seat(seat_no=2, display_name="🟣ジナ", discord_user_id=None, - is_llm=True, persona_key="gina"), # MEDIUM - Seat(seat_no=3, display_name="🦋ラキオ", discord_user_id=None, - is_llm=True, persona_key="raqio"), # VILLAGER (caller) - Seat(seat_no=4, display_name="☄️コメット", discord_user_id=None, - is_llm=True, persona_key="comet"), # VILLAGER - Seat(seat_no=5, display_name="🌙セツ", discord_user_id=None, - is_llm=True, persona_key="setsu"), # MADMAN - Seat(seat_no=6, display_name="👽シゲミチ", discord_user_id=None, - is_llm=True, persona_key="shigemichi"), # SEER - Seat(seat_no=7, display_name="🍎SQ", discord_user_id=None, - is_llm=True, persona_key="sq"), # KNIGHT + Seat( + seat_no=1, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), # WEREWOLF + Seat( + seat_no=2, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), # MEDIUM + Seat( + seat_no=3, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), # VILLAGER (caller) + Seat( + seat_no=4, + display_name="☄️コメット", + discord_user_id=None, + is_llm=True, + persona_key="comet", + ), # VILLAGER + Seat( + seat_no=5, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), # MADMAN + Seat( + seat_no=6, + display_name="👽シゲミチ", + discord_user_id=None, + is_llm=True, + persona_key="shigemichi", + ), # SEER + Seat( + seat_no=7, display_name="🍎SQ", discord_user_id=None, is_llm=True, persona_key="sq" + ), # KNIGHT ] for s in seats: await repo.insert_seat(g.id, s) for sn, role in ( - (1, Role.WEREWOLF), (2, Role.MEDIUM), (3, Role.VILLAGER), - (4, Role.VILLAGER), (5, Role.MADMAN), (6, Role.SEER), + (1, Role.WEREWOLF), + (2, Role.MEDIUM), + (3, Role.VILLAGER), + (4, Role.VILLAGER), + (5, Role.MADMAN), + (6, Role.SEER), (7, Role.KNIGHT), ): await repo.set_player_role(g.id, sn, role) @@ -716,18 +849,25 @@ async def test_info_request_callout_expands_pool_to_all_info_roles( discussion = DiscussionService(store=store) await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - alive_seat_nos=[1, 2, 3, 4, 5, 6, 7], created_at_ms=1, + alive_seat_nos=[1, 2, 3, 4, 5, 6, 7], + created_at_ms=1, ) ) # Caller (ラキオ, villager): 「誰か怪しい人挙げて」 → role_callout='info_request' from wolfbot.services.discussion_service import make_npc_generated_event + await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - speaker_seat=3, text="誰か怪しい人挙げてくれ!", + speaker_seat=3, + text="誰か怪しい人挙げてくれ!", role_callout="info_request", created_at_ms=10, ) @@ -736,20 +876,30 @@ async def test_info_request_callout_expands_pool_to_all_info_roles( registry = InMemoryNpcRegistry() bufs: dict[int, list[str]] = {n: [] for n in range(1, 8)} persona_by_seat = { - 1: "jonas", 2: "gina", 3: "raqio", 4: "comet", - 5: "setsu", 6: "shigemichi", 7: "sq", + 1: "jonas", + 2: "gina", + 3: "raqio", + 4: "comet", + 5: "setsu", + 6: "shigemichi", + 7: "sq", } for seat_no, persona in persona_by_seat.items(): registry.register( - npc_id=f"npc_{persona}", discord_bot_user_id=f"bot_{persona}", - supported_voices=(), version="1", + npc_id=f"npc_{persona}", + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", send=_captured_send(bufs[seat_no]), - now_ms=2000, persona_key=persona, + now_ms=2000, + persona_key=persona, ) registry.assign(f"npc_{persona}", seat=seat_no, game_id=g.id, phase_id=phase_id) arb = SpeakArbiter( - repo=repo, registry=registry, discussion=discussion, + repo=repo, + registry=registry, + discussion=discussion, now_ms=lambda: 3000, ) await arb.try_dispatch_next(g.id) @@ -792,10 +942,16 @@ async def test_info_request_consumed_when_any_info_role_cod( ) await repo.create_game(g) seats = [ - Seat(seat_no=1, display_name="🎩ジョナス", discord_user_id=None, - is_llm=True, persona_key="jonas"), # SEER (will CO) - Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, - is_llm=True, persona_key="setsu"), # VILLAGER + Seat( + seat_no=1, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), # SEER (will CO) + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), # VILLAGER ] for s in seats: await repo.insert_seat(g.id, s) @@ -806,17 +962,24 @@ async def test_info_request_consumed_when_any_info_role_cod( store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - alive_seat_nos=[1, 2], created_at_ms=1, + alive_seat_nos=[1, 2], + created_at_ms=1, ) ) from wolfbot.services.discussion_service import make_npc_generated_event + await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - speaker_seat=2, text="誰か怪しい人挙げて", + speaker_seat=2, + text="誰か怪しい人挙げて", role_callout="info_request", created_at_ms=10, ) @@ -824,9 +987,12 @@ async def test_info_request_consumed_when_any_info_role_cod( # Real seer answers — co_declaration='seer'. await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - speaker_seat=1, text="私が占い師だ", + speaker_seat=1, + text="私が占い師だ", co_declaration="seer", created_at_ms=20, ) @@ -837,6 +1003,7 @@ async def test_info_request_consumed_when_any_info_role_cod( from wolfbot.services.discussion_service import ( rebuild_public_state_from_events, ) + events = await store.load_phase(g.id, phase_id) state = rebuild_public_state_from_events(events) assert state is not None @@ -865,19 +1032,34 @@ async def test_role_callout_pool_excludes_seats_that_already_cod( ) await repo.create_game(g) seats = [ - Seat(seat_no=1, display_name="🎩ジョナス", discord_user_id=None, - is_llm=True, persona_key="jonas"), # WEREWOLF — fake-CO'd already - Seat(seat_no=2, display_name="🟣ジナ", discord_user_id=None, - is_llm=True, persona_key="gina"), # MADMAN — still in pool - Seat(seat_no=3, display_name="🌙セツ", discord_user_id=None, - is_llm=True, persona_key="setsu"), # SEER (real) — still in pool - Seat(seat_no=4, display_name="☄️コメット", discord_user_id=None, - is_llm=True, persona_key="comet"), # VILLAGER — never in pool + Seat( + seat_no=1, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), # WEREWOLF — fake-CO'd already + Seat( + seat_no=2, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), # MADMAN — still in pool + Seat( + seat_no=3, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), # SEER (real) — still in pool + Seat( + seat_no=4, + display_name="☄️コメット", + discord_user_id=None, + is_llm=True, + persona_key="comet", + ), # VILLAGER — never in pool ] for s in seats: await repo.insert_seat(g.id, s) for sn, role in ( - (1, Role.WEREWOLF), (2, Role.MADMAN), (3, Role.SEER), (4, Role.VILLAGER), + (1, Role.WEREWOLF), + (2, Role.MADMAN), + (3, Role.SEER), + (4, Role.VILLAGER), ): await repo.set_player_role(g.id, sn, role) @@ -886,18 +1068,25 @@ async def test_role_callout_pool_excludes_seats_that_already_cod( discussion = DiscussionService(store=store) await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - alive_seat_nos=[1, 2, 3, 4], created_at_ms=1, + alive_seat_nos=[1, 2, 3, 4], + created_at_ms=1, ) ) from wolfbot.services.discussion_service import make_npc_generated_event + # Wolf already fake-CO'd as seer (consumes the original callout). await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - speaker_seat=1, text="私が占い師だ", + speaker_seat=1, + text="私が占い師だ", co_declaration="seer", created_at_ms=10, ) @@ -905,9 +1094,12 @@ async def test_role_callout_pool_excludes_seats_that_already_cod( # Then someone else asks "他に占い師は?" — re-fires the seer callout. await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - speaker_seat=4, text="他に占い師の方は?", + speaker_seat=4, + text="他に占い師の方は?", role_callout="seer", created_at_ms=20, ) @@ -918,15 +1110,20 @@ async def test_role_callout_pool_excludes_seats_that_already_cod( persona_by_seat = {1: "jonas", 2: "gina", 3: "setsu", 4: "comet"} for seat_no, persona in persona_by_seat.items(): registry.register( - npc_id=f"npc_{persona}", discord_bot_user_id=f"bot_{persona}", - supported_voices=(), version="1", + npc_id=f"npc_{persona}", + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", send=_captured_send(bufs[seat_no]), - now_ms=2000, persona_key=persona, + now_ms=2000, + persona_key=persona, ) registry.assign(f"npc_{persona}", seat=seat_no, game_id=g.id, phase_id=phase_id) arb = SpeakArbiter( - repo=repo, registry=registry, discussion=discussion, + repo=repo, + registry=registry, + discussion=discussion, now_ms=lambda: 3000, ) @@ -962,10 +1159,16 @@ async def test_role_callout_pool_asked_tracker_avoids_repick( ) await repo.create_game(g) seats = [ - Seat(seat_no=1, display_name="🎩ジョナス", discord_user_id=None, - is_llm=True, persona_key="jonas"), - Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, - is_llm=True, persona_key="setsu"), + Seat( + seat_no=1, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), ] for s in seats: await repo.insert_seat(g.id, s) @@ -977,12 +1180,16 @@ async def test_role_callout_pool_asked_tracker_avoids_repick( discussion = DiscussionService(store=store) await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - alive_seat_nos=[1, 2], created_at_ms=1, + alive_seat_nos=[1, 2], + created_at_ms=1, ) ) from wolfbot.services.discussion_service import make_npc_generated_event + # External callout — fired by no-one in the test (we add a fake event # with a non-pool speaker_seat, but to avoid violating the alive-set # we attribute it to seat 1 with role_callout but no co_declaration). @@ -996,9 +1203,12 @@ async def test_role_callout_pool_asked_tracker_avoids_repick( # they didn't co_declare. await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - speaker_seat=1, text="占い師は誰だ?", + speaker_seat=1, + text="占い師は誰だ?", role_callout="seer", created_at_ms=10, ) @@ -1009,21 +1219,28 @@ async def test_role_callout_pool_asked_tracker_avoids_repick( persona_by_seat = {1: "jonas", 2: "setsu"} for seat_no, persona in persona_by_seat.items(): registry.register( - npc_id=f"npc_{persona}", discord_bot_user_id=f"bot_{persona}", - supported_voices=(), version="1", + npc_id=f"npc_{persona}", + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", send=_captured_send(bufs[seat_no]), - now_ms=2000, persona_key=persona, + now_ms=2000, + persona_key=persona, ) registry.assign(f"npc_{persona}", seat=seat_no, game_id=g.id, phase_id=phase_id) arb = SpeakArbiter( - repo=repo, registry=registry, discussion=discussion, + repo=repo, + registry=registry, + discussion=discussion, now_ms=lambda: 3000, ) # First dispatch from pool — pick is random between 1 and 2. await arb.try_dispatch_next(g.id) - first_picked = next((s for s, b in bufs.items() if any('"speak_request"' in m for m in b)), None) + first_picked = next( + (s for s, b in bufs.items() if any('"speak_request"' in m for m in b)), None + ) assert first_picked in {1, 2} # The picked seat is now in `_callout_pool_asked`. Second dispatch @@ -1033,7 +1250,9 @@ async def test_role_callout_pool_asked_tracker_avoids_repick( # Need to clear _pending so dispatch can proceed (no playback yet). arb._pending.clear() await arb.try_dispatch_next(g.id) - second_picked = next((s for s, b in bufs.items() if any('"speak_request"' in m for m in b)), None) + second_picked = next( + (s for s, b in bufs.items() if any('"speak_request"' in m for m in b)), None + ) assert second_picked is not None assert second_picked != first_picked, ( f"asked tracker must steer to a different pool member: " @@ -1068,10 +1287,16 @@ async def test_speak_result_rejection_clears_pending_and_redispatches( ) await repo.create_game(g) seats = [ - Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, - is_llm=True, persona_key="raqio"), - Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, - is_llm=True, persona_key="setsu"), + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), ] for s in seats: await repo.insert_seat(g.id, s) @@ -1083,9 +1308,12 @@ async def test_speak_result_rejection_clears_pending_and_redispatches( discussion = DiscussionService(store=store) await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - alive_seat_nos=[1, 2], created_at_ms=1, + alive_seat_nos=[1, 2], + created_at_ms=1, ) ) @@ -1096,10 +1324,13 @@ async def test_speak_result_rejection_clears_pending_and_redispatches( ("npc_setsu", "setsu", 2), ): registry.register( - npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", - supported_voices=(), version="1", + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", send=_captured_send(bufs[persona]), - now_ms=1000, persona_key=persona, + now_ms=1000, + persona_key=persona, ) registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) @@ -1108,12 +1339,16 @@ async def test_speak_result_rejection_clears_pending_and_redispatches( # production the NPC sends heartbeats continuously so stale clock vs # heartbeat doesn't happen. arb = SpeakArbiter( - repo=repo, registry=registry, discussion=discussion, + repo=repo, + registry=registry, + discussion=discussion, now_ms=lambda: 2000, config=SpeakArbiterConfig(heartbeat_timeout_ms=60_000), ) state = await arb.rebuild_public_state( - game_id=g.id, day=1, phase=Phase.DAY_DISCUSSION, + game_id=g.id, + day=1, + phase=Phase.DAY_DISCUSSION, ) assert state is not None req, _ = await arb.dispatch_request( @@ -1140,7 +1375,10 @@ async def test_speak_result_rejection_clears_pending_and_redispatches( text="late response", ) ok, reason = await arb.handle_speak_result( - result, current_phase_id=phase_id, day=1, phase=Phase.DAY_DISCUSSION, + result, + current_phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, ) assert not ok and reason == "expired_request" @@ -1153,9 +1391,7 @@ async def test_speak_result_rejection_clears_pending_and_redispatches( # Without the fix, both bufs would be empty and the day would stall. # (The picker may choose either seat 1 or seat 2 depending on RNG — # both have count=0 and no addressed bias.) - new_request_msgs = [ - m for m in (*bufs["raqio"], *bufs["setsu"]) if '"speak_request"' in m - ] + new_request_msgs = [m for m in (*bufs["raqio"], *bufs["setsu"]) if '"speak_request"' in m] assert new_request_msgs, ( "rejection must trigger try_dispatch_next; another candidate must be picked. " f"raqio buf: {bufs['raqio']!r}, setsu buf: {bufs['setsu']!r}" @@ -1172,7 +1408,9 @@ async def test_speak_result_stale_phase_rejected(repo: SqliteRepo) -> None: supported_voices=(), version="1", send=_captured_send(npc_buf), - now_ms=1000, persona_key="setsu") + now_ms=1000, + persona_key="setsu", + ) discussion = DiscussionService( store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] ) @@ -1213,7 +1451,9 @@ async def test_serial_speech_blocks_after_authorize_until_finished( supported_voices=(), version="1", send=_captured_send(npc_buf), - now_ms=1000, persona_key="setsu") + now_ms=1000, + persona_key="setsu", + ) discussion = DiscussionService( store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] ) @@ -1349,8 +1589,12 @@ async def test_try_dispatch_next_prefers_silent_seat_over_lowest( await repo.create_game(g) seats = [ Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), - Seat(seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu"), - Seat(seat_no=3, display_name="ジーナ", discord_user_id=None, is_llm=True, persona_key="gina"), + Seat( + seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, display_name="ジーナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), ] for s in seats: await repo.insert_seat(g.id, s) @@ -1427,9 +1671,7 @@ async def test_try_dispatch_next_prefers_silent_seat_over_lowest( assert not buf2, "non-silent NPC at seat 2 must not be picked" -async def _fetch_selection_reason( - repo: SqliteRepo, game_id: str -) -> tuple[str | None, str | None]: +async def _fetch_selection_reason(repo: SqliteRepo, game_id: str) -> tuple[str | None, str | None]: """Pull (seat_no_repr, reason) for the most recent dispatched request. Used by the selection_reason classification tests; the column is @@ -1475,8 +1717,12 @@ async def test_try_dispatch_next_records_selection_reason_addressed( await repo.create_game(g) seats = [ Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), - Seat(seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu"), - Seat(seat_no=3, display_name="ジーナ", discord_user_id=None, is_llm=True, persona_key="gina"), + Seat( + seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, display_name="ジーナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), ] for s in seats: await repo.insert_seat(g.id, s) @@ -1574,8 +1820,12 @@ async def test_try_dispatch_next_records_selection_reason_silent_rotation( await repo.create_game(g) seats = [ Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), - Seat(seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu"), - Seat(seat_no=3, display_name="ジーナ", discord_user_id=None, is_llm=True, persona_key="gina"), + Seat( + seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, display_name="ジーナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), ] for s in seats: await repo.insert_seat(g.id, s) @@ -1672,12 +1922,19 @@ async def test_try_dispatch_next_avoids_immediate_repeat_after_first_round( ) await repo.create_game(g) seats = [ - Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, - is_llm=True, persona_key="raqio"), - Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, - is_llm=True, persona_key="setsu"), - Seat(seat_no=3, display_name="🟣ジナ", discord_user_id=None, - is_llm=True, persona_key="gina"), + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), ] for s in seats: await repo.insert_seat(g.id, s) @@ -1690,9 +1947,12 @@ async def test_try_dispatch_next_avoids_immediate_repeat_after_first_round( discussion = DiscussionService(store=store) await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - alive_seat_nos=[1, 2, 3], created_at_ms=1, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, ) ) # Simulate a complete first round: every alive NPC spoke once. Last @@ -1706,9 +1966,13 @@ async def test_try_dispatch_next_avoids_immediate_repeat_after_first_round( ): await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - speaker_seat=seat_no, text=text, created_at_ms=ts, + speaker_seat=seat_no, + text=text, + created_at_ms=ts, ) ) @@ -1720,25 +1984,28 @@ async def test_try_dispatch_next_avoids_immediate_repeat_after_first_round( ("npc_gina", "gina", 3), ): registry.register( - npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", - supported_voices=(), version="1", + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", send=_captured_send(bufs[persona]), - now_ms=1000, persona_key=persona, + now_ms=1000, + persona_key=persona, ) registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) import random as _random arb = SpeakArbiter( - repo=repo, registry=registry, discussion=discussion, + repo=repo, + registry=registry, + discussion=discussion, now_ms=lambda: 2000, rng=_random.Random(0), ) await arb.try_dispatch_next(g.id) - assert not bufs["raqio"], ( - "ラキオ was the immediate previous speaker — must NOT be re-picked" - ) + assert not bufs["raqio"], "ラキオ was the immediate previous speaker — must NOT be re-picked" # Equal-priority tiebreak is randomised — seat 2 OR seat 3 may win, # but never seat 1 (the just-spoken seat). Seeded RNG keeps the # specific winner deterministic for the test (currently seat 2). @@ -1775,12 +2042,19 @@ async def test_try_dispatch_next_prefers_lower_speech_count( ) await repo.create_game(g) seats = [ - Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, - is_llm=True, persona_key="raqio"), - Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, - is_llm=True, persona_key="setsu"), - Seat(seat_no=3, display_name="🟣ジナ", discord_user_id=None, - is_llm=True, persona_key="gina"), + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), ] for s in seats: await repo.insert_seat(g.id, s) @@ -1793,10 +2067,13 @@ async def test_try_dispatch_next_prefers_lower_speech_count( discussion = DiscussionService(store=store) await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - alive_seat_nos=[1, 2, 3], created_at_ms=1, - ) + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) ) from wolfbot.services.discussion_service import make_npc_generated_event @@ -1811,21 +2088,25 @@ async def test_try_dispatch_next_prefers_lower_speech_count( # — that pool path is exercised separately. Here we want to pin the # bare ``low_count_rotation`` reason without crossing pool lines. payload = [ - (10, 1, "ラキオ1巡目"), # 1:1 - (20, 2, "セツの所感"), # 2:1 - (30, 1, "ラキオ反論1"), # 1:2 - (40, 2, "セツ反応1"), # 2:2 - (50, 1, "ラキオ反論2"), # 1:3 - (55, 2, "セツ反応2"), # 2:3 - (60, 3, "ジナの差し込み"), # 3:1 (breaks pair window) - (70, 1, "ラキオ反論3"), # 1:4 - (80, 2, "セツ反応3"), # 2:4 + (10, 1, "ラキオ1巡目"), # 1:1 + (20, 2, "セツの所感"), # 2:1 + (30, 1, "ラキオ反論1"), # 1:2 + (40, 2, "セツ反応1"), # 2:2 + (50, 1, "ラキオ反論2"), # 1:3 + (55, 2, "セツ反応2"), # 2:3 + (60, 3, "ジナの差し込み"), # 3:1 (breaks pair window) + (70, 1, "ラキオ反論3"), # 1:4 + (80, 2, "セツ反応3"), # 2:4 ] for ts, seat, text in payload: kwargs: dict[str, object] = dict( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - speaker_seat=seat, text=text, created_at_ms=ts, + speaker_seat=seat, + text=text, + created_at_ms=ts, ) await store.insert(make_npc_generated_event(**kwargs)) # type: ignore[arg-type] @@ -1837,15 +2118,20 @@ async def test_try_dispatch_next_prefers_lower_speech_count( ("npc_gina", "gina", 3), ): registry.register( - npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", - supported_voices=(), version="1", + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", send=_captured_send(bufs[persona]), - now_ms=1000, persona_key=persona, + now_ms=1000, + persona_key=persona, ) registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) arb = SpeakArbiter( - repo=repo, registry=registry, discussion=discussion, + repo=repo, + registry=registry, + discussion=discussion, now_ms=lambda: 2000, ) await arb.try_dispatch_next(g.id) @@ -1880,10 +2166,16 @@ async def test_try_dispatch_next_lru_when_speech_counts_tied( ) await repo.create_game(g) seats = [ - Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, - is_llm=True, persona_key="raqio"), - Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, - is_llm=True, persona_key="setsu"), + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), ] for s in seats: await repo.insert_seat(g.id, s) @@ -1895,9 +2187,12 @@ async def test_try_dispatch_next_lru_when_speech_counts_tied( discussion = DiscussionService(store=store) await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - alive_seat_nos=[1, 2], created_at_ms=1, + alive_seat_nos=[1, 2], + created_at_ms=1, ) ) from wolfbot.services.discussion_service import make_npc_generated_event @@ -1921,9 +2216,12 @@ async def test_try_dispatch_next_lru_when_speech_counts_tied( text, co = co_setup.get((ts, seat), (f"seat {seat} ts={ts}", None)) await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - speaker_seat=seat, text=text, + speaker_seat=seat, + text=text, co_declaration=co, created_at_ms=ts, ) @@ -1936,15 +2234,20 @@ async def test_try_dispatch_next_lru_when_speech_counts_tied( ("npc_setsu", "setsu", 2), ): registry.register( - npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", - supported_voices=(), version="1", + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", send=_captured_send(bufs[persona]), - now_ms=1000, persona_key=persona, + now_ms=1000, + persona_key=persona, ) registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) arb = SpeakArbiter( - repo=repo, registry=registry, discussion=discussion, + repo=repo, + registry=registry, + discussion=discussion, now_ms=lambda: 2000, ) await arb.try_dispatch_next(g.id) @@ -2008,12 +2311,21 @@ async def test_repeated_co_from_same_seat_does_not_bypass_gate( ) await repo.create_game(g) seats = [ - Seat(seat_no=1, display_name="Alice", discord_user_id="u1", - is_llm=False, persona_key=None), - Seat(seat_no=2, display_name="🎩ジョナス", discord_user_id=None, - is_llm=True, persona_key="jonas"), - Seat(seat_no=3, display_name="🦋ラキオ", discord_user_id=None, - is_llm=True, persona_key="raqio"), + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + Seat( + seat_no=2, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + Seat( + seat_no=3, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), ] for s in seats: await repo.insert_seat(g.id, s) @@ -2025,33 +2337,41 @@ async def test_repeated_co_from_same_seat_does_not_bypass_gate( store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - alive_seat_nos=[1, 2, 3], created_at_ms=1, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, ) ) from wolfbot.services.discussion_service import ( make_npc_generated_event, rebuild_public_state_from_events, ) + # First CO at ts=5 is genuine; the next 4 events form the volley # window. Raqio re-emits `co_declaration='seer'` on every reply but # the dedup makes those repeats has_info=False, so the window's last # 4 entries are all (seat, False) → gate fires. events_seq = [ - (5, 3, "seer"), # first CO (outside the last-4 window) - (10, 2, None), # window start - (20, 3, "seer"), # repeat — should NOT count as info + (5, 3, "seer"), # first CO (outside the last-4 window) + (10, 2, None), # window start + (20, 3, "seer"), # repeat — should NOT count as info (30, 2, None), - (40, 3, "seer"), # another repeat + (40, 3, "seer"), # another repeat ] for ts, seat, co in events_seq: await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - speaker_seat=seat, text=f"seat {seat} ts {ts}", - co_declaration=co, created_at_ms=ts, + speaker_seat=seat, + text=f"seat {seat} ts {ts}", + co_declaration=co, + created_at_ms=ts, ) ) events = await store.load_phase(g.id, phase_id) @@ -2061,6 +2381,7 @@ async def test_repeated_co_from_same_seat_does_not_bypass_gate( # Last 4 entries: (2, False), (3, False), (2, False), (3, False) # Pair volley: 2 distinct seats {2,3}, no has_info in window → demote. from wolfbot.master.speak_arbiter import _compute_demoted_seats + assert _compute_demoted_seats(state.recent_speech_summary) == frozenset({2, 3}) @@ -2103,12 +2424,23 @@ async def test_try_dispatch_next_diverts_around_pair_volley( ) await repo.create_game(g) seats = [ - Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, - is_llm=True, persona_key="raqio"), - Seat(seat_no=2, display_name="🎩ジョナス", discord_user_id=None, - is_llm=True, persona_key="jonas"), - Seat(seat_no=3, display_name="🟣ジナ", discord_user_id=None, - is_llm=True, persona_key="gina"), + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + Seat( + seat_no=3, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), ] for s in seats: await repo.insert_seat(g.id, s) @@ -2120,12 +2452,16 @@ async def test_try_dispatch_next_diverts_around_pair_volley( discussion = DiscussionService(store=store) await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - alive_seat_nos=[1, 2, 3], created_at_ms=1, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, ) ) from wolfbot.services.discussion_service import make_npc_generated_event + # First, seed a single seat-3 speech so silent_seats becomes empty. # Then 4 alternating no-info speeches between seats 1 and 2 — the # last 4 events form the pair-volley window. Without the demotion @@ -2133,18 +2469,24 @@ async def test_try_dispatch_next_diverts_around_pair_volley( # seat 3 is the only non-demoted candidate. await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - speaker_seat=3, text="ジナの開始発言", + speaker_seat=3, + text="ジナの開始発言", created_at_ms=5, ) ) for ts, seat in ((10, 1), (20, 2), (30, 1), (40, 2)): await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - speaker_seat=seat, text=f"seat {seat}", + speaker_seat=seat, + text=f"seat {seat}", created_at_ms=ts, ) ) @@ -2157,15 +2499,20 @@ async def test_try_dispatch_next_diverts_around_pair_volley( ("npc_gina", "gina", 3), ): registry.register( - npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", - supported_voices=(), version="1", + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", send=_captured_send(bufs[persona]), - now_ms=1000, persona_key=persona, + now_ms=1000, + persona_key=persona, ) registry.assign(npc_id, seat=seat, game_id=g.id, phase_id=phase_id) arb = SpeakArbiter( - repo=repo, registry=registry, discussion=discussion, + repo=repo, + registry=registry, + discussion=discussion, now_ms=lambda: 2000, ) await arb.try_dispatch_next(g.id) @@ -2207,12 +2554,23 @@ async def test_repeated_co_across_days_does_not_block_volley_demotion( ) await repo.create_game(g) seats = [ - Seat(seat_no=1, display_name="🎩ジョナス", discord_user_id=None, - is_llm=True, persona_key="jonas"), - Seat(seat_no=3, display_name="🟣ジナ", discord_user_id=None, - is_llm=True, persona_key="gina"), - Seat(seat_no=9, display_name="👑ユリコ", discord_user_id=None, - is_llm=True, persona_key="yuriko"), + Seat( + seat_no=1, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + Seat( + seat_no=3, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + Seat( + seat_no=9, + display_name="👑ユリコ", + discord_user_id=None, + is_llm=True, + persona_key="yuriko", + ), ] for s in seats: await repo.insert_seat(g.id, s) @@ -2227,16 +2585,22 @@ async def test_repeated_co_across_days_does_not_block_volley_demotion( day1_phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) await store.insert( make_phase_baseline( - game_id=g.id, phase_id=day1_phase_id, day=1, + game_id=g.id, + phase_id=day1_phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - alive_seat_nos=[1, 3, 9], created_at_ms=1, + alive_seat_nos=[1, 3, 9], + created_at_ms=1, ) ) await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=day1_phase_id, day=1, + game_id=g.id, + phase_id=day1_phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - speaker_seat=1, text="私が占い師だ", + speaker_seat=1, + text="私が占い師だ", co_declaration="seer", created_at_ms=10, ) @@ -2248,25 +2612,34 @@ async def test_repeated_co_across_days_does_not_block_volley_demotion( day2_phase_id = make_phase_id(g.id, 2, Phase.DAY_DISCUSSION) await store.insert( make_phase_baseline( - game_id=g.id, phase_id=day2_phase_id, day=2, + game_id=g.id, + phase_id=day2_phase_id, + day=2, phase=Phase.DAY_DISCUSSION, - alive_seat_nos=[1, 3, 9], created_at_ms=1000, + alive_seat_nos=[1, 3, 9], + created_at_ms=1000, ) ) await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=day2_phase_id, day=2, + game_id=g.id, + phase_id=day2_phase_id, + day=2, phase=Phase.DAY_DISCUSSION, - speaker_seat=3, text="day2 開始発言", + speaker_seat=3, + text="day2 開始発言", created_at_ms=1005, ) ) for ts, seat in ((1010, 9), (1020, 1), (1030, 9), (1040, 1)): await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=day2_phase_id, day=2, + game_id=g.id, + phase_id=day2_phase_id, + day=2, phase=Phase.DAY_DISCUSSION, - speaker_seat=seat, text=f"seat {seat} day2", + speaker_seat=seat, + text=f"seat {seat} day2", co_declaration="seer" if seat == 1 else None, created_at_ms=ts, ) @@ -2280,15 +2653,20 @@ async def test_repeated_co_across_days_does_not_block_volley_demotion( ("npc_yuriko", "yuriko", 9), ): registry.register( - npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", - supported_voices=(), version="1", + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", send=_captured_send(bufs[persona]), - now_ms=2000, persona_key=persona, + now_ms=2000, + persona_key=persona, ) registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=day2_phase_id) arb = SpeakArbiter( - repo=repo, registry=registry, discussion=discussion, + repo=repo, + registry=registry, + discussion=discussion, now_ms=lambda: 3000, ) await arb.try_dispatch_next(g.id) @@ -2340,7 +2718,9 @@ async def test_handle_tts_failed_pops_pending_before_returning( ) await repo.create_game(g) arb._pending["sr-x"] = _PendingRequest( - request_id="sr-x", npc_id="npc1", seat_no=2, + request_id="sr-x", + npc_id="npc1", + seat_no=2, phase_id="rv-tts-fail::day1::DAY_DISCUSSION::1", game_id=g.id, expires_at_ms=10_000, @@ -2361,8 +2741,11 @@ async def test_handle_tts_failed_pops_pending_before_returning( captured_game_id = arb._pending["sr-x"].game_id msg = TtsFailed( - ts=2_000, trace_id="t-x", request_id="sr-x", - npc_id="npc1", failure_reason="voicevox_timeout", + ts=2_000, + trace_id="t-x", + request_id="sr-x", + npc_id="npc1", + failure_reason="voicevox_timeout", ) await arb.handle_tts_failed(msg) @@ -2395,18 +2778,27 @@ async def test_arbiter_cleanup_game_drops_only_target_game(repo: SqliteRepo) -> # in active_playback / playback_deadlines (the gates the user-facing # serial-speech check inspects). arb._pending["req-g1-a"] = _PendingRequest( - request_id="req-g1-a", npc_id="npc1", seat_no=2, - phase_id="g1::day1::DAY_DISCUSSION::1", game_id="g1", + request_id="req-g1-a", + npc_id="npc1", + seat_no=2, + phase_id="g1::day1::DAY_DISCUSSION::1", + game_id="g1", expires_at_ms=10_000, ) arb._pending["req-g1-b"] = _PendingRequest( - request_id="req-g1-b", npc_id="npc2", seat_no=3, - phase_id="g1::day1::DAY_DISCUSSION::1", game_id="g1", + request_id="req-g1-b", + npc_id="npc2", + seat_no=3, + phase_id="g1::day1::DAY_DISCUSSION::1", + game_id="g1", expires_at_ms=10_000, ) arb._pending["req-g2-c"] = _PendingRequest( - request_id="req-g2-c", npc_id="npc3", seat_no=4, - phase_id="g2::day1::DAY_DISCUSSION::1", game_id="g2", + request_id="req-g2-c", + npc_id="npc3", + seat_no=4, + phase_id="g2::day1::DAY_DISCUSSION::1", + game_id="g2", expires_at_ms=10_000, ) arb._active_playback.update({"req-g1-a", "req-g2-c"}) @@ -2450,20 +2842,34 @@ async def test_multi_addressed_seats_both_get_priority( ) await repo.create_game(g) seats = [ - Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, - is_llm=True, persona_key="raqio"), - Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, - is_llm=True, persona_key="setsu"), - Seat(seat_no=3, display_name="🟣ジナ", discord_user_id=None, - is_llm=True, persona_key="gina"), - Seat(seat_no=4, display_name="🎩ジョナス", discord_user_id=None, - is_llm=True, persona_key="jonas"), + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + Seat( + seat_no=4, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), ] for s in seats: await repo.insert_seat(g.id, s) for sn, role in ( - (1, Role.WEREWOLF), (2, Role.SEER), - (3, Role.MEDIUM), (4, Role.VILLAGER), + (1, Role.WEREWOLF), + (2, Role.SEER), + (3, Role.MEDIUM), + (4, Role.VILLAGER), ): await repo.set_player_role(g.id, sn, role) @@ -2472,9 +2878,12 @@ async def test_multi_addressed_seats_both_get_priority( discussion = DiscussionService(store=store) await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - alive_seat_nos=[1, 2, 3, 4], created_at_ms=1, + alive_seat_nos=[1, 2, 3, 4], + created_at_ms=1, ) ) # Raqio (seat 1) addresses BOTH Setsu (2) and Gina (3) in one breath. @@ -2482,16 +2891,23 @@ async def test_multi_addressed_seats_both_get_priority( await store.insert( make_npc_generated_event( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, - speaker_seat=1, text="セツとジナはどう思う?", - addressed_seat_nos=(2, 3), created_at_ms=10, + speaker_seat=1, + text="セツとジナはどう思う?", + addressed_seat_nos=(2, 3), + created_at_ms=10, ) ) registry = InMemoryNpcRegistry() bufs: dict[str, list[str]] = { - "raqio": [], "setsu": [], "gina": [], "jonas": [], + "raqio": [], + "setsu": [], + "gina": [], + "jonas": [], } for npc_id, persona, seat_no in ( ("npc_raqio", "raqio", 1), @@ -2500,16 +2916,22 @@ async def test_multi_addressed_seats_both_get_priority( ("npc_jonas", "jonas", 4), ): registry.register( - npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", - supported_voices=(), version="1", + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", send=_captured_send(bufs[persona]), - now_ms=1000, persona_key=persona, + now_ms=1000, + persona_key=persona, ) registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) arb = SpeakArbiter( - repo=repo, registry=registry, discussion=discussion, - now_ms=lambda: 2000, rng=_random.Random(0), + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + rng=_random.Random(0), ) await arb.try_dispatch_next(g.id) @@ -2552,12 +2974,19 @@ async def test_runoff_dispatch_picks_tied_llm_candidates_in_order( ) await repo.create_game(g) seats = [ - Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, - is_llm=True, persona_key="raqio"), - Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, - is_llm=True, persona_key="setsu"), - Seat(seat_no=3, display_name="🟣ジナ", discord_user_id=None, - is_llm=True, persona_key="gina"), + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), ] for s in seats: await repo.insert_seat(g.id, s) @@ -2568,8 +2997,7 @@ async def test_runoff_dispatch_picks_tied_llm_candidates_in_order( # tally is 1=1, 2=1 — clean two-way tie. for voter, target in ((1, 2), (2, 1), (3, None)): await repo.insert_vote( - Vote(game_id=g.id, day=1, round=0, voter_seat=voter, - target_seat=target, submitted_at=1) + Vote(game_id=g.id, day=1, round=0, voter_seat=voter, target_seat=target, submitted_at=1) ) phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) @@ -2577,9 +3005,12 @@ async def test_runoff_dispatch_picks_tied_llm_candidates_in_order( discussion = DiscussionService(store=store) await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_RUNOFF_SPEECH, - alive_seat_nos=[1, 2, 3], created_at_ms=1, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, ) ) @@ -2591,10 +3022,13 @@ async def test_runoff_dispatch_picks_tied_llm_candidates_in_order( ("npc_gina", "gina", 3), ): registry.register( - npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", - supported_voices=(), version="1", + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", send=_captured_send(bufs[persona]), - now_ms=1000, persona_key=persona, + now_ms=1000, + persona_key=persona, ) registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) @@ -2609,7 +3043,9 @@ def _wake(game_id: str) -> None: wakes.append(game_id) arb = SpeakArbiter( - repo=repo, registry=registry, discussion=discussion, + repo=repo, + registry=registry, + discussion=discussion, now_ms=lambda: 2000, runoff_announce=_intro, runoff_wake=_wake, @@ -2629,16 +3065,23 @@ def _wake(game_id: str) -> None: # NPC accepts → engine wakes, runoff_speech_done flips. req_msg = next(m for m in bufs["raqio"] if '"speak_request"' in m) import json as _json + req_payload = _json.loads(req_msg) request_id = req_payload["request_id"] result = SpeakResult( - ts=2100, trace_id="t", request_id=request_id, - npc_id="npc_raqio", phase_id=phase_id, + ts=2100, + trace_id="t", + request_id=request_id, + npc_id="npc_raqio", + phase_id=phase_id, status="accepted", text="私は皆さんの推理に矛盾を感じています。投票先を見直してほしいです。", ) ok, _ = await arb.handle_speak_result( - result, current_phase_id=phase_id, day=1, phase=Phase.DAY_RUNOFF_SPEECH, + result, + current_phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, ) assert ok progress_seat1 = await repo.load_llm_speech_progress(g.id, 1, 1) @@ -2649,9 +3092,12 @@ def _wake(game_id: str) -> None: # the live wiring kicks try_dispatch_next on playback_finished. await arb.handle_playback_finished( PlaybackFinished( - ts=2200, trace_id="t", request_id=request_id, + ts=2200, + trace_id="t", + request_id=request_id, npc_id="npc_raqio", - started_at_ms=2150, finished_at_ms=2200, + started_at_ms=2150, + finished_at_ms=2200, ) ) @@ -2689,12 +3135,19 @@ async def test_runoff_dispatch_marks_done_on_offline_npc( ) await repo.create_game(g) for seat in ( - Seat(seat_no=1, display_name="🦋ラキオ", discord_user_id=None, - is_llm=True, persona_key="raqio"), - Seat(seat_no=2, display_name="🌙セツ", discord_user_id=None, - is_llm=True, persona_key="setsu"), - Seat(seat_no=3, display_name="🟣ジナ", discord_user_id=None, - is_llm=True, persona_key="gina"), + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), ): await repo.insert_seat(g.id, seat) for sn, role in ((1, Role.WEREWOLF), (2, Role.SEER), (3, Role.MEDIUM)): @@ -2702,8 +3155,7 @@ async def test_runoff_dispatch_marks_done_on_offline_npc( # Tie: seats 1 and 2 (voter 3 abstains). for voter, target in ((1, 2), (2, 1), (3, None)): await repo.insert_vote( - Vote(game_id=g.id, day=1, round=0, voter_seat=voter, - target_seat=target, submitted_at=1) + Vote(game_id=g.id, day=1, round=0, voter_seat=voter, target_seat=target, submitted_at=1) ) phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) @@ -2711,9 +3163,12 @@ async def test_runoff_dispatch_marks_done_on_offline_npc( discussion = DiscussionService(store=store) await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_RUNOFF_SPEECH, - alive_seat_nos=[1, 2, 3], created_at_ms=1, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, ) ) @@ -2721,10 +3176,13 @@ async def test_runoff_dispatch_marks_done_on_offline_npc( registry = InMemoryNpcRegistry() buf2: list[str] = [] registry.register( - npc_id="npc_setsu", discord_bot_user_id="bot_setsu", - supported_voices=(), version="1", + npc_id="npc_setsu", + discord_bot_user_id="bot_setsu", + supported_voices=(), + version="1", send=_captured_send(buf2), - now_ms=1000, persona_key="setsu", + now_ms=1000, + persona_key="setsu", ) registry.assign("npc_setsu", seat=2, game_id=g.id, phase_id=phase_id) @@ -2734,7 +3192,9 @@ async def _intro(seat: Seat) -> None: intros.append(seat.seat_no) arb = SpeakArbiter( - repo=repo, registry=registry, discussion=discussion, + repo=repo, + registry=registry, + discussion=discussion, now_ms=lambda: 2000, runoff_announce=_intro, ) @@ -2780,12 +3240,23 @@ async def test_runoff_watchdog_does_not_pop_pending_after_speak_result_accepted( ) await repo.create_game(g) for seat in ( - Seat(seat_no=1, display_name="🎩ジョナス", discord_user_id=None, - is_llm=True, persona_key="jonas"), - Seat(seat_no=6, display_name="👽シゲミチ", discord_user_id=None, - is_llm=True, persona_key="shigemichi"), - Seat(seat_no=2, display_name="🟣ジナ", discord_user_id=None, - is_llm=True, persona_key="gina"), + Seat( + seat_no=1, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + Seat( + seat_no=6, + display_name="👽シゲミチ", + discord_user_id=None, + is_llm=True, + persona_key="shigemichi", + ), + Seat( + seat_no=2, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), ): await repo.insert_seat(g.id, seat) for sn, role in ((1, Role.WEREWOLF), (6, Role.VILLAGER), (2, Role.KNIGHT)): @@ -2793,8 +3264,7 @@ async def test_runoff_watchdog_does_not_pop_pending_after_speak_result_accepted( # Tie between 1 and 6. for voter, target in ((1, 6), (6, 1), (2, None)): await repo.insert_vote( - Vote(game_id=g.id, day=1, round=0, voter_seat=voter, - target_seat=target, submitted_at=1) + Vote(game_id=g.id, day=1, round=0, voter_seat=voter, target_seat=target, submitted_at=1) ) phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) @@ -2802,9 +3272,12 @@ async def test_runoff_watchdog_does_not_pop_pending_after_speak_result_accepted( discussion = DiscussionService(store=store) await store.insert( make_phase_baseline( - game_id=g.id, phase_id=phase_id, day=1, + game_id=g.id, + phase_id=phase_id, + day=1, phase=Phase.DAY_RUNOFF_SPEECH, - alive_seat_nos=[1, 2, 6], created_at_ms=1, + alive_seat_nos=[1, 2, 6], + created_at_ms=1, ) ) @@ -2815,10 +3288,13 @@ async def test_runoff_watchdog_does_not_pop_pending_after_speak_result_accepted( ("npc_shigemichi", "shigemichi", 6), ): registry.register( - npc_id=npc_id, discord_bot_user_id=f"bot_{persona}", - supported_voices=(), version="1", + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", send=_captured_send(bufs[persona]), - now_ms=1000, persona_key=persona, + now_ms=1000, + persona_key=persona, ) registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) @@ -2831,7 +3307,9 @@ async def _intro(seat: Seat) -> None: # asyncio.sleep(8). 50ms is enough to let _watchdog wake up. cfg = SpeakArbiterConfig(request_ttl_ms=50) arb = SpeakArbiter( - repo=repo, registry=registry, discussion=discussion, + repo=repo, + registry=registry, + discussion=discussion, now_ms=lambda: 2000, runoff_announce=_intro, config=cfg, @@ -2844,17 +3322,25 @@ async def _intro(seat: Seat) -> None: req_msg = next(m for m in bufs["jonas"] if '"speak_request"' in m) import json as _json + request_id = _json.loads(req_msg)["request_id"] # Step 2: NPC accepts SpeakResult (adds to _active_playback, leaves # _pending populated until playback finishes). result = SpeakResult( - ts=2050, trace_id="t", request_id=request_id, - npc_id="npc_jonas", phase_id=phase_id, - status="accepted", text="諸君……ラキオの占い師主張など、笑止千万!", + ts=2050, + trace_id="t", + request_id=request_id, + npc_id="npc_jonas", + phase_id=phase_id, + status="accepted", + text="諸君……ラキオの占い師主張など、笑止千万!", ) ok, _ = await arb.handle_speak_result( - result, current_phase_id=phase_id, day=1, phase=Phase.DAY_RUNOFF_SPEECH, + result, + current_phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, ) assert ok assert request_id in arb._active_playback @@ -2878,9 +3364,12 @@ async def _intro(seat: Seat) -> None: intros.clear() await arb.handle_playback_finished( PlaybackFinished( - ts=2200, trace_id="t", request_id=request_id, + ts=2200, + trace_id="t", + request_id=request_id, npc_id="npc_jonas", - started_at_ms=2050, finished_at_ms=2200, + started_at_ms=2050, + finished_at_ms=2200, ) ) # In production this is wrapped by main.py's _on_playback_finished @@ -2940,3 +3429,549 @@ def test_logic_packet_summary_falls_back_to_seat_when_no_names_supplied() -> Non # No seat_names → legacy 席N rendering preserved for back-compat. assert "席2=seer" in packet.public_state_summary assert "silent_seats=[席3]" in packet.public_state_summary + + +# ─── fabrication-retry path ────────────────────────────────────────── + + +async def _seed_seer_with_divine(repo: SqliteRepo) -> Game: + """Seed a game where seat 2 is real seer with NIGHT_0 divine on seat 1. + + Seat 1 is werewolf so the real seer's color is 黒. We set up a + NIGHT_0 SEER_DIVINE row so the validator's actual_seer_history loader + has data to compare claims against. A phase_baseline event is also + seeded so ``rebuild_public_state`` (used by the fabrication retry + path to spin up a fresh dispatch) returns a non-None state. + """ + from wolfbot.domain.enums import SubmissionType + from wolfbot.domain.models import NightAction + + g = Game( + id="rv_fab", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + Seat( + seat_no=2, + display_name="セツ", + discord_user_id=None, + is_llm=True, + persona_key="setsu", + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + # NIGHT_0: seer divined seat 1 (the wolf). Real history → 黒. + await repo.insert_night_action( + NightAction( + game_id=g.id, + day=0, + actor_seat=2, + kind=SubmissionType.SEER_DIVINE, + target_seat=1, + submitted_at=0, + ) + ) + # Seed phase_baseline so rebuild_public_state returns non-None. + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=make_phase_id(g.id, 1, Phase.DAY_DISCUSSION), + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=(1, 2), + created_at_ms=900, + ) + ) + return g + + +async def test_real_seer_legal_claim_passes(repo: SqliteRepo) -> None: + """Real seer claiming the actual NIGHT_0 target+color is accepted.""" + from wolfbot.domain.ws_messages import ClaimedSeerResult + + g = await _seed_seer_with_divine(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + state = _seed_state(g.id) + req, _ = await arb.dispatch_request( + state=state, + candidate_npc_id="npc_p2", + seat_no=2, + game_id=g.id, + ) + assert req is not None + npc_buf.clear() + result = SpeakResult( + ts=1600, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + phase_id=req.phase_id, + status="accepted", + text="私は占い師。Aliceを占ったら黒。", + co_declaration="seer", + claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=True), + ) + ok, reason = await arb.handle_speak_result( + result, + current_phase_id=req.phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + ) + assert ok and reason is None, f"expected accept, got reason={reason}" + # PlaybackAuthorized should be on the back-channel. + assert any('"playback_authorized"' in m for m in npc_buf) + + +async def test_real_seer_fabricated_target_triggers_retry( + repo: SqliteRepo, +) -> None: + """Real seer claiming an unrecorded target gets PlaybackRejected and + is re-dispatched to the same NPC with retry_feedback embedded.""" + from wolfbot.domain.ws_messages import ClaimedSeerResult, SpeakRequest + + g = await _seed_seer_with_divine(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + state = _seed_state(g.id) + req, _ = await arb.dispatch_request( + state=state, + candidate_npc_id="npc_p2", + seat_no=2, + game_id=g.id, + ) + assert req is not None + npc_buf.clear() + # Fabricated target_seat=99 (not in night_actions). Use seat 1 as + # the speaker is seat 2; we want a "doesn't appear in actual" target. + # _build_claimed_seer at NPC side would reject self-target so we + # use seat 1 black-flipped to white instead — wrong color is also + # FABRICATION_REASONS. + bad = SpeakResult( + ts=1600, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + phase_id=req.phase_id, + status="accepted", + text="ジナを占って白だった", + co_declaration="seer", + claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=False), + ) + ok, reason = await arb.handle_speak_result( + bad, + current_phase_id=req.phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + ) + assert not ok + assert reason == "fabricated_seer_verdict" + # 1) PlaybackRejected was sent. + rejections = [m for m in npc_buf if '"playback_rejected"' in m] + assert len(rejections) == 1 + rej = PlaybackRejected.model_validate_json(rejections[0]) + assert rej.failure_reason == "fabricated_seer_verdict" + # 2) A new SpeakRequest was dispatched to the same NPC. + speak_requests = [m for m in npc_buf if '"speak_request"' in m] + assert len(speak_requests) == 1 + retry = SpeakRequest.model_validate_json(speak_requests[0]) + assert retry.npc_id == "npc_p2" + assert retry.seat_no == 2 + # 3) retry_feedback non-empty and references the correction. + assert retry.retry_feedback is not None + assert "claimed_seer_result" in retry.retry_feedback + + +async def test_fabrication_retries_capped_then_falls_back( + repo: SqliteRepo, +) -> None: + """After 5 consecutive fabrications the same NPC is no longer + re-dispatched — the rejection becomes a normal _reject_and_advance.""" + from wolfbot.domain.ws_messages import ClaimedSeerResult + + g = await _seed_seer_with_divine(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + state = _seed_state(g.id) + bad_claim = ClaimedSeerResult(target_seat=1, is_wolf=False) + last_reason = None + last_phase_id = None + # 5 attempts of fabrication. Each loop dispatches a fresh request, + # then feeds a fabricated SpeakResult back to the arbiter. + for i in range(5): + req, _ = await arb.dispatch_request( + state=state, + candidate_npc_id="npc_p2", + seat_no=2, + game_id=g.id, + ) + assert req is not None, f"dispatch failed at attempt {i}" + last_phase_id = req.phase_id + bad = SpeakResult( + ts=1600 + i, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + phase_id=req.phase_id, + status="accepted", + text=f"attempt {i} bogus claim", + co_declaration="seer", + claimed_seer_result=bad_claim, + ) + ok, reason = await arb.handle_speak_result( + bad, + current_phase_id=req.phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + ) + assert not ok + last_reason = reason + # By now _fabrication_retries[(g.id, phase_id, npc_p2)] == 5; cap is 5 + # so the next fabrication should bail to normal rotation (no retry + # dispatch from `_reject_and_retry_same_npc`). + npc_buf.clear() + # After 5 attempts the counter reached the cap. The arbiter no + # longer re-dispatches the same NPC; the rejection path went through + # `_reject_and_advance` (which calls try_dispatch_next, but with + # only 1 NPC online and that NPC just declined, nothing new dispatches). + assert last_reason == "fabricated_seer_verdict" + counter = arb._fabrication_retries.get( # type: ignore[attr-defined] + (g.id, last_phase_id, "npc_p2") + ) + assert counter == 5, f"expected 5 fabrications recorded, got {counter}" + + +async def test_accept_resets_fabrication_counter(repo: SqliteRepo) -> None: + """A successful accept clears the per-(game, phase, npc) counter so + the cap doesn't carry over from earlier fabrications.""" + from wolfbot.domain.ws_messages import ClaimedSeerResult + + g = await _seed_seer_with_divine(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + state = _seed_state(g.id) + # Attempt 1: fabrication. + req1, _ = await arb.dispatch_request( + state=state, + candidate_npc_id="npc_p2", + seat_no=2, + game_id=g.id, + ) + assert req1 is not None + bad = SpeakResult( + ts=1600, + trace_id="t", + request_id=req1.request_id, + npc_id="npc_p2", + phase_id=req1.phase_id, + status="accepted", + text="間違い", + co_declaration="seer", + claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=False), + ) + await arb.handle_speak_result( + bad, + current_phase_id=req1.phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + ) + key = (g.id, req1.phase_id, "npc_p2") + assert arb._fabrication_retries.get(key) == 1 # type: ignore[attr-defined] + # The retry path issued a new SpeakRequest. Pick it up via npc_buf. + speak_reqs = [m for m in npc_buf if '"speak_request"' in m] + from wolfbot.domain.ws_messages import SpeakRequest as SR + + retry_req = SR.model_validate_json(speak_reqs[-1]) + # Attempt 2: correct claim. + good = SpeakResult( + ts=1700, + trace_id="t", + request_id=retry_req.request_id, + npc_id="npc_p2", + phase_id=retry_req.phase_id, + status="accepted", + text="Aliceは黒だった", + co_declaration="seer", + claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=True), + ) + ok, _ = await arb.handle_speak_result( + good, + current_phase_id=retry_req.phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + ) + assert ok + # Counter cleared. + assert key not in arb._fabrication_retries # type: ignore[attr-defined] + + +async def test_fake_seer_target_swap_triggers_retry(repo: SqliteRepo) -> None: + """Fake seer (wolf) who claimed Bob白 then tries to claim Alice白 in + the same morning gets rejected with retry feedback.""" + from wolfbot.domain.ws_messages import ClaimedSeerResult, SpeakRequest + + g = Game( + id="rv_fake", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=2, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + Seat( + seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, + display_name="ユリコ", + discord_user_id=None, + is_llm=True, + persona_key="yuriko", + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.VILLAGER) + await repo.set_player_role(g.id, 2, Role.SEER) + await repo.set_player_role(g.id, 3, Role.WEREWOLF) + # Seed a prior public claim from seat 3 (wolf) on day 2: target=1 white. + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + phase_id = make_phase_id(g.id, 2, Phase.DAY_DISCUSSION) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=2, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=(1, 2, 3), + created_at_ms=900, + ) + ) + from wolfbot.domain.discussion import ( + SpeakerKind as SK, + ) + from wolfbot.domain.discussion import ( + SpeechEvent as SE, + ) + from wolfbot.domain.discussion import ( + SpeechSource as SS, + ) + + await store.insert( + SE( + event_id="e_prior_claim", + game_id=g.id, + phase_id=phase_id, + day=2, + phase=Phase.DAY_DISCUSSION, + source=SS.NPC_GENERATED, + speaker_kind=SK.NPC, + speaker_seat=3, + text="私が占い師。Alice白", + co_declaration="seer", + claimed_seer_target_seat=1, + claimed_seer_is_wolf=False, + created_at_ms=1000, + ) + ) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p3", + discord_bot_user_id="bot3", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="yuriko", + ) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + state = PublicDiscussionState( + game_id=g.id, + phase_id=phase_id, + day=2, + alive_seat_nos=frozenset({1, 2, 3}), + silent_seats=frozenset({1, 2}), + ) + req, _ = await arb.dispatch_request( + state=state, + candidate_npc_id="npc_p3", + seat_no=3, + game_id=g.id, + ) + assert req is not None + npc_buf.clear() + # Wolf tries to swap day-2 target to seat 2. + swap = SpeakResult( + ts=1600, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p3", + phase_id=req.phase_id, + status="accepted", + text="セツを占ったら白", + co_declaration="seer", + claimed_seer_result=ClaimedSeerResult(target_seat=2, is_wolf=False), + ) + ok, reason = await arb.handle_speak_result( + swap, + current_phase_id=req.phase_id, + day=2, + phase=Phase.DAY_DISCUSSION, + ) + assert not ok + assert reason == "seer_target_swap" + # Retry was dispatched. + speak_reqs = [m for m in npc_buf if '"speak_request"' in m] + assert len(speak_reqs) == 1 + retry = SpeakRequest.model_validate_json(speak_reqs[0]) + assert retry.npc_id == "npc_p3" + assert retry.retry_feedback is not None and "席1" in retry.retry_feedback + + +async def test_null_claim_passes_validator(repo: SqliteRepo) -> None: + """Utterance with claimed_*_result=None bypasses validation entirely + even from the real seer (general talk should never be rejected).""" + g = await _seed_seer_with_divine(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + state = _seed_state(g.id) + req, _ = await arb.dispatch_request( + state=state, + candidate_npc_id="npc_p2", + seat_no=2, + game_id=g.id, + ) + assert req is not None + npc_buf.clear() + result = SpeakResult( + ts=1600, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + phase_id=req.phase_id, + status="accepted", + text="うーん怪しいかも", + ) + ok, reason = await arb.handle_speak_result( + result, + current_phase_id=req.phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + ) + assert ok and reason is None From 6dda6ecda10993794e2c6abb78e22806c3947455 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Fri, 1 May 2026 20:40:14 +0900 Subject: [PATCH 083/133] fix(reactive_voice): validator must read NIGHT_0 from logs_private MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fabrication validator's `_load_actual_seer_history` was reading `night_actions` directly, which doesn't carry the NIGHT_0 random white — that result is emitted as a `SEER_RESULT_NIGHT0` row in `logs_private` (no submission row is written because the system picks the target). Real seers therefore had an empty `actual_seer_history` and every legitimate day-1 morning claim was treated as `fabricated_seer_target`. Reproducer: game `6a0dd72d63e3` day 1. Setsu (real seer) had her NIGHT_0 random white on Jonas. She tried to claim Jonas白 on her first speak attempt and was bounced; she retried 6 times in a row (burning quota and slowing the phase) before the arbiter finally gave up via the cap. The same flow ran her into the `fabrication_retry_dispatch` loop while every other NPC waited. Fix: rewrite both `_load_actual_seer_history` and `_load_actual_medium_history` to call the existing `master.private_state.load_private_state_for_seat`. That parses `SEER_RESULT_NIGHT0` + `SEER_RESULT` (and `MEDIUM_RESULT`) rows from `logs_private` — the same path the NPC's `自分の占い結果` / `自分の霊媒結果` prompt sections read from, so the validator and the prompt now agree on what the truth is. Tests: `_seed_seer_with_divine` now seeds via `insert_log_private` with a `SEER_RESULT_NIGHT0` entry. The existing legal-claim, retry, cap, and reset tests are updated to match the new (consistent) seed shape — seat 1 is now a villager (NIGHT_0 random white never picks a wolf), and the wrong-verdict path uses `is_wolf=True` against a white truth row. --- src/wolfbot/master/speak_arbiter.py | 183 ++++++++++++++-------------- tests/test_reactive_voice_master.py | 86 +++++++------ 2 files changed, 141 insertions(+), 128 deletions(-) diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index 18912b2..ab2333f 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -40,7 +40,7 @@ ) from wolfbot.domain.enums import Phase, Role from wolfbot.domain.models import Seat -from wolfbot.domain.rules import compute_vote_result, is_detected_as_wolf +from wolfbot.domain.rules import compute_vote_result from wolfbot.domain.ws_messages import ( PlaybackAuthorized, PlaybackFailed, @@ -601,107 +601,110 @@ async def _load_actual_seer_history( self, game_id: str, seat_no: int, - current_day: int, ) -> list[ActualSeerEvent]: - """Build the real-seer history for `seat_no`. Empty list when - the speaker isn't the seer or when DB reads fail. The - :func:`is_wolf` field is computed from the target seat's actual - role at lookup time — wolves and only wolves yield ``True``, - matching ``rules.is_detected_as_wolf`` semantics.""" + """Build the real-seer history for `seat_no`. + + Reuses :func:`master.private_state.load_private_state_for_seat` + so the validator reads the SAME private-log path the NPC's + ``自分の占い結果`` prompt section sees: ``SEER_RESULT_NIGHT0`` + plus per-night ``SEER_RESULT`` rows in ``logs_private``. + + Going through ``night_actions`` directly (the v1 implementation) + misses the NIGHT_0 random white because no submission row is + written for it — the system picks the target. Game + ``6a0dd72d63e3`` reproduced the bug: real seer Setsu's NIGHT_0 + Jonas白 wasn't in ``night_actions``, so the validator treated + her legitimate Jonas claim as a fabricated target and bounced + her 6 times in a row. + """ + from wolfbot.master.private_state import load_private_state_for_seat try: players = await self.repo.load_players(game_id) + seats = await self.repo.load_seats(game_id) except Exception: - log.exception("actual_seer_load_players_failed game=%s", game_id) + log.exception("actual_seer_load_meta_failed game=%s", game_id) return [] - role_by_seat = {p.seat_no: p.role for p in players if p.role is not None} - out: list[ActualSeerEvent] = [] - # Seer divinations happen on NIGHT_0 (= day 0 row in - # night_actions) plus one per night N (rows at day=N) through - # the night before today's morning. - from wolfbot.domain.enums import SubmissionType - - for day in range(0, current_day): - try: - actions = await self.repo.load_night_actions(game_id, day) - except Exception: - log.exception( - "actual_seer_load_night_actions_failed game=%s day=%s", - game_id, - day, - ) - continue - for action in actions: - if action.actor_seat != seat_no: - continue - if action.kind != SubmissionType.SEER_DIVINE: - continue - target_role = role_by_seat.get(action.target_seat or -1) - if target_role is None or action.target_seat is None: - continue - # The morning-N "claim day" the LLM sees in `自分の占い結果` - # for a divination submitted on day=D matches the prompt - # convention `day{D}: target → ...`. ClaimedSeerEntry - # also stores `day` as the speech day. So we treat the - # divination's `day` as the canonical claim day. - out.append( - ActualSeerEvent( - day=day, - target_seat=action.target_seat, - is_wolf=is_detected_as_wolf(target_role), - ) + try: + seer_results, _medium, _guard, _wolfchat, _attacks = ( + await load_private_state_for_seat( + self.repo, game_id=game_id, seat_no=seat_no, + role=Role.SEER, players=players, seats=seats, ) - return out + ) + except Exception: + log.exception( + "actual_seer_load_private_state_failed game=%s seat=%s", + game_id, seat_no, + ) + return [] + return [ + ActualSeerEvent( + day=sr.day, + target_seat=sr.target_seat, + is_wolf=sr.is_wolf, + ) + for sr in seer_results + ] async def _load_actual_medium_history( self, game_id: str, seat_no: int, - current_day: int, ) -> tuple[list[ActualMediumEvent], int]: - """Derive the medium history (executed seat + actual role) plus - the count of executions so far. Real medium has no per-night - action submission, so we walk public EXECUTION log entries and - cross-reference with `seats.role`. Returns ``(history, count)``; - ``count`` is the same length as ``history`` and useful for the - fake-medium "no execution yet" rule even when the speaker isn't - the real medium.""" + """Build real-medium history + total executions-so-far count. + + Mirrors :meth:`_load_actual_seer_history` for medium: parse + ``MEDIUM_RESULT`` rows in ``logs_private`` so the validator + and the NPC's ``自分の霊媒結果`` prompt section agree on truth. + For the ``executions_so_far`` count needed by fake-medium + guards, we additionally count public EXECUTION logs because + non-medium speakers don't have private medium logs. Returns + ``(history, count)`` — history empty unless the speaker IS + the real medium, count comes from the public log regardless. + """ + from wolfbot.master.private_state import load_private_state_for_seat try: - logs = await self.repo.load_public_logs(game_id, limit=200) players = await self.repo.load_players(game_id) + seats = await self.repo.load_seats(game_id) except Exception: - log.exception("actual_medium_load_failed game=%s", game_id) + log.exception("actual_medium_load_meta_failed game=%s", game_id) return ([], 0) - role_by_seat = {p.seat_no: p.role for p in players if p.role is not None} - history: list[ActualMediumEvent] = [] - count = 0 - for row in logs: - if row.get("kind") != "EXECUTION": - continue - if row.get("day") is None or row.get("actor_seat") is None: - continue - day_int = int(row["day"]) - executed_seat = int(row["actor_seat"]) - target_role = role_by_seat.get(executed_seat) - if target_role is None: - continue - count += 1 - # Conventionally the medium "result" is announced on the - # morning AFTER the execution. We tag the event with the - # execution day; the validator's same-day match against - # ``ClaimedMediumEntry.day`` (which uses the speech day) - # works because the prompt builder feeds `自分の霊媒結果` - # rows tagged with the EXECUTION's day too. - history.append( - ActualMediumEvent( - day=day_int, - target_seat=executed_seat, - is_wolf=is_detected_as_wolf(target_role), + try: + _seer, medium_results, _guard, _wolfchat, _attacks = ( + await load_private_state_for_seat( + self.repo, game_id=game_id, seat_no=seat_no, + role=Role.MEDIUM, players=players, seats=seats, ) ) - # Filter to only events strictly before today's morning — we - # don't want to include an execution that hasn't happened yet - # (defensive; load_public_logs is past-only by construction). - history = [h for h in history if h.day < current_day] + except Exception: + log.exception( + "actual_medium_load_private_state_failed game=%s seat=%s", + game_id, seat_no, + ) + medium_results = () + # Real-medium results may include "no execution today" entries + # whose is_wolf is None — those don't need validating against + # because they carry no claim-able fact, so drop them here. + history = [ + ActualMediumEvent( + day=mr.day, + target_seat=mr.target_seat, + is_wolf=mr.is_wolf, + ) + for mr in medium_results + if mr.is_wolf is not None + ] + # Public EXECUTION count is needed even when the speaker isn't + # the real medium (fake-medium "no execution yet" rule). + count = 0 + try: + logs = await self.repo.load_public_logs(game_id, limit=200) + except Exception: + log.exception("actual_medium_load_logs_failed game=%s", game_id) + logs = [] + for row in logs: + if row.get("kind") == "EXECUTION": + count += 1 return (history, count) async def _load_speaker_role(self, game_id: str, seat_no: int) -> Role | None: @@ -873,23 +876,17 @@ async def _reject_and_retry_same_npc( executions_so_far = 0 if speaker_role is Role.SEER: actual_seer = await self._load_actual_seer_history( - pending.game_id, - pending.seat_no, - day, + pending.game_id, pending.seat_no, ) if speaker_role is Role.MEDIUM: actual_medium, executions_so_far = await self._load_actual_medium_history( - pending.game_id, - pending.seat_no, - day, + pending.game_id, pending.seat_no, ) else: # Need executions_so_far for the fake-medium "no execution # yet" rule even when the speaker isn't the real medium. _, executions_so_far = await self._load_actual_medium_history( - pending.game_id, - pending.seat_no, - day, + pending.game_id, pending.seat_no, ) seats = await self.repo.load_seats(pending.game_id) seat_names_for_history = {s.seat_no: s.display_name for s in seats} diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index bd28cc3..9aa8f7e 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -3435,16 +3435,24 @@ def test_logic_packet_summary_falls_back_to_seat_when_no_names_supplied() -> Non async def _seed_seer_with_divine(repo: SqliteRepo) -> Game: - """Seed a game where seat 2 is real seer with NIGHT_0 divine on seat 1. - - Seat 1 is werewolf so the real seer's color is 黒. We set up a - NIGHT_0 SEER_DIVINE row so the validator's actual_seer_history loader - has data to compare claims against. A phase_baseline event is also - seeded so ``rebuild_public_state`` (used by the fabrication retry - path to spin up a fresh dispatch) returns a non-None state. + """Seed a game where seat 2 is real seer with NIGHT_0 random white on seat 1. + + Seat 1 is villager so seat 2's NIGHT_0 random white pointing at seat 1 + is internally consistent (the system never picks a wolf for the random + white). We seed the result via a ``SEER_RESULT_NIGHT0`` row in + ``logs_private`` so the validator's ``_load_actual_seer_history`` + (which goes through ``load_private_state_for_seat``) picks it up the + same way the NPC's ``自分の占い結果`` prompt section does. + + The earlier version of this helper inserted into ``night_actions``, + which the NPC's prompt builder ignores for NIGHT_0; the validator + then read truth from a different source than the NPC and bounced + every legitimate Setsu claim. Game ``6a0dd72d63e3`` reproduced the + bug: real seer Setsu hit the 5-retry cap on her FIRST legitimate + Jonas白 claim because the validator couldn't find Jonas in her + actual history. """ - from wolfbot.domain.enums import SubmissionType - from wolfbot.domain.models import NightAction + from wolfbot.domain.models import LogEntry g = Game( id="rv_fab", @@ -3470,19 +3478,25 @@ async def _seed_seer_with_divine(repo: SqliteRepo) -> Game: ] for s in seats: await repo.insert_seat(g.id, s) - await repo.set_player_role(g.id, 1, Role.WEREWOLF) + # Seat 1 must NOT be a wolf — NIGHT_0 random white only ever picks + # non-wolf targets, so this keeps the seed internally consistent + # with the seer's recorded result. + await repo.set_player_role(g.id, 1, Role.VILLAGER) await repo.set_player_role(g.id, 2, Role.SEER) - # NIGHT_0: seer divined seat 1 (the wolf). Real history → 黒. - await repo.insert_night_action( - NightAction( - game_id=g.id, - day=0, - actor_seat=2, - kind=SubmissionType.SEER_DIVINE, - target_seat=1, - submitted_at=0, - ) - ) + # NIGHT_0 random white on seat 1 (Alice). Color = white (= is_wolf False). + # The text format must match `private_state._SEER_NIGHT0_RE` so the + # validator's `load_private_state_for_seat` parses it correctly. + await repo.insert_log_private(LogEntry( + game_id=g.id, + day=0, + phase=Phase.NIGHT_0, + kind="SEER_RESULT_NIGHT0", + actor_seat=2, + visibility="PRIVATE", + audience_seat=2, + text="初日ランダム白: Alice は 人狼ではありません。", + created_at=0, + )) # Seed phase_baseline so rebuild_public_state returns non-None. store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] await store.insert( @@ -3539,9 +3553,11 @@ async def test_real_seer_legal_claim_passes(repo: SqliteRepo) -> None: npc_id="npc_p2", phase_id=req.phase_id, status="accepted", - text="私は占い師。Aliceを占ったら黒。", + text="私は占い師。Aliceを占ったら白。", co_declaration="seer", - claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=True), + # Real seer's NIGHT_0 random white is on seat 1 (Alice, villager). + # Matches the seeded SEER_RESULT_NIGHT0 entry exactly. + claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=False), ) ok, reason = await arb.handle_speak_result( result, @@ -3591,11 +3607,9 @@ async def test_real_seer_fabricated_target_triggers_retry( ) assert req is not None npc_buf.clear() - # Fabricated target_seat=99 (not in night_actions). Use seat 1 as - # the speaker is seat 2; we want a "doesn't appear in actual" target. - # _build_claimed_seer at NPC side would reject self-target so we - # use seat 1 black-flipped to white instead — wrong color is also - # FABRICATION_REASONS. + # The seeded NIGHT_0 result is `target=1, is_wolf=False`. Flipping + # the verdict to is_wolf=True for the same target triggers + # REASON_SEER_WRONG_VERDICT (one of FABRICATION_REASONS). bad = SpeakResult( ts=1600, trace_id="t", @@ -3603,9 +3617,9 @@ async def test_real_seer_fabricated_target_triggers_retry( npc_id="npc_p2", phase_id=req.phase_id, status="accepted", - text="ジナを占って白だった", + text="Aliceを占ったら黒だった", co_declaration="seer", - claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=False), + claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=True), ) ok, reason = await arb.handle_speak_result( bad, @@ -3660,7 +3674,8 @@ async def test_fabrication_retries_capped_then_falls_back( now_ms=lambda: 1500, ) state = _seed_state(g.id) - bad_claim = ClaimedSeerResult(target_seat=1, is_wolf=False) + # Wrong verdict for the seeded target (real history has is_wolf=False). + bad_claim = ClaimedSeerResult(target_seat=1, is_wolf=True) last_reason = None last_phase_id = None # 5 attempts of fabrication. Each loop dispatches a fresh request, @@ -3752,7 +3767,8 @@ async def test_accept_resets_fabrication_counter(repo: SqliteRepo) -> None: status="accepted", text="間違い", co_declaration="seer", - claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=False), + # Wrong verdict (truth is is_wolf=False on seat 1). + claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=True), ) await arb.handle_speak_result( bad, @@ -3767,7 +3783,7 @@ async def test_accept_resets_fabrication_counter(repo: SqliteRepo) -> None: from wolfbot.domain.ws_messages import SpeakRequest as SR retry_req = SR.model_validate_json(speak_reqs[-1]) - # Attempt 2: correct claim. + # Attempt 2: correct claim (matches seeded NIGHT_0 white). good = SpeakResult( ts=1700, trace_id="t", @@ -3775,9 +3791,9 @@ async def test_accept_resets_fabrication_counter(repo: SqliteRepo) -> None: npc_id="npc_p2", phase_id=retry_req.phase_id, status="accepted", - text="Aliceは黒だった", + text="Aliceは白だった", co_declaration="seer", - claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=True), + claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=False), ) ok, _ = await arb.handle_speak_result( good, From 8ff23bf1b9ac4eceb03c8e0b17c87537e5d4565d Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Fri, 1 May 2026 20:51:02 +0900 Subject: [PATCH 084/133] feat(decision_dispatcher): stagger per-actor LLM calls to avoid quota spikes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Game `6a0dd72d63e3` showed 9 NPC bots dispatched in parallel for the day-1 vote, all 9 hitting Gemini 2.5 Flash within <1s. Five of them came back with `reason=llm_error` (429 RESOURCE_EXHAUSTED), and the parallel night-action dispatch (3 actors) hit the same wall — the phase stalled with all `target=None`. There's no game-mechanical reason vote / night decisions need to be parallel. Add `inter_request_stagger_ms` to DecisionDispatcherConfig (default 800ms) and a `_run_staggered` helper that: - launches the first task immediately (single-actor case stays fast), - waits `stagger_s` between subsequent task launches, - still gathers all results before returning, - computes each task's `expires_at_ms` at task-start time so a 6.4s-staggered last task gets the full request_ttl_ms, not a shrinking shared deadline. For 9 NPCs at 800ms apart, total spread is ~7.2s; combined with ~3s LLM latency the dispatch wall-clock lands near 10s while smoothing the per-second request rate enough that the provider's rate limiter no longer throttles the late ones. Tests: - existing 9 cases now pass `inter_request_stagger_ms=0` to keep CI fast (no real wall-clock waits) - `test_dispatch_votes_staggers_outbound_requests` asserts the send-side timestamps spread by at least the configured stagger - `test_dispatch_votes_stagger_zero_is_concurrent` guards the test-friendly mode against accidental regression --- src/wolfbot/master/decision_dispatcher.py | 71 ++++++++-- tests/test_master_decision_dispatcher.py | 157 ++++++++++++++++++++-- 2 files changed, 208 insertions(+), 20 deletions(-) diff --git a/src/wolfbot/master/decision_dispatcher.py b/src/wolfbot/master/decision_dispatcher.py index 48f88b3..f61b9a5 100644 --- a/src/wolfbot/master/decision_dispatcher.py +++ b/src/wolfbot/master/decision_dispatcher.py @@ -25,7 +25,7 @@ import asyncio import logging import uuid -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Awaitable, Callable, Coroutine, Iterable, Sequence from dataclasses import dataclass from wolfbot.domain.discussion import make_phase_id @@ -52,9 +52,20 @@ class DecisionDispatcherConfig: → reply over WS. A typical decision round-trips in well under 10s even at the bad tail; we set 12s so a slow provider still resolves before the deadline. + + `inter_request_stagger_ms` spreads the per-actor LLM dispatches in + time so we don't burst N parallel requests at the LLM provider. + Game ``6a0dd72d63e3`` exhausted Gemini's per-minute quota with a + 9-NPC parallel vote dispatch (9 requests in <1s → 429 RESOURCE_ + EXHAUSTED on 5+ of them, all returning ``target=None`` with + ``reason=llm_error``). 800ms * 9 actors = ~7.2s spread keeps the + wall-clock under 12s while smoothing the request rate enough that + the provider's rate limiter doesn't throttle the late ones. Set + to 0 in tests so they finish in milliseconds. """ request_ttl_ms: int = 12_000 + inter_request_stagger_ms: int = 800 @dataclass @@ -132,9 +143,14 @@ async def dispatch_votes( ) phase = Phase.DAY_RUNOFF if round_ >= 1 else Phase.DAY_VOTE phase_id = make_phase_id(game_id, day, phase) - deadline = self._now_ms() + self.config.request_ttl_ms async def _one(voter: Player) -> tuple[int, int | None]: + # Compute deadline at task start so each staggered task + # gets the full request_ttl_ms, not a shared shrinking + # window. Without this the 9th-staggered task at +6.4s + # would only have ~5s of TTL left from a 12s shared + # deadline. + expires_at_ms = self._now_ms() + self.config.request_ttl_ms target = await self._dispatch_one_vote( voter=voter, seats_by_no=seats_by_no, @@ -142,14 +158,17 @@ async def _one(voter: Player) -> tuple[int, int | None]: game_id=game_id, phase_id=phase_id, round_=round_, - expires_at_ms=deadline, + expires_at_ms=expires_at_ms, public_state_summary=public_state_summary, ) return voter.seat_no, target - results = await asyncio.gather( - *(_one(v) for v in voters), return_exceptions=False - ) + def _factory(voter: Player) -> Callable[ + [], Coroutine[object, object, tuple[int, int | None]] + ]: + return lambda: _one(voter) + + results = await self._run_staggered(_factory(v) for v in voters) return dict(results) async def dispatch_wolf_chat_lines( @@ -223,9 +242,10 @@ async def dispatch_night_actions( phase_id = make_phase_id( game_id, day, Phase.NIGHT_0 if day == 0 else Phase.NIGHT ) - deadline = self._now_ms() + self.config.request_ttl_ms async def _one(actor: Player) -> tuple[int, int | None]: + # Per-task deadline (see dispatch_votes for rationale). + expires_at_ms = self._now_ms() + self.config.request_ttl_ms target = await self._dispatch_one_night( actor=actor, seats_by_no=seats_by_no, @@ -233,14 +253,17 @@ async def _one(actor: Player) -> tuple[int, int | None]: game_id=game_id, phase_id=phase_id, action_kind=action_kind, - expires_at_ms=deadline, + expires_at_ms=expires_at_ms, public_state_summary=public_state_summary, ) return actor.seat_no, target - results = await asyncio.gather( - *(_one(a) for a in actors), return_exceptions=False - ) + def _factory(actor: Player) -> Callable[ + [], Coroutine[object, object, tuple[int, int | None]] + ]: + return lambda: _one(actor) + + results = await self._run_staggered(_factory(a) for a in actors) return dict(results) # ------------------------------------------------- WS handler hooks @@ -459,6 +482,32 @@ async def _dispatch_one_night( label=f"night-{action_kind}", ) + async def _run_staggered( + self, + coro_factories: Iterable[ + Callable[[], Coroutine[object, object, tuple[int, int | None]]] + ], + ) -> list[tuple[int, int | None]]: + """Launch one task per factory, spaced by ``inter_request_stagger_ms``. + + Each factory is a zero-arg callable that returns a coroutine; we + wait for the stagger interval BETWEEN launches, not before + gathering. The first task starts immediately so the wall-clock + for a 1-actor dispatch is unchanged. With ``stagger_ms=0`` the + behaviour is equivalent to the historical + ``asyncio.gather(*coros)`` fan-out, which keeps the unit tests + snappy. + """ + stagger_s = self.config.inter_request_stagger_ms / 1000.0 + tasks: list[asyncio.Task[tuple[int, int | None]]] = [] + for i, factory in enumerate(coro_factories): + if i > 0 and stagger_s > 0: + await asyncio.sleep(stagger_s) + tasks.append(asyncio.create_task(factory())) + if not tasks: + return [] + return list(await asyncio.gather(*tasks, return_exceptions=False)) + async def _send_and_wait( self, *, diff --git a/tests/test_master_decision_dispatcher.py b/tests/test_master_decision_dispatcher.py index fdeff09..d40363b 100644 --- a/tests/test_master_decision_dispatcher.py +++ b/tests/test_master_decision_dispatcher.py @@ -75,7 +75,7 @@ async def test_dispatch_votes_resolves_when_npcs_reply() -> None: dispatcher = NpcDecisionDispatcher( registry=registry, - config=DecisionDispatcherConfig(request_ttl_ms=5_000), + config=DecisionDispatcherConfig(request_ttl_ms=5_000, inter_request_stagger_ms=0), now_ms=lambda: 2_000, ) @@ -125,7 +125,7 @@ async def test_dispatch_votes_offline_seat_resolves_to_none() -> None: dispatcher = NpcDecisionDispatcher( registry=registry, - config=DecisionDispatcherConfig(request_ttl_ms=5_000), + config=DecisionDispatcherConfig(request_ttl_ms=5_000, inter_request_stagger_ms=0), now_ms=lambda: 2_000, ) @@ -168,7 +168,7 @@ async def test_dispatch_votes_timeout_yields_none() -> None: # Very short TTL so the test runs fast. dispatcher = NpcDecisionDispatcher( registry=registry, - config=DecisionDispatcherConfig(request_ttl_ms=100), + config=DecisionDispatcherConfig(request_ttl_ms=100, inter_request_stagger_ms=0), now_ms=lambda: 0, ) voter_only = [Player(seat_no=2, role=Role.VILLAGER, alive=True)] @@ -195,7 +195,7 @@ async def _failing_send(_msg: str) -> None: dispatcher = NpcDecisionDispatcher( registry=registry, - config=DecisionDispatcherConfig(request_ttl_ms=200), + config=DecisionDispatcherConfig(request_ttl_ms=200, inter_request_stagger_ms=0), now_ms=lambda: 0, ) voter_only = [Player(seat_no=2, role=Role.VILLAGER, alive=True)] @@ -219,7 +219,7 @@ async def test_dispatch_night_actions_routes_action_kind() -> None: dispatcher = NpcDecisionDispatcher( registry=registry, - config=DecisionDispatcherConfig(request_ttl_ms=5_000), + config=DecisionDispatcherConfig(request_ttl_ms=5_000, inter_request_stagger_ms=0), now_ms=lambda: 0, ) actor = Player(seat_no=3, role=Role.WEREWOLF, alive=True) @@ -276,7 +276,7 @@ async def test_dispatch_wolf_chat_lines_resolves_sequentially() -> None: dispatcher = NpcDecisionDispatcher( registry=registry, - config=DecisionDispatcherConfig(request_ttl_ms=5_000), + config=DecisionDispatcherConfig(request_ttl_ms=5_000, inter_request_stagger_ms=0), now_ms=lambda: 1_000, ) wolves = [ @@ -336,7 +336,7 @@ async def test_dispatch_wolf_chat_lines_timeout_yields_none() -> None: registry.assign("npc_w2", seat=2, game_id="g1", phase_id="g1::day1::NIGHT::1") dispatcher = NpcDecisionDispatcher( registry=registry, - config=DecisionDispatcherConfig(request_ttl_ms=100), + config=DecisionDispatcherConfig(request_ttl_ms=100, inter_request_stagger_ms=0), now_ms=lambda: 0, ) wolves = [Player(seat_no=2, role=Role.WEREWOLF, alive=True)] @@ -361,7 +361,7 @@ async def test_decide_vote_request_payload_shape() -> None: dispatcher = NpcDecisionDispatcher( registry=registry, - config=DecisionDispatcherConfig(request_ttl_ms=5_000), + config=DecisionDispatcherConfig(request_ttl_ms=5_000, inter_request_stagger_ms=0), now_ms=lambda: 1_000, ) @@ -423,7 +423,7 @@ async def test_cleanup_game_resolves_pending_for_target_game_only() -> None: registry=registry, # Long TTL so the test isn't racing the timeout path; cleanup must # win regardless. - config=DecisionDispatcherConfig(request_ttl_ms=60_000), + config=DecisionDispatcherConfig(request_ttl_ms=60_000, inter_request_stagger_ms=0), now_ms=lambda: 1_000, ) @@ -463,3 +463,142 @@ async def _drive(game_id: str, voter_seat: int) -> dict[int, int | None]: ) ) await g2_task + + +async def test_dispatch_votes_staggers_outbound_requests() -> None: + """``inter_request_stagger_ms`` spaces the per-actor LLM calls in + time so 9 NPC bots don't burst-hit the upstream provider's quota. + + Verifies: with stagger=120ms × 3 voters, the second outbound + payload arrives at least ~100ms after the first, and the third at + least ~200ms after the first. We don't assert exact wall-clock + (event-loop scheduling jitter) — the lower bound is the contract. + """ + import time + + registry = InMemoryNpcRegistry() + bufs: dict[int, list[float]] = {2: [], 3: [], 4: []} + + def _timestamping_send(seat: int) -> Callable[[str], Awaitable[None]]: + async def _send(_msg: str) -> None: + bufs[seat].append(time.monotonic()) + return _send + + for seat, persona, npc_id, bot_id in ( + (2, "setsu", "npc_seat2", "bot2"), + (3, "gina", "npc_seat3", "bot3"), + (4, "comet", "npc_seat4", "bot4"), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=bot_id, + supported_voices=(), version="1", + send=_timestamping_send(seat), now_ms=1000, persona_key=persona, + ) + registry.assign( + npc_id, seat=seat, game_id="g1", + phase_id="g1::day1::DAY_VOTE::1", + ) + + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig( + request_ttl_ms=200, + inter_request_stagger_ms=120, + ), + now_ms=lambda: int(time.time() * 1000), + ) + + seats: list[Seat] = [ + Seat(seat_no=1, display_name="A", is_llm=False, persona_key=None, + discord_user_id="u1"), + Seat(seat_no=2, display_name="B", is_llm=True, persona_key="setsu", + discord_user_id=None), + Seat(seat_no=3, display_name="C", is_llm=True, persona_key="gina", + discord_user_id=None), + Seat(seat_no=4, display_name="D", is_llm=True, persona_key="comet", + discord_user_id=None), + ] + voters = [ + Player(seat_no=2, role=Role.VILLAGER, alive=True), + Player(seat_no=3, role=Role.WEREWOLF, alive=True), + Player(seat_no=4, role=Role.VILLAGER, alive=True), + ] + # Don't send any responses — let the timeout fire so the call returns. + # We're only inspecting the OUTBOUND timestamps. + await dispatcher.dispatch_votes( + game_id="g1", day=1, round_=0, + voters=voters, seats=seats, + candidate_seats=[1, 2, 3, 4], + ) + + # All three NPCs received exactly one request. + assert all(len(b) == 1 for b in bufs.values()), bufs + t2, t3, t4 = bufs[2][0], bufs[3][0], bufs[4][0] + # Lower bounds: stagger=120ms ⇒ at least ~100ms of separation + # (allow some scheduler slack). + assert t3 - t2 >= 0.10, f"seat3 fired only {t3 - t2:.3f}s after seat2" + assert t4 - t3 >= 0.10, f"seat4 fired only {t4 - t3:.3f}s after seat3" + + +async def test_dispatch_votes_stagger_zero_is_concurrent() -> None: + """With ``inter_request_stagger_ms=0`` (test-friendly mode) the + behaviour matches the historical ``asyncio.gather(*coros)`` + fan-out — all outbound requests fire within the same event-loop + tick. Guards against accidental regression where a future change + sneaks in a non-zero default into this test path.""" + import time + + registry = InMemoryNpcRegistry() + seat2_buf: list[float] = [] + seat3_buf: list[float] = [] + + def _timestamp(buf: list[float]) -> Callable[[str], Awaitable[None]]: + async def _send(_msg: str) -> None: + buf.append(time.monotonic()) + return _send + + registry.register( + npc_id="npc_seat2", discord_bot_user_id="bot2", + supported_voices=(), version="1", + send=_timestamp(seat2_buf), now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_seat2", seat=2, game_id="g1", + phase_id="g1::day1::DAY_VOTE::1") + registry.register( + npc_id="npc_seat3", discord_bot_user_id="bot3", + supported_voices=(), version="1", + send=_timestamp(seat3_buf), now_ms=1000, persona_key="gina", + ) + registry.assign("npc_seat3", seat=3, game_id="g1", + phase_id="g1::day1::DAY_VOTE::1") + + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig( + request_ttl_ms=100, inter_request_stagger_ms=0, + ), + now_ms=lambda: int(time.time() * 1000), + ) + + seats: list[Seat] = [ + Seat(seat_no=1, display_name="A", is_llm=False, persona_key=None, + discord_user_id="u1"), + Seat(seat_no=2, display_name="B", is_llm=True, persona_key="setsu", + discord_user_id=None), + Seat(seat_no=3, display_name="C", is_llm=True, persona_key="gina", + discord_user_id=None), + ] + voters = [ + Player(seat_no=2, role=Role.VILLAGER, alive=True), + Player(seat_no=3, role=Role.WEREWOLF, alive=True), + ] + await dispatcher.dispatch_votes( + game_id="g1", day=1, round_=0, + voters=voters, seats=seats, + candidate_seats=[1, 2, 3], + ) + # Both fired within the same scheduler tick (≪ stagger of 120ms + # we used in the staggered test). Use 50ms as a generous upper bound. + assert len(seat2_buf) == 1 and len(seat3_buf) == 1 + spread = abs(seat2_buf[0] - seat3_buf[0]) + assert spread < 0.05, f"stagger=0 should fire near-concurrently, got {spread:.3f}s" From 1a19d4ab9af67e53bb2d640c1197bef327b0f0f3 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Fri, 1 May 2026 20:56:33 +0900 Subject: [PATCH 085/133] feat(llm): support Google AI Studio API-key auth + per-family thinking config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes carried over from the model-migration work: 1. Gemini provider now accepts EITHER `*_LLM_VERTEX_PROJECT` (Vertex AI via ADC) OR `*_LLM_API_KEY` (Google AI Studio via AIza... key). At least one must be set; if both, Vertex wins (production). MasterSettings and NpcSettings validators updated symmetrically. Lets a dev clone the repo, drop a free-tier API key in the env, and run without setting up gcloud ADC. 2. `_gemini_thinking_config` now branches by model family: - `gemini-3-*` / `gemini-flash-latest` → `thinking_level` enum (minimal/low/medium/high) - `gemini-2.5-*` → `thinking_budget` integer (mapped from the same env knob: minimal=0, low=1024, medium=4096, high=-1 dynamic) - `gemini-2.0-*` and older → `thinking_config` field omitted entirely (passing it would 400 INVALID_ARGUMENT) Without this branch, switching the prod model from gemini-3-flash to gemini-2.5-flash blew up at boot because the SDK rejected `thinking_level=` on a 2.5-family model. 3. STT analyzer step error message extended: when GAMEPLAY_LLM_PROVIDER is gemini, suggest VOICE_STT_PROVIDER=gemini path so the operator isn't stuck reading "groq requires xai/deepseek" with no fix pointer. --- src/wolfbot/config.py | 32 ++-- src/wolfbot/npc/config.py | 21 +-- src/wolfbot/npc/gemini_generator.py | 196 +++++++++++++++++++++--- src/wolfbot/npc/generator_factory.py | 8 +- src/wolfbot/services/llm_service.py | 218 +++++++++++++++++++-------- 5 files changed, 360 insertions(+), 115 deletions(-) diff --git a/src/wolfbot/config.py b/src/wolfbot/config.py index 02e86d6..1a46ee1 100644 --- a/src/wolfbot/config.py +++ b/src/wolfbot/config.py @@ -17,7 +17,8 @@ class MasterSettings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env.master", env_file_encoding="utf-8", extra="ignore") + env_file=".env.master", env_file_encoding="utf-8", extra="ignore" + ) DISCORD_TOKEN: SecretStr DISCORD_GUILD_ID: int @@ -134,42 +135,43 @@ class MasterSettings(BaseSettings): @model_validator(mode="after") def _require_gameplay_provider_key(self) -> MasterSettings: - if ( - self.GAMEPLAY_LLM_PROVIDER in ("xai", "deepseek") - and self.GAMEPLAY_LLM_API_KEY is None - ): + if self.GAMEPLAY_LLM_PROVIDER in ("xai", "deepseek") and self.GAMEPLAY_LLM_API_KEY is None: raise ValueError( f"GAMEPLAY_LLM_PROVIDER={self.GAMEPLAY_LLM_PROVIDER} " "requires GAMEPLAY_LLM_API_KEY to be set" ) + # Gemini supports two auth modes: + # * Vertex AI via ADC + GAMEPLAY_LLM_VERTEX_PROJECT + # * Google AI Studio via GAMEPLAY_LLM_API_KEY (AIza... format) + # At least one must be set; if both are set, Vertex wins + # (production deployments rely on attached-SA credentials). if ( self.GAMEPLAY_LLM_PROVIDER == "gemini" and not self.GAMEPLAY_LLM_VERTEX_PROJECT + and self.GAMEPLAY_LLM_API_KEY is None ): raise ValueError( - "GAMEPLAY_LLM_PROVIDER=gemini requires " - "GAMEPLAY_LLM_VERTEX_PROJECT to be set" + "GAMEPLAY_LLM_PROVIDER=gemini requires either " + "GAMEPLAY_LLM_VERTEX_PROJECT (Vertex AI / ADC) or " + "GAMEPLAY_LLM_API_KEY (Google AI Studio)" ) return self @model_validator(mode="after") def _require_voice_stt_provider_key(self) -> MasterSettings: if self.VOICE_STT_PROVIDER == "groq" and self.GROQ_STT_API_KEY is None: - raise ValueError( - "VOICE_STT_PROVIDER=groq requires GROQ_STT_API_KEY to be set" - ) + raise ValueError("VOICE_STT_PROVIDER=groq requires GROQ_STT_API_KEY to be set") # The Groq path's analyzer step reuses GAMEPLAY_LLM_*. The xAI/DeepSeek # case is already covered above; the Gemini case isn't fit for the # OpenAI-compatible analyzer call shape, so block that combo loud and # early rather than failing per-segment at runtime. - if ( - self.VOICE_STT_PROVIDER == "groq" - and self.GAMEPLAY_LLM_PROVIDER == "gemini" - ): + if self.VOICE_STT_PROVIDER == "groq" and self.GAMEPLAY_LLM_PROVIDER == "gemini": raise ValueError( "VOICE_STT_PROVIDER=groq's analyzer step reuses GAMEPLAY_LLM_* " "but requires an OpenAI-compatible provider (xai or deepseek), " - f"not {self.GAMEPLAY_LLM_PROVIDER}" + f"not {self.GAMEPLAY_LLM_PROVIDER}. " + "Set VOICE_STT_PROVIDER=gemini (uses VOICE_LLM_API_KEY) when " + "switching gameplay to Gemini." ) return self diff --git a/src/wolfbot/npc/config.py b/src/wolfbot/npc/config.py index b045141..dc43c6d 100644 --- a/src/wolfbot/npc/config.py +++ b/src/wolfbot/npc/config.py @@ -22,7 +22,8 @@ class NpcSettings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env.npc", env_file_encoding="utf-8", extra="ignore") + env_file=".env.npc", env_file_encoding="utf-8", extra="ignore" + ) # NPC identity / Discord NPC_ID: str @@ -84,21 +85,23 @@ class NpcSettings(BaseSettings): @model_validator(mode="after") def _require_npc_provider_key(self) -> NpcSettings: - if ( - self.NPC_LLM_PROVIDER in ("xai", "deepseek") - and self.NPC_LLM_API_KEY is None - ): + if self.NPC_LLM_PROVIDER in ("xai", "deepseek") and self.NPC_LLM_API_KEY is None: raise ValueError( - f"NPC_LLM_PROVIDER={self.NPC_LLM_PROVIDER} " - "requires NPC_LLM_API_KEY to be set" + f"NPC_LLM_PROVIDER={self.NPC_LLM_PROVIDER} requires NPC_LLM_API_KEY to be set" ) + # Mirror MasterSettings: gemini accepts either Vertex AI + # (via NPC_LLM_VERTEX_PROJECT + ADC) or Google AI Studio + # (via NPC_LLM_API_KEY in AIza... format). At least one must + # be set; if both are present, Vertex wins. if ( self.NPC_LLM_PROVIDER == "gemini" and not self.NPC_LLM_VERTEX_PROJECT + and self.NPC_LLM_API_KEY is None ): raise ValueError( - "NPC_LLM_PROVIDER=gemini requires " - "NPC_LLM_VERTEX_PROJECT to be set" + "NPC_LLM_PROVIDER=gemini requires either " + "NPC_LLM_VERTEX_PROJECT (Vertex AI / ADC) or " + "NPC_LLM_API_KEY (Google AI Studio)" ) return self diff --git a/src/wolfbot/npc/gemini_generator.py b/src/wolfbot/npc/gemini_generator.py index 4e71ee5..4fb2cff 100644 --- a/src/wolfbot/npc/gemini_generator.py +++ b/src/wolfbot/npc/gemini_generator.py @@ -39,13 +39,27 @@ @dataclass class GeminiVertexConfig: - """Vertex AI Gemini config. Authentication is via ADC/IAM.""" + """Gemini config for the NPC speech generator. - project: str + Supports two authentication modes (mutually exclusive at the + constructor boundary, picked by the factory based on which env + var was set): + + * Vertex AI / ADC — set ``project`` (and optionally + ``location``); leave ``api_key=None``. + * Google AI Studio — set ``api_key`` (``AIza...`` form); + leave ``project=None``. + + The class name is unchanged for back-compat with imports / tests + even though the AI Studio mode is no longer strictly "vertex". + """ + + project: str | None = None location: str = "global" model: str = "gemini-3-flash-preview" thinking_level: Literal["minimal", "low", "medium", "high"] = "high" timeout: float = 15.0 + api_key: str | None = None class GeminiNpcGenerator: @@ -65,9 +79,7 @@ def __init__(self, *, config: GeminiVertexConfig) -> None: def set_persona(self, persona_key: str) -> None: if persona_key not in NPC_PERSONAS_BY_KEY: valid = ", ".join(sorted(NPC_PERSONAS_BY_KEY.keys())) - raise ValueError( - f"unknown persona_key {persona_key!r}; valid keys: {valid}" - ) + raise ValueError(f"unknown persona_key {persona_key!r}; valid keys: {valid}") self._persona_key = persona_key async def generate( @@ -105,16 +117,32 @@ async def generate( ) user = _build_user(logic, request, state) - client = genai.Client( - vertexai=True, - project=self.config.project, - location=self.config.location, - http_options=types.HttpOptions(timeout=int(self.config.timeout * 1000)), - ) + # Two SDK construction modes — Vertex (ADC) or AI Studio (api + # key). At least one must be set per ``NpcSettings`` validator; + # both set means the factory passed both, so prefer Vertex. + if self.config.project: + client = genai.Client( + vertexai=True, + project=self.config.project, + location=self.config.location, + http_options=types.HttpOptions( + timeout=int(self.config.timeout * 1000), + ), + ) + elif self.config.api_key: + client = genai.Client( + api_key=self.config.api_key, + http_options=types.HttpOptions( + timeout=int(self.config.timeout * 1000), + ), + ) + else: + raise RuntimeError( + "GeminiNpcGenerator requires either project (Vertex AI / ADC) " + "or api_key (Google AI Studio); both are unset." + ) - actor = ( - f"npc_id={request.npc_id} seat={request.seat_no} persona={self._persona_key}" - ) + actor = f"npc_id={request.npc_id} seat={request.seat_no} persona={self._persona_key}" timer = CallTimer() content = "" err: str | None = None @@ -133,19 +161,25 @@ async def generate( }, ): try: + from wolfbot.services.llm_service import ( + _gemini_thinking_config, + ) + + thinking_cfg = _gemini_thinking_config( + self.config.model, + self.config.thinking_level, + ) + gc_kwargs: dict[str, object] = { + "system_instruction": system, + "response_mime_type": "application/json", + "response_json_schema": _RESPONSE_SCHEMA["schema"], + } + if thinking_cfg is not None: + gc_kwargs["thinking_config"] = thinking_cfg resp = await client.aio.models.generate_content( model=self.config.model, contents=user, - config=types.GenerateContentConfig( - system_instruction=system, - response_mime_type="application/json", - response_json_schema=_RESPONSE_SCHEMA["schema"], - thinking_config=types.ThinkingConfig( - # The SDK normalizes the string into ThinkingLevel - # at runtime; the type annotation is enum-only. - thinking_level=self.config.thinking_level, # type: ignore[arg-type] - ), - ), + config=types.GenerateContentConfig(**gc_kwargs), # type: ignore[arg-type] ) content = resp.text or "{}" tokens = extract_gemini_vertex_tokens(resp) @@ -153,7 +187,8 @@ async def generate( err = f"{type(exc).__name__}: {exc}" log.exception( "npc_generate_gemini_failed project=%s model=%s", - self.config.project, self.config.model, + self.config.project, + self.config.model, ) await log_llm_call( role="npc_speech", @@ -189,5 +224,116 @@ async def generate( return _build_speech_from_json(data) + async def decide_json( + self, + *, + system_prompt: str, + user_prompt: str, + schema: dict[str, object], + ) -> str: + """Phase-D: structured-output decision call (vote / night action). + + Mirrors :meth:`OpenAICompatibleNpcGenerator.decide_json` so the NPC + bot's ``DecisionLLM`` protocol detection picks up Gemini-backed + generators too. Without this method, the npc/main.py wiring code + (line ~226) silently falls back to ``decision_llm=None`` and every + vote / night-action returns "abstain", which is what bricked + game ``75a3b1f379cc`` after the gemini provider switch. + + Uses the same Vertex / AI Studio client construction as the + speech path (project XOR api_key on the config) and the same + thinking-config helper that adapts ``thinking_level`` / + ``thinking_budget`` per model family. + """ + from google import genai + from google.genai import types + + from wolfbot.services.llm_service import _gemini_thinking_config + from wolfbot.services.llm_trace import ( + CallTimer, + extract_gemini_vertex_tokens, + log_llm_call, + ) + + if self.config.project: + client = genai.Client( + vertexai=True, + project=self.config.project, + location=self.config.location, + http_options=types.HttpOptions( + timeout=int(self.config.timeout * 1000), + ), + ) + elif self.config.api_key: + client = genai.Client( + api_key=self.config.api_key, + http_options=types.HttpOptions( + timeout=int(self.config.timeout * 1000), + ), + ) + else: + raise RuntimeError( + "GeminiNpcGenerator.decide_json requires either project " + "(Vertex AI / ADC) or api_key (Google AI Studio)." + ) + + thinking_cfg = _gemini_thinking_config( + self.config.model, + self.config.thinking_level, + ) + gc_kwargs: dict[str, object] = { + "system_instruction": system_prompt, + "response_mime_type": "application/json", + "response_json_schema": schema, + } + if thinking_cfg is not None: + gc_kwargs["thinking_config"] = thinking_cfg + + timer = CallTimer() + content = "" + err: str | None = None + tokens: dict[str, int | None] | None = None + try: + resp = await client.aio.models.generate_content( + model=self.config.model, + contents=user_prompt, + config=types.GenerateContentConfig(**gc_kwargs), # type: ignore[arg-type] + ) + content = resp.text or "{}" + tokens = extract_gemini_vertex_tokens(resp) + except Exception as exc: + err = f"{type(exc).__name__}: {exc}" + log.exception( + "npc_decide_gemini_failed project=%s model=%s", + self.config.project, + self.config.model, + ) + await log_llm_call( + role="npc_decision", + provider="gemini", + model=self.config.model, + system_prompt=system_prompt, + user_prompt=user_prompt, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + file_stem=f"npc_{self._persona_key}", + ) + raise + + await log_llm_call( + role="npc_decision", + provider="gemini", + model=self.config.model, + system_prompt=system_prompt, + user_prompt=user_prompt, + response=content, + latency_ms=timer.elapsed_ms, + error=None, + tokens=tokens, + file_stem=f"npc_{self._persona_key}", + ) + return content + __all__ = ["GeminiNpcGenerator", "GeminiVertexConfig"] diff --git a/src/wolfbot/npc/generator_factory.py b/src/wolfbot/npc/generator_factory.py index aaf9a68..c84d4a3 100644 --- a/src/wolfbot/npc/generator_factory.py +++ b/src/wolfbot/npc/generator_factory.py @@ -74,7 +74,12 @@ def make_npc_generator(cfg: LLMDeciderConfig, *, persona_key: str) -> NpcGenerat GeminiVertexConfig, ) - assert cfg.vertex_project is not None # validated in NpcSettings + # Vertex (project) wins when both are set; AI Studio (api_key) + # is the fallback. NpcSettings.validator guarantees at least + # one is present. + api_key_value: str | None = ( + cfg.api_key.get_secret_value() if cfg.api_key is not None else None + ) gen_gemini = GeminiNpcGenerator( config=GeminiVertexConfig( project=cfg.vertex_project, @@ -82,6 +87,7 @@ def make_npc_generator(cfg: LLMDeciderConfig, *, persona_key: str) -> NpcGenerat model=cfg.model, thinking_level=cfg.thinking_level, timeout=cfg.timeout, + api_key=api_key_value if not cfg.vertex_project else None, ), ) gen_gemini.set_persona(persona_key) diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index e443d7e..e0a55dd 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -188,7 +188,9 @@ class LLMAction(BaseModel): "required": ["target_seat", "is_wolf"], "properties": { "target_seat": { - "type": "integer", "minimum": 1, "maximum": 9, + "type": "integer", + "minimum": 1, + "maximum": 9, }, "is_wolf": {"type": "boolean"}, }, @@ -199,7 +201,9 @@ class LLMAction(BaseModel): "required": ["target_seat", "is_wolf"], "properties": { "target_seat": { - "type": "integer", "minimum": 1, "maximum": 9, + "type": "integer", + "minimum": 1, + "maximum": 9, }, "is_wolf": {"type": ["boolean", "null"]}, }, @@ -393,6 +397,56 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: ) +def _gemini_thinking_config( + model: str, + thinking_level: Literal["minimal", "low", "medium", "high"], +) -> object | None: + """Return the right ``ThinkingConfig`` for ``model``, or ``None``. + + Different Gemini model families take different thinking knobs: + + * **gemini-3-** and ``gemini-flash-latest`` (= 3-family alias) accept + ``thinking_level`` (``minimal`` / ``low`` / ``medium`` / ``high``). + * **gemini-2.5-** accepts ``thinking_budget`` (integer). ``-1`` = + dynamic budget, ``0`` = disabled, positive int = fixed cap. + * **gemini-2.0-** has no thinking surface at all; passing either + knob throws ``INVALID_ARGUMENT`` at the API boundary. + + The decider/generator stores the user-facing string from env + (``minimal``..``high``) and we map it to the family-specific + parameter here so callers don't have to branch model-by-model. + + Returns ``None`` when the model doesn't support thinking, so the + caller can omit ``thinking_config`` from + ``GenerateContentConfig`` entirely (passing ``thinking_config=None`` + would be invalid; the field has to be absent). + """ + from google.genai import types + + if model.startswith("gemini-3") or model.startswith("gemini-flash-latest"): + return types.ThinkingConfig( + # SDK normalizes the string into ThinkingLevel at runtime; + # the type annotation is enum-only, so silence the check. + thinking_level=thinking_level, # type: ignore[arg-type] + ) + if model.startswith("gemini-2.5"): + # Map the qualitative env knob to a numeric budget. Tuned so + # ``high`` keeps full reasoning headroom (-1 = unbounded) while + # ``low`` / ``minimal`` bias toward latency. + budget_map = { + "high": -1, # dynamic / unbounded + "medium": 4096, + "low": 1024, + "minimal": 0, # thinking disabled + } + return types.ThinkingConfig( + thinking_budget=budget_map.get(thinking_level, -1), + ) + # gemini-2.0 / older / unknown families: omit the thinking surface + # entirely so the request stays valid. + return None + + class GeminiLLMActionDecider: """Calls Vertex AI's Gemini API through the official google-genai SDK. @@ -429,19 +483,18 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: err: str | None = None tokens: dict[str, int | None] | None = None try: + thinking_cfg = _gemini_thinking_config(self.model, self.thinking_level) + generate_config_kwargs: dict[str, object] = { + "system_instruction": system_prompt, + "response_mime_type": "application/json", + "response_json_schema": RESPONSE_SCHEMA["schema"], + } + if thinking_cfg is not None: + generate_config_kwargs["thinking_config"] = thinking_cfg resp = await self.client.aio.models.generate_content( # type: ignore[attr-defined] model=self.model, contents=user_context, - config=types.GenerateContentConfig( - system_instruction=system_prompt, - response_mime_type="application/json", - response_json_schema=RESPONSE_SCHEMA["schema"], - thinking_config=types.ThinkingConfig( - # SDK normalizes the string into ThinkingLevel at runtime; - # the type annotation is enum-only, so silence the check. - thinking_level=self.thinking_level, # type: ignore[arg-type] - ), - ), + config=types.GenerateContentConfig(**generate_config_kwargs), # type: ignore[arg-type] ) content = resp.text or "{}" tokens = extract_gemini_vertex_tokens(resp) @@ -673,10 +726,7 @@ async def submit_llm_night_actions( llm_players = [p for p in llm_players if p.seat_no in restrict_to_seats] if not llm_players: return - if ( - game.discussion_mode == "reactive_voice" - and self._npc_decision_dispatcher is not None - ): + if game.discussion_mode == "reactive_voice" and self._npc_decision_dispatcher is not None: task = asyncio.create_task( self._run_night_actions_via_npc_dispatcher( game, llm_players, players, seats, unresolved_seats @@ -768,7 +818,8 @@ async def _run_night_actions_via_npc_dispatcher( except Exception: log.exception( "phase_d_wolf_chat_dispatch_failed game=%s day=%d", - game.id, game.day_number, + game.id, + game.day_number, ) for action_label, items in buckets.items(): @@ -800,7 +851,9 @@ async def _run_night_actions_via_npc_dispatcher( except Exception: log.exception( "npc_night_dispatch_failed game=%s day=%d kind=%s", - game.id, game.day_number, action_label, + game.id, + game.day_number, + action_label, ) continue for actor in actors: @@ -808,18 +861,27 @@ async def _run_night_actions_via_npc_dispatcher( if target is not None and target not in per_actor_legal[actor.seat_no]: log.info( "npc_night_decision_illegal_target game=%s seat=%d kind=%s target=%s", - game.id, actor.seat_no, action_label, target, + game.id, + actor.seat_no, + action_label, + target, ) target = None kind = per_actor_kind[actor.seat_no] try: await self.gs.submit_night_action( - game.id, actor.seat_no, kind, target, game.day_number, + game.id, + actor.seat_no, + kind, + target, + game.day_number, ) except Exception: log.exception( "npc_night_submit_failed game=%s seat=%d kind=%s", - game.id, actor.seat_no, kind.value, + game.id, + actor.seat_no, + kind.value, ) _ = seats_by_no # lint: keep parameter live for log readers @@ -1025,10 +1087,7 @@ async def submit_llm_votes( llm_voters = [p for p in llm_voters if p.seat_no in restrict_to_seats] if not llm_voters: return - if ( - game.discussion_mode == "reactive_voice" - and self._npc_decision_dispatcher is not None - ): + if game.discussion_mode == "reactive_voice" and self._npc_decision_dispatcher is not None: task = asyncio.create_task( self._run_votes_via_npc_dispatcher( game, llm_voters, players, seats, candidates, round_ @@ -1071,22 +1130,10 @@ async def _build_public_digest(self, game: Game, seats: Sequence[Seat]) -> str: ) day = game.day_number - discussion_phase_id = _make_phase_id( - game.id, day, Phase.DAY_DISCUSSION - ) - runoff_phase_id = _make_phase_id( - game.id, day, Phase.DAY_RUNOFF_SPEECH - ) - events = list( - await self.discussion_service.load_phase( - game.id, discussion_phase_id - ) - ) - runoff_events = list( - await self.discussion_service.load_phase( - game.id, runoff_phase_id - ) - ) + discussion_phase_id = _make_phase_id(game.id, day, Phase.DAY_DISCUSSION) + runoff_phase_id = _make_phase_id(game.id, day, Phase.DAY_RUNOFF_SPEECH) + events = list(await self.discussion_service.load_phase(game.id, discussion_phase_id)) + runoff_events = list(await self.discussion_service.load_phase(game.id, runoff_phase_id)) # Append runoff events after discussion so they fold on top # of the day's accumulated state. Both phases carry their # own phase_baseline sentinel so the fold reads each @@ -1110,7 +1157,8 @@ async def _build_public_digest(self, game: Game, seats: Sequence[Seat]) -> str: except Exception: log.exception( "public_digest_build_failed game=%s day=%d", - game.id, game.day_number, + game.id, + game.day_number, ) return "" @@ -1134,7 +1182,9 @@ async def _load_past_votes( for day in range(1, current_day): for round_ in (0, 1): rows = await self.repo.load_votes( - game_id, day=day, round_=round_, + game_id, + day=day, + round_=round_, ) if not rows: continue @@ -1176,9 +1226,7 @@ async def _run_votes_via_npc_dispatcher( # Build candidate set per voter — same shape as the decider path. # We fan out only voters who have at least one legal target; # voters with no legal target (e.g. last-survivor edge) abstain. - existing_votes = await self.repo.load_votes( - game.id, day=game.day_number, round_=round_ - ) + existing_votes = await self.repo.load_votes(game.id, day=game.day_number, round_=round_) voted = {v.voter_seat for v in existing_votes} per_voter_candidates: dict[int, list[int]] = {} voters_to_dispatch: list[Player] = [] @@ -1190,9 +1238,7 @@ async def _run_votes_via_npc_dispatcher( s.seat_no for s in seats if s.seat_no != v.seat_no - and any( - p.seat_no == s.seat_no and p.alive for p in all_players - ) + and any(p.seat_no == s.seat_no and p.alive for p in all_players) ] else: cands = [ @@ -1225,7 +1271,9 @@ async def _run_votes_via_npc_dispatcher( except Exception: log.exception( "npc_vote_dispatch_failed game=%s day=%d round=%d", - game.id, game.day_number, round_, + game.id, + game.day_number, + round_, ) return # Persist each result. ``None`` (offline / timeout / explicit @@ -1234,12 +1282,12 @@ async def _run_votes_via_npc_dispatcher( for voter in voters_to_dispatch: target = results.get(voter.seat_no) # Validate target is in the voter's legal set, otherwise abstain. - if target is not None and target not in per_voter_candidates.get( - voter.seat_no, [] - ): + if target is not None and target not in per_voter_candidates.get(voter.seat_no, []): log.info( "npc_vote_decision_illegal_target game=%s seat=%d target=%s", - game.id, voter.seat_no, target, + game.id, + voter.seat_no, + target, ) target = None try: @@ -1253,7 +1301,9 @@ async def _run_votes_via_npc_dispatcher( except Exception: log.exception( "npc_vote_submit_failed game=%s seat=%d round=%d", - game.id, voter.seat_no, round_, + game.id, + voter.seat_no, + round_, ) # Touch seats_by_no to keep the parameter live for log readers # auditing the dispatch (lint guard). @@ -1820,9 +1870,7 @@ async def _ask( private_logs=private_logs, deduced_facts_block=deduced_block, ) - actor = ( - f"seat={player.seat_no} persona={seat.persona_key} role={player.role.value}" - ) + actor = f"seat={player.seat_no} persona={seat.persona_key} role={player.role.value}" task_tag = _classify_task_text(task_text) with trace_context( game_id=game.id, @@ -1834,9 +1882,7 @@ async def _ask( try: return await self.decider.decide(system, user) except Exception: - log.exception( - "LLM decide failed for seat %s game %s", player.seat_no, game.id - ) + log.exception("LLM decide failed for seat %s game %s", player.seat_no, game.id) return LLMAction(intent="skip", reason_summary="decider error") async def _build_deduced_facts_block( @@ -2022,6 +2068,38 @@ def make_gemini_decider( ) +def make_gemini_aistudio_decider( + *, + api_key: str, + model: str = "gemini-3-flash-preview", + thinking_level: Literal["minimal", "low", "medium", "high"] = "high", + timeout: float = 30.0, +) -> GeminiLLMActionDecider: + """Build a Google AI Studio Gemini-backed decider. + + Mirror of :func:`make_gemini_decider` but authenticates via the + AI Studio API key path instead of Vertex AI / ADC. The same + ``GeminiLLMActionDecider`` runtime works because the only diff + between the two SDK modes is how the underlying client is + constructed; the structured-output schema and thinking config are + identical surface across both. Use this when running outside GCP + (no project / no service account) but with an ``AIza...`` key. + """ + from google import genai + from google.genai import types + + client = genai.Client( + api_key=api_key, + http_options=types.HttpOptions(timeout=int(timeout * 1000)), + ) + return GeminiLLMActionDecider( + client=client, + model=model, + thinking_level=thinking_level, + timeout=timeout, + ) + + def make_llm_decider(cfg: LLMDeciderConfig) -> LLMActionDecider: """Provider-aware decider factory. Branches on ``cfg.provider``. @@ -2053,10 +2131,20 @@ def make_llm_decider(cfg: LLMDeciderConfig) -> LLMActionDecider: timeout=cfg.timeout, ) if cfg.provider == "gemini": - assert cfg.vertex_project is not None # validated in Settings - return make_gemini_decider( - project=cfg.vertex_project, - location=cfg.vertex_location, + # Vertex AI wins when both are set (production deployments + # rely on attached-SA credentials, AI Studio key is a local- + # dev convenience). + if cfg.vertex_project: + return make_gemini_decider( + project=cfg.vertex_project, + location=cfg.vertex_location, + model=cfg.model, + thinking_level=cfg.thinking_level, + timeout=cfg.timeout, + ) + assert cfg.api_key is not None # validated in Settings + return make_gemini_aistudio_decider( + api_key=cfg.api_key.get_secret_value(), model=cfg.model, thinking_level=cfg.thinking_level, timeout=cfg.timeout, From d1cc59fa48066c90d9f0accb893e060afa877e19 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Fri, 1 May 2026 21:58:06 +0900 Subject: [PATCH 086/133] =?UTF-8?q?chore(decision=5Fdispatcher):=20bump=20?= =?UTF-8?q?default=20inter=5Frequest=5Fstagger=5Fms=20800=E2=86=921500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Game `824ebf79edf4` (post-800ms rollout) still hit `429 RESOURCE_EXHAUSTED` on Comet's NIGHT_1 wolf_attack call, parking the game in WAITING_HOST_DECISION because no fallback target was available before the deadline. The per-project Vertex AI quota is shared across the speech + wolf-chat + vote + night-action phases that run back-to-back, so 800ms (= 7.2s spread for 9 actors) wasn't enough to stay under the rate limiter when speech-side calls had already burned through some of the budget moments earlier. 1500ms gives ~13.5s of spread for 9 actors. Combined with the per-task `request_ttl_ms` (12s computed at task start, not from function entry), the worst-case wall-clock is ~25s — well under any discussion or night-action deadline. Tests pin `inter_request_stagger_ms=0` so the suite still runs in milliseconds; the production default change doesn't touch the test path. --- src/wolfbot/master/decision_dispatcher.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/wolfbot/master/decision_dispatcher.py b/src/wolfbot/master/decision_dispatcher.py index f61b9a5..ff696b9 100644 --- a/src/wolfbot/master/decision_dispatcher.py +++ b/src/wolfbot/master/decision_dispatcher.py @@ -57,15 +57,19 @@ class DecisionDispatcherConfig: time so we don't burst N parallel requests at the LLM provider. Game ``6a0dd72d63e3`` exhausted Gemini's per-minute quota with a 9-NPC parallel vote dispatch (9 requests in <1s → 429 RESOURCE_ - EXHAUSTED on 5+ of them, all returning ``target=None`` with - ``reason=llm_error``). 800ms * 9 actors = ~7.2s spread keeps the - wall-clock under 12s while smoothing the request rate enough that - the provider's rate limiter doesn't throttle the late ones. Set - to 0 in tests so they finish in milliseconds. + EXHAUSTED on 5+ of them). Game ``824ebf79edf4`` (post-800ms + rollout) still hit a 429 on Comet's NIGHT_1 attack call because + the per-project Vertex quota is shared across speech + decision + + wolf-chat phases that run back-to-back. Bumping to 1500ms gives + the rate limiter ~13.5s of spread for 9 actors; combined with the + per-task ``request_ttl_ms`` (12s computed at task start) the worst- + case wall-clock is ~25s for a fully-staggered run, well under any + discussion or night-action deadline. Tests pin this to 0 so they + finish in milliseconds. """ request_ttl_ms: int = 12_000 - inter_request_stagger_ms: int = 800 + inter_request_stagger_ms: int = 1500 @dataclass From 9ad6a73355dbd799b2fed9e8a6b3dc259ad3cb15 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Fri, 1 May 2026 22:23:36 +0900 Subject: [PATCH 087/133] fix(reactive_voice): block fabrication-capped seat from re-selection + feat(prompt): CO saturation cap + multi-CO attack avoidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes from observed game `6366cb014a0a`: 1. Fabrication cap was soft. After ユリコ (madman) hit `day1_seer_claim_overflow` for the 5th time on day 1, the `_reject_and_advance` path called `try_dispatch_next` which immediately re-selected ユリコ (because she was in the seer callout pool). The retry counter then incremented past the cap (attempt=6/5, 7/5, 8/5, 9/5...) and the phase stalled for ~75s with no other NPC speaking — that's the "誰も喋らなくなった" the user observed. Fix: add `_fabrication_capped: dict[(game_id, phase_id), set[seat_no]]`; cap-hit now records the seat AND bails to normal rotation. The dispatcher's candidate picker filters capped seats out for the rest of the phase. Cleared on game cleanup. 2. Prompt rules for CO saturation: - game_rules: explicit max-CO cap (seer 3 / medium 2 / knight 2) beyond which observers treat the offender as wolf-line. - WEREWOLF: "must not CO seer if ledger already has 3" + "the wolf-side hostess SQ that came out as 4th seer in the game above is the bug pattern this rule blocks". Plus: "do not attack a target whose role has multiple COs out — village resolves the contest via roller / black-stop, so killing one of them just feeds the resolver"; prefer single-CO real info-roles or non-CO confirmed-white positions. - MADMAN: same no-4th-CO rule. - VILLAGER: emphasize that fake-CO-as-4th-seer is the worst possible villager move (immediately gets lynched as wolf-line). Tests: new `test_fabrication_cap_blocks_seat_from_re_selection` verifies the cap-hit seat is skipped by `try_dispatch_next` and a different LLM seat is dispatched instead. --- src/wolfbot/llm/prompt_builder.py | 33 ++++++++- src/wolfbot/master/speak_arbiter.py | 39 +++++++++- tests/test_reactive_voice_master.py | 111 ++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 3 deletions(-) diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index 79fdbd6..11f1304 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -108,6 +108,13 @@ def _build_game_rules_block() -> str: "村陣営にとっては破綻発言で、その発言者の信用も落ちる。" "霊媒結果より優先される HARD ファクトとして扱う。\n" "- 騎士は同じ相手を連続で護衛できない (前夜と同じ対象は選べない)。\n" + "- **同一役職の CO 数には構造的な上限がある。**" + "占い師は 1 (真) + 2 (人狼が騙れる) + 1 (狂人が騙れる) で **理論最大 4** だが、" + "**実戦では公開ログ上の占い CO が通算 3 件に達した時点で、4 件目を出すのは戦略的に破綻**する " + "(村陣営の村人 1 名が騙りに出る合理的理由がなく、4 件目 CO は強い狼/狂人疑いを浴びるため)。" + "霊媒師は 1 (真) + 1 (人狼か狂人の騙り) で **理論最大 2**、3 件目以降は出した時点で偽確定。" + "騎士は 1 (真) + 1 (人狼/狂人の騙り) で **理論最大 2**、3 件目以降は偽確定。" + "聞き手側はこの上限を超えた CO を観測した時点で、その CO 者を強い狼陣営疑いとして扱う。\n" "- 投票先や夜行動対象は、プロンプトで提示された合法な候補トークン " "(例: `席3 Alice`) の中からだけ選ぶ。候補外の名前を返してはならない。\n" "- 特定役職 (占い師・霊媒師・騎士) の CO が 1 人だけで、同じ役職への対抗 CO が" @@ -479,10 +486,24 @@ def _build_game_rules_block() -> str: "- 役職 CO と対抗 CO が合計 6 人以上に膨らむと、" "役職 CO していない位置の白が確定する。" "騙りすぎには注意し、相方との役職分担を事前に意識する。\n" + "- **占い師 CO が公開ログ上 既に 3 件以上出ている場合、4 件目を出してはならない。**" + "理論最大 4 (真 1 + 狼 2 + 狂人 1) だが、4 件目に出ると村陣営の村人騙りとしか説明がつかず、" + "村側に強い狼/狂人ラインとして即座に切られる。" + "**霊媒師 CO が 2 件以上出ている場合・騎士 CO が 2 件以上出ている場合も、追加 CO を出してはならない** " + "(これらは 3 件目で構造的偽確定)。既に上限に達している役職への追加 CO は破綻行動。" + "潜伏 (= 役職 CO に出ない) を選ぶか、相方が既に出ていない別役職を考える。\n" "- 夜の襲撃先は、候補ごとに「襲撃価値」「護衛されやすさ」「騎士候補度」" "「相方との整合」を比較して選ぶ。" "単独真寄りの情報役・確白寄り・直近で白をもらった重要位置・進行役・" "強く信頼された発言者は、襲撃価値も護衛されやすさも高い両刃の存在として扱う。\n" + "- **対抗 CO が出ている役職 (= 同じ役職の CO が公開ログ上 2 件以上) の CO 者は、原則として襲撃価値が低い。**" + "村側は対抗 CO 群を残して占いローラー / 黒ストップで真偽を絞り込む進行をするため、" + "たとえ真を 1 人噛んでも残った対抗 CO 群と霊媒結果で整理されてしまう。" + "むしろ対抗 CO 群を **そのまま吊り役に使わせる** ほうが狼陣営の縄を消費させられて得。" + "襲撃価値が高いのは、(a) 対抗のいない単独 CO の真寄り情報役 (例: 単独霊媒・単独騎士)、" + "(b) CO していない確白寄りの進行役・盤面整理役、(c) 信頼を集めている灰位置。" + "複数 CO 役職を噛むのは、相方が CO 群に居る場合の囲い解除や、" + "黒ストップを阻止する具体目的が明確なときだけにする。\n" "- 騎士候補度は公開ログから推定する。" "騎士 CO、護衛先を匂わせる発言、情報役を守りたがる姿勢、" "平和な朝の反応、処刑回避の仕方、発言量の抑え方を材料にする。" @@ -608,6 +629,12 @@ def _build_game_rules_block() -> str: "破綻しない範囲に留める。\n" "- 知り得ない確定情報 (夜行動の内訳・他プレイヤーの属性など) を事実として断言しない。\n" "- 真占い・真霊媒に疑いを向け、村陣営の情報整理を妨げる方向に投票や発言を運ぶ。\n" + "- **占い師 CO が公開ログ上 既に 3 件以上出ている場合、4 件目を出してはならない。**" + "理論最大 4 (真 1 + 狼 2 + 狂人 1) だが、4 件目に出ると村陣営の村人騙りとしか説明がつかず、" + "村側に強い狼陣営ライン (= 狂人位置) として即座に切られる。" + "**霊媒師 CO が 2 件以上、騎士 CO が 2 件以上の場合も、追加 CO を出してはならない** " + "(3 件目で構造的偽確定)。既に上限に達している役職への追加 CO は、人狼陣営にとって縄を浪費するだけの破綻行動。" + "上限に達した役職には潜伏で対応し、別の方法 (真占い・真霊媒の信用落とし、灰の混乱) で支援する。\n" "- day 1 の戦術選択は **先制CO/対抗CO/潜伏** の 3 択。3 つはどれも等しく強い第一級の候補で、" "盤面・自席発言順・公開ログの先行発言・既出 CO 数・縄構造から毎回 3 択全てを俎上に載せて比較する。" "特定の 1 択に機械的に流れない。" @@ -915,7 +942,11 @@ def _build_game_rules_block() -> str: "- 公開発言の矛盾、視点漏れ、投票理由、占い/霊媒結果との整合性を重視して推理する。\n" "- 不確実なときは候補を絞り、理由を添えて話す。曖昧な決めつけや" "『なんとなく怪しい』だけの発言は避ける。\n" - "- 自分に私的情報があるふりをしない。占い/霊媒/騎士の CO 騙りは村陣営としては行わない。\n" + "- 自分に私的情報があるふりをしない。占い/霊媒/騎士の CO 騙りは村陣営としては行わない。" + "**特に占い CO が既に 3 件出ている盤面で 4 件目として出るのは絶対に避ける** " + "(占い CO 4 件は理論最大 = 真 1 + 狼 2 + 狂人 1 の構造的に飽和した形で、" + "村人が 4 件目に出ると即座に強い狼陣営疑いを浴びて吊り対象になる)。" + "霊媒・騎士も既に 2 件出ている場合は追加 CO に出ない。\n" "- 「村人CO」「素村CO」「普通の村人です」「役職は村人です」のように、" "自分から村人役職を CO して信用を取ろうとしない。" "村人は能力結果を持たないため CO しても証明にはならず、熟練者は村人 CO を信用材料に使わない。\n" diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index ab2333f..6eb86e5 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -252,6 +252,13 @@ def __init__( # in handle_speak_result. Cleared on phase change (alongside # _callout_pool_asked) and on a successful accept for the NPC. self._fabrication_retries: dict[tuple[str, str, str], int] = {} + # Per-(game_id, phase_id) set of seats that hit the fabrication + # retry cap (`_MAX_FABRICATION_RETRIES`). The dispatcher's + # candidate picker filters these seats out for the rest of the + # phase so a chronically-fabricating NPC can't monopolise the + # phase via the rotation re-selecting them. Cleared on phase + # change (alongside `_callout_pool_asked`). + self._fabrication_capped: dict[tuple[str, str], set[int]] = {} # ------------------------------------------------------------- gates @@ -923,8 +930,24 @@ async def _reject_and_retry_same_npc( _MAX_FABRICATION_RETRIES, ) if attempt >= _MAX_FABRICATION_RETRIES: - # Give up: bail to normal rotation so the phase - # doesn't stall on one stuck NPC. + # Give up: block this seat from being re-picked for + # the rest of the phase, then bail to normal rotation. + # Without the block, `try_dispatch_next` re-selects + # the same NPC immediately (especially when they're + # in the seer/medium callout pool), the retry counter + # increments past the cap (observed: attempt=6/5, + # 7/5, 8/5...), and the phase stalls because no other + # NPC ever gets dispatched. Game ``6366cb014a0a`` + # day 1 hit this with ユリコ (madman) burning ~75s + # of phase time on `day1_seer_claim_overflow` retries + # before the deadline closed the discussion. + self._fabrication_capped.setdefault( + (pending.game_id, result.phase_id), set() + ).add(pending.seat_no) + log.info( + "fabrication_capped_seat_blocked game=%s phase=%s seat=%s", + pending.game_id, result.phase_id, pending.seat_no, + ) return await _reject_and_advance(validation.reason) return await _reject_and_retry_same_npc( reason=validation.reason, @@ -1262,11 +1285,19 @@ def _pick_key(e: object) -> tuple[int, int, int, int, int, float]: "speech_counts": sorted(candidate_counts.items()), } + capped_for_phase: frozenset[int] | set[int] = self._fabrication_capped.get( + (game_id, state.phase_id), frozenset() + ) for entry in sorted(online, key=_pick_key): if entry.assigned_seat is None or entry.game_id != game_id: continue if entry.assigned_seat not in state.alive_seat_nos: continue + if entry.assigned_seat in capped_for_phase: + # Hit the fabrication cap this phase — skip so a chronically + # fabricating NPC can't monopolise rotation. Cleared when the + # phase advances (see cleanup_game / phase-change reset). + continue seat = entry.assigned_seat picked_count = state.speech_counts.get(seat, 0) if seat in effective_pool: @@ -1665,6 +1696,10 @@ def cleanup_game(self, game_id: str) -> int: for key in list(self._fabrication_retries.keys()): if key[0] == game_id: self._fabrication_retries.pop(key, None) + # Drop fabrication cap-hit blocklist for this game. + for cap_key in list(self._fabrication_capped.keys()): + if cap_key[0] == game_id: + self._fabrication_capped.pop(cap_key, None) if swept: log.info( "speak_arbiter_cleanup_game game=%s swept=%d", diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index 9aa8f7e..d0e8f9f 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -3991,3 +3991,114 @@ async def test_null_claim_passes_validator(repo: SqliteRepo) -> None: phase=Phase.DAY_DISCUSSION, ) assert ok and reason is None + + +async def test_fabrication_cap_blocks_seat_from_re_selection( + repo: SqliteRepo, +) -> None: + """After a seat hits ``_MAX_FABRICATION_RETRIES``, the picker must + NOT re-select that seat for the rest of the phase. Without this + block, normal rotation re-picks the same NPC immediately (especially + when they're in the seer-callout pool), the retry counter increments + past the cap (observed in game ``6366cb014a0a``: attempt=6/5, 7/5, + 8/5, 9/5...), and the phase stalls because no other NPC ever gets + dispatched. + + Setup: 2 LLM seats online, both online for the same phase. NPC A + fabricates 5 times in a row → cap fires → A is blocked. The + arbiter's ``try_dispatch_next`` should now pick NPC B. + """ + from wolfbot.domain.ws_messages import ClaimedSeerResult + + g = await _seed_seer_with_divine(repo) + # Add a 3rd LLM seat and overwrite the phase_baseline so the alive + # set in `rebuild_public_state` includes seat 3 as a dispatchable + # candidate. The fold uses the FIRST baseline it sees, so we must + # delete the original (alive=(1,2)) before inserting the new one. + third = Seat( + seat_no=3, display_name="Charlie", + discord_user_id=None, is_llm=True, persona_key="raqio", + ) + await repo.insert_seat(g.id, third) + await repo.set_player_role(g.id, 3, Role.VILLAGER) + await repo._conn.execute( # type: ignore[attr-defined] + "DELETE FROM speech_events WHERE game_id=? AND source='phase_baseline'", + (g.id,), + ) + await repo._conn.commit() # type: ignore[attr-defined] + store_extra = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + await store_extra.insert(make_phase_baseline( + game_id=g.id, + phase_id=make_phase_id(g.id, 1, Phase.DAY_DISCUSSION), + day=1, phase=Phase.DAY_DISCUSSION, + alive_seat_nos=(1, 2, 3), created_at_ms=950, + )) + + registry = InMemoryNpcRegistry() + a_buf: list[str] = [] + b_buf: list[str] = [] + registry.register( + npc_id="npc_p2", discord_bot_user_id="bot2", supported_voices=(), + version="1", send=_captured_send(a_buf), now_ms=1000, + persona_key="setsu", + ) + registry.register( + npc_id="npc_p3", discord_bot_user_id="bot3", supported_voices=(), + version="1", send=_captured_send(b_buf), now_ms=1000, + persona_key="raqio", + ) + registry.assign("npc_p2", seat=2, game_id=g.id, + phase_id=make_phase_id(g.id, 1, Phase.DAY_DISCUSSION)) + registry.assign("npc_p3", seat=3, game_id=g.id, + phase_id=make_phase_id(g.id, 1, Phase.DAY_DISCUSSION)) + + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 1500, + ) + bad_claim = ClaimedSeerResult(target_seat=1, is_wolf=True) + + last_phase_id = None + # 5 fabrication attempts on seat 2 → cap. + for i in range(5): + req, _ = await arb.dispatch_request( + state=_seed_state(g.id), candidate_npc_id="npc_p2", + seat_no=2, game_id=g.id, + ) + assert req is not None + last_phase_id = req.phase_id + bad = SpeakResult( + ts=1600 + i, trace_id="t", request_id=req.request_id, + npc_id="npc_p2", phase_id=req.phase_id, status="accepted", + text=f"attempt {i}", co_declaration="seer", + claimed_seer_result=bad_claim, + ) + await arb.handle_speak_result( + bad, current_phase_id=req.phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + ) + + # Seat 2 must now be in the cap-hit set. + assert (g.id, last_phase_id) in arb._fabrication_capped # type: ignore[attr-defined] + capped = arb._fabrication_capped[(g.id, last_phase_id)] # type: ignore[attr-defined] + assert 2 in capped + assert 3 not in capped + + # When the picker runs `try_dispatch_next`, it must skip seat 2 + # and dispatch to seat 3 instead. We exercise this by calling + # `try_dispatch_next` directly and checking that npc_p3's send buffer + # received a SpeakRequest while npc_p2's didn't get a fresh one. + a_buf.clear() + b_buf.clear() + await arb.try_dispatch_next(g.id) + # Seat 2 (capped) should NOT have been dispatched. + assert not any('"speak_request"' in m for m in a_buf), ( + "capped seat 2 should be skipped, but got: " + str(a_buf) + ) + # Seat 3 (not capped) should have been dispatched. + assert any('"speak_request"' in m for m in b_buf), ( + "seat 3 should have been picked, got buf: " + str(b_buf) + ) From 68dc7fb941b7440b1cf27ad9d0a47fafe51762b3 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Fri, 1 May 2026 22:30:33 +0900 Subject: [PATCH 088/133] =?UTF-8?q?fix(reactive=5Fvoice):=20bump=20heartbe?= =?UTF-8?q?at=5Ftimeout=5Fms=205s=E2=86=9215s=20(3x=20interval)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Game `6366cb014a0a` had Gina (medium) silent for the entire 3-day game — `npc_speak_requests` count showed 0 dispatches to npc_gina across 59 total SpeakRequests, while she received and responded to DecideVoteRequest just fine. Root cause: SpeakArbiter's heartbeat freshness gate (`heartbeat_timeout_ms=5000`) matched the NPC env's `HEARTBEAT_INTERVAL_S=5` exactly. Any heartbeat-delivery jitter (Discord gateway, asyncio backpressure, GC) pushed the gap >5000ms, the dispatch gate read "offline", and the candidate was skipped. Vote dispatch path doesn't apply this gate, which is why Gina could vote but not speak. 15s = 3x the 5s heartbeat interval is the textbook ratio: tolerates 2 missed/jittered beats before declaring offline. Fixes Gina's silent-medium bug without sacrificing real-offline detection. --- src/wolfbot/master/speak_arbiter.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index 6eb86e5..bb5554a 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -168,7 +168,17 @@ class SpeakArbiterConfig: # for tail-latency tolerance. request_ttl_ms: int = 30_000 playback_deadline_ms: int = 12_000 - heartbeat_timeout_ms: int = 5000 + # Heartbeat freshness gate: an NPC bot is considered offline if its + # last heartbeat is older than this. Must be ≥3x ``HEARTBEAT_INTERVAL_S`` + # in the NPC env so a single missed/jittered beat doesn't kick the + # bot offline. Game ``6366cb014a0a`` had ``heartbeat_timeout_ms=5000`` + # against ``HEARTBEAT_INTERVAL_S=5`` (1:1 ratio) — Gina's NPC bot + # was chronically just-late on heartbeats and got skipped on EVERY + # SpeakRequest dispatch (0/59 across the whole game) while still + # receiving DecideVoteRequest (vote dispatch doesn't apply this + # gate). 15s = 3x interval, the textbook ratio for missed-beat + # tolerance. + heartbeat_timeout_ms: int = 15_000 vad_finalization_timeout_ms: int = 4000 From 97807297175399aa8fec0e49cf0f18fd986ba9c8 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Fri, 1 May 2026 23:06:24 +0900 Subject: [PATCH 089/133] feat(reactive_voice): hard-block 4th seer CO / 3rd medium CO via validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Game `59d5377c6794` had a 4th seer CO appear (seat 6 ジョナス, day 1) explicitly noting "占い師が三人も揃うとはね、私こそが導き手" — the prompt-side max-3-seer rule didn't stop the model from CO'ing as the 4th. Add a hard validator gate so the 4th CO is rejected at the wire boundary regardless of model precision. `claim_validator` now exposes: - `SEER_CO_CAP = 3` / `MEDIUM_CO_CAP = 2` - `REASON_SEER_CO_CAP_EXCEEDED` / `REASON_MEDIUM_CO_CAP_EXCEEDED` - `CO_CAP_REASONS` — distinct frozenset, NOT included in `FABRICATION_REASONS`. The cap is a structural rule, not a self-correction problem; retrying the same NPC with feedback won't help (the model would have to abandon CO entirely). `validate_claim_against_truth` gains `seer_co_count` and `medium_co_count` parameters. If the speaker has no prior claim of their own AND the count is at cap, the new CO is rejected with the new reason code. `speak_arbiter.handle_speak_result`: - counts distinct seer / medium claimers from the public ClaimHistory - passes both to the validator - on CO_CAP_REASONS: drop the audio (PlaybackRejected) and rotate to the next picker pick via `_reject_and_advance`. No fabrication retry counter increment, no `_fabrication_capped` block — the speaker stays eligible for future dispatches; if they CO again, they get rejected again, but no retry cascade. Tests: - `test_4th_seer_co_rejected` / `test_3rd_medium_co_rejected`: validator-level - `test_3rd_seer_co_passes_when_under_cap` / boundary case - `test_repeat_claim_from_same_speaker_at_cap_passes`: the cap only blocks NEW claimers - `test_co_cap_exceeded_one_shot_skip_no_retry`: integration — PlaybackRejected sent, fabrication counter stays 0, seat NOT added to the cap-block set --- src/wolfbot/master/claim_validator.py | 75 ++++++++++++++++++ src/wolfbot/master/speak_arbiter.py | 30 ++++++++ tests/test_claim_validator.py | 105 ++++++++++++++++++++++++++ tests/test_reactive_voice_master.py | 97 ++++++++++++++++++++++++ 4 files changed, 307 insertions(+) diff --git a/src/wolfbot/master/claim_validator.py b/src/wolfbot/master/claim_validator.py index 166ee01..667edeb 100644 --- a/src/wolfbot/master/claim_validator.py +++ b/src/wolfbot/master/claim_validator.py @@ -42,12 +42,24 @@ REASON_SEER_DAY1_OVERFLOW = "day1_seer_claim_overflow" REASON_SEER_TARGET_SWAP = "seer_target_swap" REASON_SEER_VERDICT_FLIP = "seer_verdict_flip" +REASON_SEER_CO_CAP_EXCEEDED = "seer_co_cap_exceeded" REASON_MEDIUM_FABRICATED_TARGET = "fabricated_medium_target" REASON_MEDIUM_WRONG_VERDICT = "fabricated_medium_verdict" REASON_MEDIUM_DAY1 = "day1_medium_claim" REASON_MEDIUM_NO_EXECUTION = "medium_claim_without_execution" REASON_MEDIUM_TARGET_SWAP = "medium_target_swap" REASON_MEDIUM_VERDICT_FLIP = "medium_verdict_flip" +REASON_MEDIUM_CO_CAP_EXCEEDED = "medium_co_cap_exceeded" + +# Maximum number of distinct claimers per CO role. Beyond this, a fresh +# CO from a not-yet-CO'd seat is structurally fabricated: +# * seer — 真 1 + 狼 2 + 狂人 1 = 4 theoretical max, but a 4th CO can +# only come from a non-roleholder villager and is immediately +# cut by the village ledger. +# * medium — 真 1 + 狼/狂人 1 騙り = 2 theoretical max; 3rd is broken. +# * knight — same shape as medium. +SEER_CO_CAP = 3 +MEDIUM_CO_CAP = 2 FABRICATION_REASONS = frozenset( { @@ -65,6 +77,17 @@ } ) +# CO-cap rejection reasons are NOT in FABRICATION_REASONS by design. +# Fabrication rejections trigger the same-NPC retry loop with feedback; +# the cap is a structural rule (= "you literally cannot CO this role +# anymore, no amount of self-correction will fix it"), so the right +# response is a one-time soft skip — drop this dispatch's audio, let +# the picker rotate to the next NPC, and don't burn retries on a +# speaker that the model can't help fixing. +CO_CAP_REASONS = frozenset( + {REASON_SEER_CO_CAP_EXCEEDED, REASON_MEDIUM_CO_CAP_EXCEEDED} +) + @dataclass(frozen=True) class ActualSeerEvent: @@ -348,6 +371,8 @@ def validate_claim_against_truth( actual_medium_history: Sequence[ActualMediumEvent] = (), prior_public_claims: ClaimerHistory | None = None, executions_so_far: int = 0, + seer_co_count: int = 0, + medium_co_count: int = 0, ) -> ValidationResult: """Single entry point: validate this utterance's structured claims. @@ -359,11 +384,43 @@ def validate_claim_against_truth( Both ``claimed_seer`` and ``claimed_medium`` may be ``None`` (the common case when the utterance doesn't announce a new result); that's always accepted. + + ``seer_co_count`` / ``medium_co_count`` are the count of *distinct + seats* that have already issued a CO of that role in the public + ledger. When this seat is brand-new to the role (no prior claim + of their own) AND the count is already at the structural cap + (3 for seer, 2 for medium), the new CO is rejected as + fabrication. Game ``59d5377c6794`` reproduced this: a 4th seer + CO appeared (seat 6 ジョナス) on day 1 explicitly noting "占い師 + が三人も揃うとはね、私こそが導き手" and the prompt-side + rule didn't stop it — only a hard validator catches it. """ prior_seer = prior_public_claims.seer_claims if prior_public_claims else () prior_medium = prior_public_claims.medium_claims if prior_public_claims else () if claimed_seer is not None: + # CO cap check: applies BEFORE real/fake-specific rules. If the + # ledger already has SEER_CO_CAP distinct claimers and this + # speaker hasn't claimed yet, the CO is structurally fabricated + # regardless of role (real seer would still fail this — but + # `seer_co_count >= cap` while the real seer hasn't CO'd is + # itself impossible because the real seer is one of the distinct + # claimers; so this branch only fires for non-real CO inflation). + if not prior_seer and seer_co_count >= SEER_CO_CAP: + return ValidationResult.reject( + reason=REASON_SEER_CO_CAP_EXCEEDED, + feedback=( + f"前回の発話で占い師 CO を出したが、" + f"既に公開ログ上 {seer_co_count} 件の占い師 CO が出ている。" + f"占い師 CO は構造的に最大 {SEER_CO_CAP} 件 (真 1 + 狼 2 + 狂人 1) で、" + f"{SEER_CO_CAP + 1} 件目は村陣営の村人騙りとしてしか説明がつかず、" + "村側に強い狼/狂人ラインとして即座に切られる破綻行動である。" + "次の発話では占い師 CO に出ないこと " + "(`co_declaration=null`、`claimed_seer_result=null` を設定)。" + "潜伏のまま発話するか、別役職 (霊媒/騎士) の対抗 CO 余地を考える " + "(ただし霊媒/騎士も既に上限に達していないか確認すること)。" + ), + ) if speaker_role is Role.SEER: res = _validate_seer_real( claim=claimed_seer, @@ -382,6 +439,19 @@ def validate_claim_against_truth( return res if claimed_medium is not None: + # Mirror the seer cap for medium (max 2 distinct claimers). + if not prior_medium and medium_co_count >= MEDIUM_CO_CAP: + return ValidationResult.reject( + reason=REASON_MEDIUM_CO_CAP_EXCEEDED, + feedback=( + f"前回の発話で霊媒師 CO を出したが、" + f"既に公開ログ上 {medium_co_count} 件の霊媒師 CO が出ている。" + f"霊媒師 CO は構造的に最大 {MEDIUM_CO_CAP} 件 (真 1 + 狼/狂人 1) で、" + f"{MEDIUM_CO_CAP + 1} 件目は本物の霊媒師ではあり得ない破綻行動である。" + "次の発話では霊媒師 CO に出ないこと " + "(`co_declaration=null`、`claimed_medium_result=null` を設定)。" + ), + ) if speaker_role is Role.MEDIUM: res = _validate_medium_real( claim=claimed_medium, @@ -404,18 +474,23 @@ def validate_claim_against_truth( __all__ = [ + "CO_CAP_REASONS", "FABRICATION_REASONS", + "MEDIUM_CO_CAP", + "REASON_MEDIUM_CO_CAP_EXCEEDED", "REASON_MEDIUM_DAY1", "REASON_MEDIUM_FABRICATED_TARGET", "REASON_MEDIUM_NO_EXECUTION", "REASON_MEDIUM_TARGET_SWAP", "REASON_MEDIUM_VERDICT_FLIP", "REASON_MEDIUM_WRONG_VERDICT", + "REASON_SEER_CO_CAP_EXCEEDED", "REASON_SEER_DAY1_OVERFLOW", "REASON_SEER_FABRICATED_TARGET", "REASON_SEER_TARGET_SWAP", "REASON_SEER_VERDICT_FLIP", "REASON_SEER_WRONG_VERDICT", + "SEER_CO_CAP", "ActualMediumEvent", "ActualSeerEvent", "ValidationResult", diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index bb5554a..ab3f189 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -54,6 +54,7 @@ from wolfbot.llm.prompt_builder import build_strategy_block from wolfbot.master.claim_history import ClaimHistory, collect_claim_history from wolfbot.master.claim_validator import ( + CO_CAP_REASONS, FABRICATION_REASONS, ActualMediumEvent, ActualSeerEvent, @@ -914,6 +915,18 @@ async def _reject_and_retry_same_npc( prior_for_speaker = ( claim_history.by_seat.get(pending.seat_no) if claim_history is not None else None ) + # Count distinct claimers per role for the global CO cap + # (max 3 seer / max 2 medium). A seat with non-empty + # seer_claims has CO'd at least once → counts as one + # distinct claimer. + seer_co_count = 0 + medium_co_count = 0 + if claim_history is not None: + for ch in claim_history.by_seat.values(): + if ch.seer_claims: + seer_co_count += 1 + if ch.medium_claims: + medium_co_count += 1 validation = validate_claim_against_truth( speaker_role=speaker_role or Role.VILLAGER, speaker_seat=pending.seat_no, @@ -925,7 +938,24 @@ async def _reject_and_retry_same_npc( actual_medium_history=actual_medium, prior_public_claims=prior_for_speaker, executions_so_far=executions_so_far, + seer_co_count=seer_co_count, + medium_co_count=medium_co_count, ) + if not validation.ok and validation.reason in CO_CAP_REASONS: + # CO cap exceeded: drop the audio (PlaybackRejected) and + # rotate to the next picker pick. Don't burn fabrication + # retries — the cap is a structural rule, not a self- + # correction problem. The same NPC remains eligible for + # subsequent dispatches; if they CO again next time, + # they get rejected again, but no retry cascade happens. + log.info( + "co_cap_exceeded_skip game=%s npc=%s seat=%s reason=%s", + pending.game_id, + result.npc_id, + pending.seat_no, + validation.reason, + ) + return await _reject_and_advance(validation.reason) if not validation.ok and validation.reason in FABRICATION_REASONS: key = (pending.game_id, result.phase_id, result.npc_id) self._fabrication_retries[key] = self._fabrication_retries.get(key, 0) + 1 diff --git a/tests/test_claim_validator.py b/tests/test_claim_validator.py index 0f68ef7..3f7a334 100644 --- a/tests/test_claim_validator.py +++ b/tests/test_claim_validator.py @@ -419,3 +419,108 @@ def test_madman_first_seer_claim_passes() -> None: prior_public_claims=ClaimerHistory(claimer_seat=8), ) assert res.ok + + +# ─── CO saturation cap ─────────────────────────────────────────────── + + +def test_4th_seer_co_rejected() -> None: + """Reproduces game `59d5377c6794`: a 4th seat tries to seer-CO when + 3 distinct seats already have seer claims in the public ledger.""" + from wolfbot.master.claim_validator import REASON_SEER_CO_CAP_EXCEEDED + + res = validate_claim_against_truth( + speaker_role=Role.VILLAGER, + speaker_seat=6, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=1, is_wolf=False), + claimed_medium=None, + # Speaker has no prior CO of their own. + prior_public_claims=ClaimerHistory(claimer_seat=6), + # 3 OTHER seats already CO'd seer. + seer_co_count=3, + ) + assert not res.ok + assert res.reason == REASON_SEER_CO_CAP_EXCEEDED + assert "3 件" in (res.feedback or "") + + +def test_3rd_seer_co_passes_when_under_cap() -> None: + """At seer_co_count=2, a 3rd CO is still legal (within the cap).""" + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=4, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=1, is_wolf=False), + claimed_medium=None, + prior_public_claims=ClaimerHistory(claimer_seat=4), + seer_co_count=2, + ) + assert res.ok + + +def test_repeat_claim_from_same_speaker_at_cap_passes() -> None: + """The cap only blocks NEW CO claimers. A speaker who's already + CO'd seer can keep adding new daily results past day 1 even when + the ledger has 3 distinct claimers (they're one of those 3).""" + from wolfbot.master.claim_history import ClaimedSeerEntry + + prior = ClaimerHistory( + claimer_seat=4, + seer_claims=( + ClaimedSeerEntry( + day=1, target_seat=1, target_name="席1", + is_wolf=False, declared_at_event_id="e1", + ), + ), + ) + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=4, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=2, is_wolf=False), + claimed_medium=None, + prior_public_claims=prior, + seer_co_count=3, + ) + assert res.ok + + +def test_3rd_medium_co_rejected() -> None: + """Medium cap is 2. A 3rd medium CO from a new claimer is rejected.""" + from wolfbot.domain.ws_messages import ClaimedMediumResult + from wolfbot.master.claim_validator import REASON_MEDIUM_CO_CAP_EXCEEDED + + res = validate_claim_against_truth( + speaker_role=Role.VILLAGER, + speaker_seat=5, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=3, is_wolf=False), + prior_public_claims=ClaimerHistory(claimer_seat=5), + medium_co_count=2, + executions_so_far=1, + ) + assert not res.ok + assert res.reason == REASON_MEDIUM_CO_CAP_EXCEEDED + + +def test_2nd_medium_co_passes_when_under_cap() -> None: + from wolfbot.domain.ws_messages import ClaimedMediumResult + + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=5, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=3, is_wolf=False), + prior_public_claims=ClaimerHistory(claimer_seat=5), + medium_co_count=1, + executions_so_far=1, + ) + assert res.ok diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index d0e8f9f..b190fe0 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -4102,3 +4102,100 @@ async def test_fabrication_cap_blocks_seat_from_re_selection( assert any('"speak_request"' in m for m in b_buf), ( "seat 3 should have been picked, got buf: " + str(b_buf) ) + + +async def test_co_cap_exceeded_one_shot_skip_no_retry(repo: SqliteRepo) -> None: + """A 4th seat trying to seer-CO when the ledger already has 3 + distinct seers gets PlaybackRejected on this dispatch — but the + fabrication retry counter stays 0 and the seat is NOT blocked + from future picks. The cap is a structural rule, not a self- + correction problem; retrying the same NPC with feedback won't + help (the model would have to abandon CO entirely, which is + cleaner to enforce by simply not re-dispatching this attempt).""" + from wolfbot.domain.discussion import ( + SpeakerKind as SK, + ) + from wolfbot.domain.discussion import ( + SpeechEvent as SE, + ) + from wolfbot.domain.discussion import ( + SpeechSource as SS, + ) + from wolfbot.domain.ws_messages import ClaimedSeerResult + + g = Game( + id="rv_cocap", guild_id="gu", host_user_id="h", + phase=Phase.DAY_DISCUSSION, day_number=1, + main_text_channel_id="c1", main_vc_channel_id="c2", + created_at=0, discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="A", discord_user_id=None, is_llm=True, persona_key="setsu"), + Seat(seat_no=2, display_name="B", discord_user_id=None, is_llm=True, persona_key="gina"), + Seat(seat_no=3, display_name="C", discord_user_id=None, is_llm=True, persona_key="raqio"), + Seat(seat_no=4, display_name="D", discord_user_id=None, is_llm=True, persona_key="comet"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in [(1, Role.SEER), (2, Role.WEREWOLF), (3, Role.MADMAN), (4, Role.VILLAGER)]: + await repo.set_player_role(g.id, sn, role) + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + await store.insert(make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, phase=Phase.DAY_DISCUSSION, + alive_seat_nos=(1, 2, 3, 4), created_at_ms=900, + )) + # Seed 3 prior seer COs in speech_events from seats 1, 2, 3. + for i, sn in enumerate([1, 2, 3]): + await store.insert(SE( + event_id=f"e_co_{sn}", game_id=g.id, phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, + source=SS.NPC_GENERATED, speaker_kind=SK.NPC, + speaker_seat=sn, text=f"占い師CO from seat {sn}", + co_declaration="seer", + claimed_seer_target_seat=sn % 4 + 1, + claimed_seer_is_wolf=False, + created_at_ms=1000 + i, + )) + registry = InMemoryNpcRegistry() + npc4_buf: list[str] = [] + registry.register( + npc_id="npc_p4", discord_bot_user_id="bot4", supported_voices=(), + version="1", send=_captured_send(npc4_buf), now_ms=1000, + persona_key="comet", + ) + registry.assign("npc_p4", seat=4, game_id=g.id, phase_id=phase_id) + discussion = DiscussionService(store=store) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500) + + state = PublicDiscussionState( + game_id=g.id, phase_id=phase_id, day=1, + alive_seat_nos=frozenset({1, 2, 3, 4}), + silent_seats=frozenset({4}), + ) + req, _ = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p4", seat_no=4, game_id=g.id, + ) + assert req is not None + npc4_buf.clear() + + # Seat 4 (villager) tries to CO seer despite 3 already out. + bad = SpeakResult( + ts=1600, trace_id="t", request_id=req.request_id, + npc_id="npc_p4", phase_id=req.phase_id, status="accepted", + text="僕も占い師だよ", co_declaration="seer", + claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=False), + ) + ok, reason = await arb.handle_speak_result( + bad, current_phase_id=req.phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + ) + assert not ok + assert reason == "seer_co_cap_exceeded" + # PlaybackRejected was sent. + assert any('"playback_rejected"' in m for m in npc4_buf) + # Fabrication retry counter stayed 0 (cap is NOT a fabrication). + assert (g.id, req.phase_id, "npc_p4") not in arb._fabrication_retries # type: ignore[attr-defined] + # Seat is NOT in the cap-block set (= remains eligible for future picks). + assert 4 not in arb._fabrication_capped.get((g.id, req.phase_id), set()) # type: ignore[attr-defined] From ccaa8dc65ebbf86ee4220febed79c1839adc4da4 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Fri, 1 May 2026 23:22:30 +0900 Subject: [PATCH 090/133] fix(npc): apply abstain fallback for LLM transport errors too Game `06c38cd43494` NIGHT_1 stalled because Vertex AI returned `504 DEADLINE_EXCEEDED` on Raqio (real seer)'s divine call. The NPC client's `_decide_night_target` short-circuited with `return None, "llm_error"` on any exception, completely bypassing the random-legal abstain fallback that was already in place for parse failures and illegal targets. Master's `pending_decisions` kept `missing_seats=[seer]` past the deadline, and the game parked in WAITING_HOST_DECISION. Restructure both `_decide_night_target` and `_decide_vote_target` so LLM transport exceptions and parse failures share the same fallback gate: track `result_target_seat` + `reason_summary` in locals, leave them at None/`"llm_error"` on exception, then run the existing random-legal fallback when `result_target_seat is None and legal`. Net effect: a transient 429 / 504 from the provider drops to a deterministic random pick instead of silently abstaining the seat. Tests: `test_vote_falls_back_when_llm_raises_exception` and `test_night_target_falls_back_when_llm_raises_exception` reproduce the bug shape and assert the seat lands on a legal candidate with `reason_summary` carrying `abstain_fallback`. --- src/wolfbot/npc/client.py | 51 ++++++++++++++++------- tests/test_npc_game_state.py | 79 ++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 15 deletions(-) diff --git a/src/wolfbot/npc/client.py b/src/wolfbot/npc/client.py index 50eed9b..280f376 100644 --- a/src/wolfbot/npc/client.py +++ b/src/wolfbot/npc/client.py @@ -556,22 +556,31 @@ async def _decide_vote_target( return None, "unknown_persona" legal = frozenset(seat for seat, _name in req.candidate_seats) system, user = build_vote_prompt(state=state, persona=persona, request=req) + # Mirror `_decide_night_target`: track the LLM call's success + # uniformly so the abstain fallback below covers transport errors + # (504 DEADLINE_EXCEEDED, 429 RESOURCE_EXHAUSTED, ...) the same + # way it covers parse failures. Without this, a Gemini 504 on + # the vote turn drops the ballot to abstain, weakening the + # village's ability to lynch a wolf when one round-trip flakes. + result_target_seat: int | None = None + reason_summary = "llm_error" try: raw = await self.decision_llm.decide_json( system_prompt=system, user_prompt=user, schema=_VOTE_SCHEMA, ) + result = parse_decision(raw, legal_seats=legal) + result_target_seat = result.target_seat + reason_summary = result.reason_summary except Exception: log.exception( "npc_vote_llm_failed game=%s seat=%d", req.game_id, req.seat_no, ) - return None, "llm_error" - result = parse_decision(raw, legal_seats=legal) # Forbid abstention in voting. The schema disallows null and the # prompt explicitly says "棄権禁止", but if the model still drops - # back (parse error, out-of-set target, persona inertia) we pick - # a deterministic-but-uniform fallback so the seat doesn't end - # up in the silent-abstain bucket. - if result.target_seat is None and legal: + # back (parse error, out-of-set target, persona inertia, or LLM + # transport error) we pick a deterministic-but-uniform fallback + # so the seat doesn't end up in the silent-abstain bucket. + if result_target_seat is None and legal: rng = random.Random( f"{req.game_id}:{req.seat_no}:{req.round_}".__hash__() ) @@ -579,10 +588,10 @@ async def _decide_vote_target( log.info( "npc_vote_abstain_fallback game=%s seat=%d -> %d reason=%s", req.game_id, req.seat_no, fallback, - result.reason_summary or "(none)", + reason_summary or "(none)", ) - return fallback, f"abstain_fallback:{result.reason_summary or ''}" - return result.target_seat, result.reason_summary + return fallback, f"abstain_fallback:{reason_summary or ''}" + return result_target_seat, reason_summary async def _on_decide_night_action_request( self, req: DecideNightActionRequest @@ -686,17 +695,29 @@ async def _decide_night_target( return None, "unknown_persona" legal = frozenset(seat for seat, _name in req.candidate_seats) system, user = build_night_prompt(state=state, persona=persona, request=req) + # Track parse + transport errors uniformly so the abstain + # fallback below covers BOTH cases. Game ``06c38cd43494`` + # NIGHT_1 stalled because Vertex AI returned 504 + # DEADLINE_EXCEEDED on the seer's divine call — the previous + # version of this method short-circuited with `return None, + # "llm_error"` on any exception, bypassing the random-legal + # fallback. The deadline closed with `pending_decisions + # missing_seats=[1]` and the game parked in + # WAITING_HOST_DECISION. + result_target_seat: int | None = None + reason_summary = "llm_error" try: raw = await self.decision_llm.decide_json( system_prompt=system, user_prompt=user, schema=_NIGHT_SCHEMA, ) + result = parse_decision(raw, legal_seats=legal) + result_target_seat = result.target_seat + reason_summary = result.reason_summary except Exception: log.exception( "npc_night_llm_failed game=%s seat=%d kind=%s", req.game_id, req.seat_no, req.action_kind, ) - return None, "llm_error" - result = parse_decision(raw, legal_seats=legal) # Forbid skipping for night actions. Master rejects target=None # with ILLEGAL_TARGET; the missing seat then deadlocks the # NIGHT phase via pending_decisions until the host force-skips. @@ -704,7 +725,7 @@ async def _decide_night_target( # null saying "GJ リスク回避し次夜余地残す". Force a legal pick # so the phase always advances; persona keeps a chance to do # 捨て護衛 / 価値の薄い位置 via the LLM choice itself. - if result.target_seat is None and legal: + if result_target_seat is None and legal: rng = random.Random( f"{req.game_id}:{req.seat_no}:{req.action_kind}".__hash__() ) @@ -713,10 +734,10 @@ async def _decide_night_target( "npc_night_abstain_fallback game=%s seat=%d kind=%s -> %d " "reason=%s", req.game_id, req.seat_no, req.action_kind, fallback, - result.reason_summary or "(none)", + reason_summary or "(none)", ) - return fallback, f"abstain_fallback:{result.reason_summary or ''}" - return result.target_seat, result.reason_summary + return fallback, f"abstain_fallback:{reason_summary or ''}" + return result_target_seat, reason_summary __all__ = ["NpcClient", "NpcClientConfig"] diff --git a/tests/test_npc_game_state.py b/tests/test_npc_game_state.py index 0ad6b54..df05a01 100644 --- a/tests/test_npc_game_state.py +++ b/tests/test_npc_game_state.py @@ -458,3 +458,82 @@ def test_npcgamestate_constructs_with_defaults() -> None: ) assert state.game_id == "g" assert state.seer_results == [] + + +@pytest.mark.asyncio +async def test_vote_falls_back_when_llm_raises_exception() -> None: + """Vertex AI sometimes returns 504 DEADLINE_EXCEEDED / 429 + RESOURCE_EXHAUSTED. The previous behaviour returned None on + exception, bypassing the abstain fallback and silently dropping + the ballot. Force a legal pick so the seat doesn't end up in the + silent-abstain bucket on a transient LLM failure. + """ + client, sent = _make_client_with_capture() + await client.process_message(_snapshot().model_dump_json()) + + class _RaisingLLM: + async def decide_json( + self, *, system_prompt: str, user_prompt: str, + schema: dict[str, object], + ) -> str: + raise RuntimeError("simulated 504 DEADLINE_EXCEEDED") + + client.decision_llm = _RaisingLLM() # type: ignore[assignment] + + vote_req = DecideVoteRequest( + ts=3000, trace_id="t-vote", + request_id="rv-llm-err", npc_id="npc_setsu", seat_no=3, + game_id="g1", phase_id="g1::day1::DAY_VOTE::1", + candidate_seats=((1, "Alice"), (5, "Bob")), + expires_at_ms=10_000, + ) + await client.process_message(vote_req.model_dump_json()) + decisions = [VoteDecision.model_validate_json(m) for m in sent if '"vote_decision"' in m] + assert len(decisions) == 1 + assert decisions[0].target_seat in {1, 5}, ( + "LLM exception must trigger abstain fallback, not silent abstain" + ) + assert decisions[0].reason_summary is not None + assert "abstain_fallback" in decisions[0].reason_summary + + +@pytest.mark.asyncio +async def test_night_target_falls_back_when_llm_raises_exception() -> None: + """Reproduces game `06c38cd43494` NIGHT_1 stall: Vertex AI returned + 504 DEADLINE_EXCEEDED on the seer's divine call → previous code + short-circuited with `None, "llm_error"` → Master's + `pending_decisions` retained `missing_seats=[seer]` past the + deadline → game parked in WAITING_HOST_DECISION. Now the random- + legal fallback covers transport errors too. + """ + client, sent = _make_client_with_capture() + await client.process_message(_snapshot(role="SEER").model_dump_json()) + + class _RaisingLLM: + async def decide_json( + self, *, system_prompt: str, user_prompt: str, + schema: dict[str, object], + ) -> str: + raise RuntimeError("simulated 504 DEADLINE_EXCEEDED") + + client.decision_llm = _RaisingLLM() # type: ignore[assignment] + + night_req = DecideNightActionRequest( + ts=4000, trace_id="t-night", + request_id="rn-llm-err", npc_id="npc_setsu", seat_no=3, + game_id="g1", phase_id="g1::day1::NIGHT::1", + action_kind="seer_divine", + candidate_seats=((1, "Alice"), (5, "Bob")), + expires_at_ms=20_000, + ) + await client.process_message(night_req.model_dump_json()) + decisions = [ + NightActionDecision.model_validate_json(m) + for m in sent if '"night_action_decision"' in m + ] + assert len(decisions) == 1 + assert decisions[0].target_seat in {1, 5}, ( + "LLM exception must trigger abstain fallback for night actions too" + ) + assert decisions[0].reason_summary is not None + assert "abstain_fallback" in decisions[0].reason_summary From 5934f776058be9300c231ffa7249ba473dc46fb9 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Fri, 1 May 2026 23:58:44 +0900 Subject: [PATCH 091/133] feat(prompt): restructure CO ledger + dead-CO awareness rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Game `74edf214638d` had ステラ (madman) seer-CO on day 1 and get attacked night 1; on day 2 morning every NPC said "ユリコさんが 唯一の占い師CO" / "二人とも単独のCO", treating Stella's CO as if it never happened. The structured `claimed_seer_*` fields were correctly persisted on the SpeakResult and the prompt's `公開された占い/霊媒CO結果` ledger DID list Stella's CO — the LLM was just not parsing it carefully enough on a low-thinking gemini-3.1-flash-lite-preview pass. Two complementary fixes: 1. ledger rendering (`master/logic_service.py`): - Top warning banner: "死亡席のCOも依然として有効。alive/dead でledgerから除外しない". - Split into separate ### 占いCO / ### 霊媒CO sections so the LLM doesn't conflate the two when scanning a row. - Each row now carries an explicit (席N, 生存|死亡) tag inline, removing the cross-reference burden against the participant section. - Header surfaces both the distinct-claimer count vs role cap (max 3 seer / 2 medium) and the per-claimer day-N expected count. 2. game_rules block (`prompt_builder.py`): - New paragraph emphasizing that "通算 N 件" counts ALL past claimers regardless of alive/dead, that the official source is the ledger block's count, and that "占い師は◯◯さんだけ" / "単独CO" cannot be concluded without first checking the ledger total. Tests `tests/test_claim_history.py` updated to match the new row shape (`(席N, 生存)` instead of `(占いCO 通算 1 件)`) and verify the dead-CO banner is present. --- src/wolfbot/llm/prompt_builder.py | 9 ++++ src/wolfbot/master/logic_service.py | 68 ++++++++++++++++++++++------- tests/test_claim_history.py | 20 ++++++--- 3 files changed, 76 insertions(+), 21 deletions(-) diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index 11f1304..d5cfa99 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -142,6 +142,15 @@ def _build_game_rules_block() -> str: "- ただし「現在生存している CO 者が 1 人だけ」というだけでは単独 CO 扱いしない。" "同じ役職 CO が過去に 2 人以上存在したことがある場合、対抗者が処刑・襲撃などで死亡して" "現在 1 人だけ残っていても、その残存 CO 者を自動的に真置きしない。\n" + "- **CO 通算件数は『生存・死亡を問わず過去に CO した全 seat の数』で数える。**" + "プロンプトの `## 公開された占い/霊媒CO結果` ブロックには死亡席の CO も列挙されており、" + "そのブロックの『通算 N 件』が公式の件数。" + "「現在生存している占い CO は 1 人」と「占い CO は単独 (= 通算 1 件)」は別の概念で、" + "後者は通算ベースで判断する。" + "「占い師は◯◯さんだけ」「単独のCO」「単独で出ているから真」と判断する **前に必ず** " + "ledger ブロックの通算件数を確認すること。" + "通算 2 件以上なら、たとえ生存者が 1 人でも『単独 CO』とは呼ばない。" + "死亡した CO 者の主張は記録に残り、推理材料として依然として有効である。\n" "- 対抗 CO が出た場合は、死亡済み CO 者も推理対象として保持し、" "判定結果・発言の時系列・投票・襲撃結果・死亡タイミングとの整合性で真偽を比較し、" "どちらをより真寄りとするか判断する。\n" diff --git a/src/wolfbot/master/logic_service.py b/src/wolfbot/master/logic_service.py index 2957c23..24d4f7e 100644 --- a/src/wolfbot/master/logic_service.py +++ b/src/wolfbot/master/logic_service.py @@ -112,29 +112,66 @@ def _name(seat: int) -> str: # the same record — real roles use it to keep their own past # results consistent in speech, fake-CO wolves see their own # prior lies and either commit to them or get caught when they - # contradict themselves. Compact rendering keeps the prompt - # token budget bounded even on day 4+. - summary += "\n\n## 公開された占い/霊媒CO結果 (公式記録)\n" - # Expected count rule: a real seer at day N has claimed N + 1 - # results (NIGHT_0 random white + one per night). The line - # below surfaces it once so the LLM has a numeric anchor. + # contradict themselves. + # + # Layout (post-game ``74edf214638d`` redesign): + # + # 1. Top warning banner: dead-claimer COs are still valid public + # info. Game ``74edf214638d`` had ステラ (madman) seer-CO on + # day 1, get attacked night 1, then on day 2 morning every + # NPC ignored her CO and called ユリコ "the single seer CO" — + # the LLM was treating dead = irrelevant. Banner up top is + # the cheapest fix; the rule also goes into prompt_builder. + # 2. Per-role section header (### 占いCO / ### 霊媒CO) so the + # LLM doesn't confuse seer vs medium when scanning a row. + # 3. Each row carries an explicit alive/dead tag in-line so the + # LLM doesn't need to cross-reference the participant list + # to know whether a claimer is still around. + # 4. Distinct-claimer count vs cap is shown next to the role + # header (max 3 seer / 2 medium per CLAUDE rules). Per-day + # expected count (= day+1 results per real seer) stays as a + # separate sub-line under the seer header. expected = expected_seer_claim_count_for_day(state.day) + seer_claimers = sorted( + s for s, h in claim_history.by_seat.items() if h.seer_claims + ) + medium_claimers = sorted( + s for s, h in claim_history.by_seat.items() if h.medium_claims + ) + + def _alive_tag(seat: int) -> str: + return "生存" if seat in state.alive_seat_nos else "死亡" + + summary += "\n\n## 公開された占い/霊媒CO結果 (公式記録)\n" summary += ( - f"(占いCO: 通算 {expected} 件まで整合。これより少ない/多い結果は破綻)\n" + "**重要**: 死亡席の CO も依然として有効な公開情報です。" + "alive/dead で ledger から除外しないでください。" + "「占い師は◯◯さん 1 人だけ」「単独 CO」のように現在生存中の CO 数だけで" + "結論を出すのは誤りで、必ず以下の通算件数で判断してください。\n" + ) - for seat_no in sorted(claim_history.by_seat.keys()): - history = claim_history.by_seat[seat_no] - who = _name(seat_no) - if history.seer_claims: + if seer_claimers: + summary += ( + f"\n### 占いCO 通算 {len(seer_claimers)} 件 / 上限 3 件 " + f"(各人の発表は朝までに day+1 = {expected} 件まで整合)\n" + ) + for seat_no in seer_claimers: + history = claim_history.by_seat[seat_no] + who = _name(seat_no) seer_summary = ", ".join( f"day{c.day}: {c.target_name}{'黒' if c.is_wolf else '白'}" for c in history.seer_claims ) summary += ( - f"- {who} (占いCO 通算 {len(history.seer_claims)} 件): " - f"{seer_summary}\n" + f"- {who} (席{seat_no}, {_alive_tag(seat_no)}) — {seer_summary}\n" ) - if history.medium_claims: + if medium_claimers: + summary += ( + f"\n### 霊媒CO 通算 {len(medium_claimers)} 件 / 上限 2 件\n" + ) + for seat_no in medium_claimers: + history = claim_history.by_seat[seat_no] + who = _name(seat_no) medium_summary = ", ".join( f"day{c.day}: " + ( @@ -144,8 +181,7 @@ def _name(seat: int) -> str: for c in history.medium_claims ) summary += ( - f"- {who} (霊媒CO 通算 {len(history.medium_claims)} 件): " - f"{medium_summary}\n" + f"- {who} (席{seat_no}, {_alive_tag(seat_no)}) — {medium_summary}\n" ) summary = summary.rstrip() # Per-recipient nudge when the addressee themselves is a CO'd diff --git a/tests/test_claim_history.py b/tests/test_claim_history.py index 08bf8a8..a444724 100644 --- a/tests/test_claim_history.py +++ b/tests/test_claim_history.py @@ -215,10 +215,18 @@ def test_logic_packet_summary_includes_claim_history_block() -> None: summary = packet.public_state_summary assert "公開された占い/霊媒CO結果" in summary - # Expected count rule surfaces the day-N anchor. - assert "通算 2 件まで整合" in summary - assert "Jonas (占いCO 通算 1 件): day1: Comet白" in summary - assert "Yuriko (占いCO 通算 1 件): day1: Setsu白" in summary + # Distinct-claimer count vs cap shown in the role header. + assert "通算 2 件 / 上限 3 件" in summary + # Per-day expected count (= day+1 results per real seer) shown + # under the seer header so the LLM has a numeric anchor. + assert "day+1 = 2 件まで整合" in summary + # Per-row format includes seat number + alive/dead tag inline. + assert "Jonas (席2," in summary + assert "day1: Comet白" in summary + assert "Yuriko (席9," in summary + assert "day1: Setsu白" in summary + # Top warning banner for the dead-CO awareness rule. + assert "死亡席の CO も依然として有効" in summary def test_logic_packet_summary_omits_block_without_history() -> None: @@ -259,5 +267,7 @@ def test_logic_packet_summary_renders_medium_void_as_no_result() -> None: claim_history=history, ) - assert "Shigemichi (霊媒CO" in packet.public_state_summary + # Medium ledger row format: claimer + seat + alive/dead, then results. + assert "霊媒CO 通算 1 件 / 上限 2 件" in packet.public_state_summary + assert "Shigemichi (席5," in packet.public_state_summary assert "結果なし" in packet.public_state_summary From 9d3d86b550da9128f207316ad19657b2ceeb548a Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 00:35:12 +0900 Subject: [PATCH 092/133] fix(reactive_voice): block duplicate runoff candidate intro on double phase-entry kick DAY_RUNOFF_SPEECH entry runs try_dispatch_next twice (post-PHASE_CHANGE narration kick + _on_reactive_phase_enter). Between the first call's dispatch_request (only adds to _pending) and the NPC's SpeakResult (adds to _active_playback), is_blocked() returns None, so the second call re-picked the same chosen seat and Master read the candidate intro twice. Skip when a SpeakRequest for the chosen seat is already pending. --- src/wolfbot/master/speak_arbiter.py | 22 +++++ tests/test_reactive_voice_master.py | 139 ++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/speak_arbiter.py index ab3f189..13364b9 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/speak_arbiter.py @@ -1469,6 +1469,28 @@ async def _dispatch_runoff_next(self, game_id: str, day: int) -> None: self._maybe_wake_runoff(game_id) return + # Re-entrancy guard: phase entry into DAY_RUNOFF_SPEECH fires + # `try_dispatch_next` from two sequential paths — the + # post-PHASE_CHANGE-narration kick in `_master_narrate` and the + # `_on_reactive_phase_enter` callback driven by + # `_dispatch_submissions`. Between the first call's + # `dispatch_request` (which only populates `_pending`) and the + # NPC's `SpeakResult` (which adds to `_active_playback`), + # `is_blocked()` returns None, so the second call would pick + # the same `chosen` seat again and have Master read the + # candidate intro twice. Skip when a SpeakRequest for this + # seat is already pending — the existing dispatch will flip + # `runoff_speech_done` itself. + for pending in self._pending.values(): + if pending.game_id == game_id and pending.seat_no == chosen.seat_no: + log.debug( + "runoff_dispatch_skipped_already_pending game=%s seat=%d request=%s", + game_id, + chosen.seat_no, + pending.request_id, + ) + return + # Master narration: name the candidate before they speak. Awaited # so the intro finishes before the NPC's own TTS starts. if self._runoff_announce is not None: diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index b190fe0..de087fb 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -3113,6 +3113,145 @@ def _wake(game_id: str) -> None: assert not bufs["gina"] +async def test_runoff_dispatch_skips_when_seat_already_pending( + repo: SqliteRepo, +) -> None: + """Two sequential `try_dispatch_next` invocations on phase entry into + DAY_RUNOFF_SPEECH (post-PHASE_CHANGE-narration kick + the + `_on_reactive_phase_enter` callback) must NOT both run the Master + candidate intro. Between the first call's `dispatch_request` (which + only adds to `_pending`) and the NPC's SpeakResult arriving (which + flips `_active_playback`), the gate is open — without the + re-entrancy guard the second call picks the same chosen seat and + speaks the intro twice. Regression for the user-reported + "決選投票で最初の候補者の発言を促す案内が2回読み上げられる" bug. + """ + from wolfbot.domain.models import Vote + + g = Game( + id="rv-runoff-double-kick", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_RUNOFF_SPEECH, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + for seat in ( + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, + display_name="🌙セツ", + discord_user_id=None, + is_llm=True, + persona_key="setsu", + ), + Seat( + seat_no=3, + display_name="🟣ジナ", + discord_user_id=None, + is_llm=True, + persona_key="gina", + ), + ): + await repo.insert_seat(g.id, seat) + for sn, role in ((1, Role.WEREWOLF), (2, Role.SEER), (3, Role.MEDIUM)): + await repo.set_player_role(g.id, sn, role) + # Tie: seats 1 and 2 (voter 3 abstains). + for voter, target in ((1, 2), (2, 1), (3, None)): + await repo.insert_vote( + Vote( + game_id=g.id, + day=1, + round=0, + voter_seat=voter, + target_seat=target, + submitted_at=1, + ) + ) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"raqio": [], "setsu": [], "gina": []} + for npc_id, persona, seat_no in ( + ("npc_raqio", "raqio", 1), + ("npc_setsu", "setsu", 2), + ("npc_gina", "gina", 3), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + intros: list[int] = [] + + async def _intro(seat: Seat) -> None: + intros.append(seat.seat_no) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + runoff_announce=_intro, + ) + + # Two sequential kicks — mirrors the prod ordering where + # `_master_narrate`'s post-PHASE_CHANGE kick runs before + # `_on_reactive_phase_enter` fires from `_dispatch_submissions`. + # Between them, the first dispatch's SpeakRequest is in + # `_pending` but no SpeakResult has arrived yet so + # `_active_playback` is empty. + await arb.try_dispatch_next(g.id) + await arb.try_dispatch_next(g.id) + + # The intro must fire exactly once, not twice. Seat 1 (lowest + # tied seat-no) is the chosen candidate; the second kick sees + # the pending SpeakRequest and bails out. + assert intros == [1], ( + "candidate intro must be voiced exactly once even on a " + f"double phase-entry kick (got intros={intros})" + ) + # Exactly one SpeakRequest was emitted. + raqio_speak_requests = [m for m in bufs["raqio"] if '"speak_request"' in m] + assert len(raqio_speak_requests) == 1, ( + "exactly one SpeakRequest must be emitted to the chosen NPC " + f"(got {len(raqio_speak_requests)})" + ) + # The other tied seat (席2 セツ) must not be picked while seat 1 + # is still in flight. + assert not any('"speak_request"' in m for m in bufs["setsu"]) + + async def test_runoff_dispatch_marks_done_on_offline_npc( repo: SqliteRepo, ) -> None: From c3328ac37e0064492ff2484df1cc16f7bf1ccda8 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 00:35:24 +0900 Subject: [PATCH 093/133] fix(prompt): correct seer claim count off-by-one + add runoff speech guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expected_seer_claim_count_for_day(N) returned N+1 but the validator's '1 entry per declared day' rule (same_day_priors) treats N as the correct count. The mismatch caused the urgent-CO nudge to tell day-1 seers to declare a 2nd same-day target, which the validator then rejected as seer_target_swap — fabricating-capping the seat after 5 retries (game 7faf339713cf where ジナ never spoke in the runoff). Also add a DAY_RUNOFF_SPEECH-specific block to the speech LLM user prompt: tied candidates must name a suspect with concrete evidence, not just re-cite their CO/seer result. Without this the LLM defaulted to a defensive 'please trust me' speech that gave the village no reason to vote the other way. --- src/wolfbot/master/claim_history.py | 38 +++++++++----- src/wolfbot/master/logic_service.py | 21 +++++--- .../npc/openai_compatible_generator.py | 35 ++++++++++++- tests/test_claim_history.py | 35 ++++++++++--- tests/test_npc_seat_assignment.py | 50 +++++++++++++++++++ 5 files changed, 151 insertions(+), 28 deletions(-) diff --git a/src/wolfbot/master/claim_history.py b/src/wolfbot/master/claim_history.py index 83b143a..d879ea8 100644 --- a/src/wolfbot/master/claim_history.py +++ b/src/wolfbot/master/claim_history.py @@ -24,11 +24,16 @@ Day-vs-count integrity ---------------------- -A real seer at day-N morning has performed ``N + 1`` divinations -(NIGHT_0 random white at day=0, plus one per night through night -N-1, surfaced morning of day N). A liar must mirror that count or -get caught — :func:`expected_claim_count_for_day` exposes the rule -so the prompt builder and viewer can both reference the same number. +A real seer at day-N morning has issued ``N`` public divination +claims: 1 for NIGHT_0 (random white, declared day-1 morning), plus +1 per subsequent night surfaced on the next morning. The claim +ledger tags each entry with the day it was *announced*, so by day +N morning the count is exactly N (one entry per declared day, +day=1..N). :func:`expected_seer_claim_count_for_day` exposes the +rule so the prompt builder and viewer can both reference the same +number — and so the count never disagrees with +``claim_validator``'s ``same_day_priors`` (= "1 entry per declared +day") rule. A liar must mirror that count or get caught. """ from __future__ import annotations @@ -160,16 +165,25 @@ def _name(seat: int) -> str: def expected_seer_claim_count_for_day(day: int) -> int: - """Number of seer results a real seer should have announced by day N. + """Number of seer claim entries a real seer should have announced by day N. - The seer performs the NIGHT_0 random white on day 0 and one - divination each subsequent night. The day-N morning therefore - surfaces ``N + 1`` cumulative results (day 0 + nights 0..N-1). - A claimer reporting fewer or more results breaks the invariant. + The claim ledger tags each entry with the day it was *announced*: + + - NIGHT_0's random white is declared on day 1 morning → entry day=1. + - NIGHT_K's result (for K>=1) is declared on day K+1 morning → + entry day=K+1. + + So by day N morning the seer has announced exactly N entries + (one per declared day, day=1..N). This matches + :func:`wolfbot.master.claim_validator._validate_seer_fake`'s + "1 entry per declared day" rule (``same_day_priors``). + + Day 0 has no morning discussion (SETUP / NIGHT_0 are transient), + so the rule reads "no announced results before day 1". """ - if day < 0: + if day < 1: return 0 - return day + 1 + return day def expected_medium_claim_count_for_day(executions_so_far: int) -> int: diff --git a/src/wolfbot/master/logic_service.py b/src/wolfbot/master/logic_service.py index 24d4f7e..1237abb 100644 --- a/src/wolfbot/master/logic_service.py +++ b/src/wolfbot/master/logic_service.py @@ -129,8 +129,9 @@ def _name(seat: int) -> str: # to know whether a claimer is still around. # 4. Distinct-claimer count vs cap is shown next to the role # header (max 3 seer / 2 medium per CLAUDE rules). Per-day - # expected count (= day+1 results per real seer) stays as a - # separate sub-line under the seer header. + # expected count (= N results per real seer by day-N morning, + # one per declared day) stays as a separate sub-line under + # the seer header. expected = expected_seer_claim_count_for_day(state.day) seer_claimers = sorted( s for s, h in claim_history.by_seat.items() if h.seer_claims @@ -153,7 +154,7 @@ def _alive_tag(seat: int) -> str: if seer_claimers: summary += ( f"\n### 占いCO 通算 {len(seer_claimers)} 件 / 上限 3 件 " - f"(各人の発表は朝までに day+1 = {expected} 件まで整合)\n" + f"(各人の発表は day{state.day} 朝までに通算 {expected} 件まで整合)\n" ) for seat_no in seer_claimers: history = claim_history.by_seat[seat_no] @@ -205,18 +206,24 @@ def _alive_tag(seat: int) -> str: and recipient_seer_count < expected ): missing = expected - recipient_seer_count + # Past-night index in the ledger convention: at day-N + # morning the most recent night is NIGHT_(N-1) (NIGHT_0 + # is the night before day 1, NIGHT_(N-1) is the night + # before day N). `prev_night = state.day - 1` is the + # one whose result is announced *today*. + prev_night = max(0, state.day - 1) summary += ( f"\n\n## 【あなた宛 / 緊急】占いCO 結果の発表が不足しています\n" f"あなたは占いCO 者で、現在の発表結果は通算 " - f"{recipient_seer_count} 件、本日 day{state.day} 朝の" - f"期待値は {expected} 件 (NIGHT_0 + 各夜 1 件)。" + f"{recipient_seer_count} 件。day{state.day} 朝までの" + f"期待値は {expected} 件 (day1 朝で 1 件、以後毎朝 +1 件)。" f"未発表が {missing} 件あります。" - f"**この発話で前夜 (NIGHT_{state.day}) の新しい占い結果を必ず発表し、" + f"**この発話で前夜 (NIGHT_{prev_night}) の新しい占い結果を必ず発表し、" f"`claimed_seer_result` に `{{target_seat, is_wolf}}` を構造化" f"して入れてください**。" f"過去の結果 ({', '.join(c.target_name for c in recipient_history.seer_claims)}) " f"を再表明するだけでは整合しません。" - f"対象は前夜 NIGHT_{state.day} の開始時点で生存していた相手から選ぶこと。" + f"対象は NIGHT_{prev_night} の開始時点で生存していた相手から選ぶこと。" ) # Same gating logic for medium claimers: if the seat has # CO'd as medium and yesterday's execution exists in the diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index b3b8b97..42c5fbe 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -232,8 +232,11 @@ def _build_system( "両フィールドとも `null` にする。" "プロンプト中の `## 公開された占い/霊媒CO結果` ブロックが過去の発表履歴 (公式記録) であり、" "そこに矛盾する/重複する/数が合わない結果を出すと **破綻判定** され狼/騙り側は即吊られる。" - "占いCO した seat は day N の朝までに通算 N 個の結果を持つ " - "(NIGHT_0 の day0 ランダム白 + 毎晩1個追加; day1 朝なら通算 1 個、day2 朝なら 2 個…)。" + "占いCO した seat は day N の朝までに通算 N 個の結果を発表できる " + "(day1 朝で NIGHT_0 ランダム白 1 件、day N 朝までに各夜分が +1 件ずつ加算; " + "day1 朝なら通算 1 個、day2 朝なら 2 個…)。" + "**同じ day に 2 件目の占い結果は出せない (本物の占い師は一夜 1 件まで)**。" + "決選演説 (DAY_RUNOFF_SPEECH) もその day 内なので、新しい占い結果は出さない。" "1 回の発話で複数 seat を占ったと言う「すべて白」「全員白」は破綻なので絶対に出さない。\n" "- 特定の席に向けて話す場合は `addressed_seat_nos` にその席番号の配列を入れる。" "1人だけなら `[3]`、複数人に同時に問いかけるなら `[2, 3]` (例「セツとジナはどう?」)。" @@ -308,6 +311,34 @@ def _cause_tag(seat_no: int) -> str: "(新しい結果がないなら null)。" ) + # DAY_RUNOFF_SPEECH-specific guidance: tied candidates each get one + # final speech before the runoff vote. Without a phase-specific + # nudge the speech LLM defaults to the DAY_DISCUSSION pattern of + # "re-cite my divination result and ask people to trust me", which + # is structurally weak — the village already heard that during the + # main discussion. A runoff speech needs to PUSH suspicion onto a + # specific other player (with reasoning) so listeners have a + # reason to vote the other way. + if "DAY_RUNOFF_SPEECH" in request.phase_id: + lines.append("") + lines.append("## 【決選投票 直前の最終演説】") + lines.append( + "あなたは決選投票の対象として、これが処刑回避のための最後の発言です。" + "占い結果や霊媒結果の再表明だけで終わると村は判断材料を得られず、" + "あなたへの票は動きません。次の 2 つを必ず言葉にしてください:\n" + "1. **誰を最も怪しいと思っているか** — 同じ決選候補者または他の生存者を" + "1 人名指しする (display_name で呼ぶ)。\n" + "2. **その根拠** — 投票履歴の不自然さ、CO/対抗のタイミング、" + "発言の矛盾、占い・霊媒結果の整合性、ライン (庇い合い) の動きなど、" + "公開ログから引ける具体的な事実を 1 つ以上挙げる。\n" + "自分が真を主張する場合は、相手 (対抗 CO) の何が偽だと判断したかを" + "具体的に指摘する (例: 「対抗の発表が遅すぎた」「占い先の選び方が黒先狙いに見えない」" + "「私への投票理由が薄い」など)。" + "「私を信じてください」「誠実に話します」のような感情訴えだけでは票は動きません。" + "新しい占い/霊媒結果は出せない (`claimed_seer_result` / `claimed_medium_result` は" + "`null`、または既に公表した過去の結果と完全一致のいずれか)。" + ) + # Roster header — the ONLY block where seat numbers explicitly # appear. All other blocks below reference players by display_name. if alive_seats or dead_seats: diff --git a/tests/test_claim_history.py b/tests/test_claim_history.py index a444724..b787fb7 100644 --- a/tests/test_claim_history.py +++ b/tests/test_claim_history.py @@ -160,10 +160,30 @@ def test_collect_claim_history_drops_partial_seer_claims() -> None: assert history.by_seat == {} -def test_expected_seer_claim_count_for_day_follows_n_plus_one_rule() -> None: - assert expected_seer_claim_count_for_day(0) == 1 # NIGHT_0 random white - assert expected_seer_claim_count_for_day(1) == 2 # + night 0 result - assert expected_seer_claim_count_for_day(4) == 5 +def test_expected_seer_claim_count_for_day_returns_announced_entries() -> None: + """Real seer's announced-claim count: 1 entry per declared day. + + The claim ledger tags each entry with the day it was *announced* + (not the night the divination happened on). NIGHT_0 surfaces day-1 + morning → entry day=1; NIGHT_K (K>=1) surfaces day-(K+1) morning → + entry day=K+1. So by day-N morning the count is exactly N. This + matches `claim_validator._validate_seer_fake`'s + "1 entry per declared day" rule (`same_day_priors`) — the prompt + builder's expected count and the validator's structural rule must + not disagree, otherwise an "緊急: 結果が不足" nudge tells the LLM + to declare a 2nd same-day target that the validator instantly + rejects as `seer_target_swap` (regression observed in game + 7faf339713cf where ジナ got fabrication-capped on day-1 runoff + and never spoke). + """ + # Day 0: SETUP / NIGHT_0 only — no morning, no announced results. + assert expected_seer_claim_count_for_day(0) == 0 + # Day 1 morning: only NIGHT_0's random white announced (1 entry). + assert expected_seer_claim_count_for_day(1) == 1 + # Day 2 morning: NIGHT_0 + NIGHT_1 announced (2 entries). + assert expected_seer_claim_count_for_day(2) == 2 + # Day N morning: N entries. + assert expected_seer_claim_count_for_day(4) == 4 # Defensive: negative day clamps to zero so the rule reads "no # results before the game has begun" rather than blowing up. assert expected_seer_claim_count_for_day(-1) == 0 @@ -217,9 +237,10 @@ def test_logic_packet_summary_includes_claim_history_block() -> None: assert "公開された占い/霊媒CO結果" in summary # Distinct-claimer count vs cap shown in the role header. assert "通算 2 件 / 上限 3 件" in summary - # Per-day expected count (= day+1 results per real seer) shown - # under the seer header so the LLM has a numeric anchor. - assert "day+1 = 2 件まで整合" in summary + # Per-day expected count (= N entries by day-N morning, one per + # declared day) shown under the seer header so the LLM has a + # numeric anchor that matches the validator's same-day rule. + assert "day1 朝までに通算 1 件まで整合" in summary # Per-row format includes seat number + alive/dead tag inline. assert "Jonas (席2," in summary assert "day1: Comet白" in summary diff --git a/tests/test_npc_seat_assignment.py b/tests/test_npc_seat_assignment.py index 71d4fa6..23ea016 100644 --- a/tests/test_npc_seat_assignment.py +++ b/tests/test_npc_seat_assignment.py @@ -349,6 +349,56 @@ def test_build_user_prompt_renders_recent_speeches_and_seats() -> None: assert "席1 Alice [テキスト]" not in user_msg +def test_build_user_prompt_runoff_speech_block_appears_only_in_runoff_phase() -> None: + """Tied candidates speaking in DAY_RUNOFF_SPEECH need a phase-specific + nudge to (1) name a suspect and (2) cite concrete evidence — not just + re-cite their CO/seer result. The nudge must NOT appear in + DAY_DISCUSSION (where the rotation is broader and the LLM speech is + already conversational). Regression for game 7faf339713cf where ジナ + only ever re-cited her seer result during runoff and got + fabrication-capped trying to invent a 2nd same-day claim. + """ + logic = LogicPacket( + ts=1, + trace_id="t", + packet_id="lp", + phase_id="g1::day1::DAY_RUNOFF_SPEECH::1", + recipient_npc_id="npc_1", + public_state_summary="alive=[1,2,3]", + expires_at_ms=9999, + ) + runoff_request = SpeakRequest( + ts=1, + trace_id="t", + request_id="sr", + npc_id="npc_1", + phase_id="g1::day1::DAY_RUNOFF_SPEECH::1", + seat_no=2, + logic_packet_id="lp", + suggested_intent="speak", + max_chars=140, + expires_at_ms=5000, + alive_seats=((1, "Alice"), (2, "Bob"), (3, "🌙セツ")), + ) + user_msg = _build_user(logic, runoff_request) + # Runoff guidance must surface the two required content axes. + assert "決選投票 直前の最終演説" in user_msg + assert "誰を最も怪しいと思っているか" in user_msg + assert "その根拠" in user_msg + # And reinforce the validator's "no new same-day result" rule so the + # LLM doesn't try to invent a 2nd day-1 divination target. + assert "新しい占い/霊媒結果は出せない" in user_msg + + # DAY_DISCUSSION (different phase id) must NOT include the runoff + # block — it would distort regular discussion into pre-vote + # accusations. + discuss_request = runoff_request.model_copy( + update={"phase_id": "g1::day1::DAY_DISCUSSION::1"} + ) + discuss_msg = _build_user(logic, discuss_request) + assert "決選投票 直前の最終演説" not in discuss_msg + + def test_build_user_prompt_uses_npc_state_over_request_fields() -> None: """Phase-D: when an `NpcGameState` is supplied, the speech user prompt pulls alive/dead/private info from it rather than the SpeakRequest. From 0788aec4c851ca1d70ee4d90915600a78353d899 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 01:06:11 +0900 Subject: [PATCH 094/133] fix(reactive_voice): random-legal fallback when NPC night-action dispatcher returns null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In reactive_voice mode the per-actor request_ttl_ms (12s) can fire when an NPC bot's LLM is slow (Gemini with thinking=high tail can exceed 12s). The dispatcher returns target=None, which forwards unchanged to submit_night_action and gets rejected as ILLEGAL_TARGET — leaving the seat as missing. plan_night_resolve then parks the game in WAITING_HOST_DECISION until the host runs /wolf force-skip. Match rounds-mode behaviour (_resolve_target with allow_none=False already picks a uniform-random legal target on null/illegal LLM output). Adds the same random-legal fallback in _run_night_actions_via_npc_dispatcher so a slow NPC LLM no longer stalls the night. Observed in game bc20ebccb605 NIGHT 2 where jonas's knight_guard call took ~20s and the entire phase parked. --- src/wolfbot/services/llm_service.py | 23 ++++++ tests/test_llm_service.py | 118 ++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index e0a55dd..1dd840d 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -867,6 +867,29 @@ async def _run_night_actions_via_npc_dispatcher( target, ) target = None + # Random-legal fallback: matches rounds-mode behaviour + # at llm_service._run_night_actions where + # `_resolve_target(..., allow_none=False)` already picks + # a uniform legal target when the LLM returns None / + # illegal. Without this, a slow NPC LLM that hits the + # dispatcher's request_ttl (12s) returns None → + # `submit_night_action` rejects it (ILLEGAL_TARGET) → + # the seat stays missing → `plan_night_resolve` parks + # the game in WAITING_HOST_DECISION. Observed in game + # bc20ebccb605 NIGHT 2 where jonas's knight_guard call + # took ~20s and stalled the entire phase. + if target is None: + legal_for_actor = sorted(per_actor_legal[actor.seat_no]) + if legal_for_actor: + target = self.rng.choice(legal_for_actor) + log.info( + "npc_night_decision_random_fallback game=%s seat=%d kind=%s target=%d " + "reason=null_or_illegal_response_from_npc", + game.id, + actor.seat_no, + action_label, + target, + ) kind = per_actor_kind[actor.seat_no] try: await self.gs.submit_night_action( diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index 1653989..82f5a98 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -597,6 +597,124 @@ async def test_wolf_chat_skipped_when_phase_advances_during_ask(repo: SqliteRepo assert [r for r in priv_for_seat3 if r.get("kind") == "WOLF_CHAT"] == [] +async def test_reactive_voice_night_dispatcher_random_legal_fallback_on_null( + repo: SqliteRepo, +) -> None: + """When the NPC bot's night-action LLM call times out (or returns + illegal), the dispatcher returns ``None`` for that seat. Without a + fallback this null target is forwarded to ``submit_night_action`` + which rejects it as ILLEGAL_TARGET, leaving the seat missing → + ``plan_night_resolve`` parks the game in ``WAITING_HOST_DECISION`` + until the host runs ``/wolf force-skip``. Regression for game + bc20ebccb605 NIGHT 2 where jonas's knight_guard call took ~20s + (over the 12s dispatcher TTL) and stalled the entire phase. + + Match rounds-mode (`_resolve_target(allow_none=False)`) by picking + a uniform-random legal target so the night still resolves. + """ + game = Game( + id="g-rv-night", + guild_id="gu", + host_user_id="h", + phase=Phase.NIGHT, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + heaven_channel_id="h1", + wolves_channel_id="w1", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(game) + seats = [ + Seat(seat_no=1, display_name="H1", discord_user_id="u1", is_llm=False, persona_key=None), + Seat( + seat_no=2, + display_name="Wolf", + discord_user_id=None, + is_llm=True, + persona_key="gina", + ), + Seat( + seat_no=3, + display_name="Knight", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + ] + for s in seats: + await repo.insert_seat(game.id, s) + await repo.set_player_role(game.id, 1, Role.VILLAGER) + await repo.set_player_role(game.id, 2, Role.WEREWOLF) + await repo.set_player_role(game.id, 3, Role.KNIGHT) + + class _NullingDispatcher: + """Returns None for every actor — simulates a slow LLM hitting + the dispatcher's request_ttl_ms timeout.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, list[int]]] = [] + + async def dispatch_night_actions( + self, + *, + game_id: str, + day: int, + action_kind: str, + actors: list[Player], + seats: list[Seat], + candidate_seats: list[int], + public_state_summary: str = "", + ) -> dict[int, int | None]: + self.calls.append((action_kind, [a.seat_no for a in actors])) + return {a.seat_no: None for a in actors} + + async def dispatch_wolf_chat_lines(self, **kwargs: object) -> None: + return None + + gs = _FakeGameService() + dispatcher = _NullingDispatcher() + adapter = LLMAdapter( + repo=repo, + decider=_ScriptedDecider([]), + rng=random.Random(0), + npc_decision_dispatcher=dispatcher, # type: ignore[arg-type] + ) + adapter.set_game_service(gs) # type: ignore[arg-type] + + players = await repo.load_players(game.id) + await adapter.submit_llm_night_actions(game, players, seats) + await asyncio.gather(*list(adapter._background_tasks), return_exceptions=True) + + # Dispatcher was invoked for both wolf_attack and knight_guard. + kinds_dispatched = {kind for kind, _ in dispatcher.calls} + assert "wolf_attack" in kinds_dispatched + assert "knight_guard" in kinds_dispatched + + # CRITICAL: every submission must carry a non-null target despite + # the dispatcher returning None. Without the fallback, the wolf's + # attack and knight's guard would be rejected and the game would + # stall in WAITING_HOST_DECISION at deadline. + submitted_kinds = {kind for _gid, _seat, kind, _t, _d in gs.nights} + assert SubmissionType.WOLF_ATTACK in submitted_kinds + assert SubmissionType.KNIGHT_GUARD in submitted_kinds + for _gid, seat, kind, target, _d in gs.nights: + assert target is not None, ( + f"seat={seat} kind={kind.value} got null target — " + "fallback to random legal target is missing, the night will stall" + ) + # Targets must be legal — wolf can't self-attack, knight can't + # self-guard, etc. + for _gid, _seat, kind, target, _d in gs.nights: + if kind is SubmissionType.WOLF_ATTACK: + # Wolf attacks any non-wolf alive seat: 1 (villager) or 3 (knight). + assert target in {1, 3} + elif kind is SubmissionType.KNIGHT_GUARD: + # Knight guards any other alive seat: 1 (villager) or 2 (wolf). + assert target in {1, 2} + + async def test_run_night_actions_skips_seat_with_existing_action(repo: SqliteRepo) -> None: """Fix 1: night re-dispatch won't double-submit for a seat that already has a submission (unless it's in unresolved_seats for a wolf split).""" From 75db64ad432ac358b9bc534ea05bf88126e9c4f0 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 01:46:13 +0900 Subject: [PATCH 095/133] fix(npc): wolf-chat prompt now includes WEREWOLF role-strategy block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Game `38627df1ade1` night 1 had a 3-seer-CO board (Setsu real seer + Stella wolf fake seer + Comet madman fake seer). Both wolves' wolf- chat decisions agreed to attack Setsu — directly violating the "対抗 CO が出ている役職 (= 同じ役職の CO が公開ログ上 2 件以上) の CO 者は襲撃価値が低い" rule that lives in `_ROLE_STRATEGIES[WEREWOLF]`. Root cause: `build_wolf_chat_prompt` constructed its system + user prompt WITHOUT calling `_build_role_block`, so the WEREWOLF strategy (multi-CO attack avoidance, no-4th-seer-CO, GJ rebite, info-role priority, knight-candidate scoring) never reached the chat-side decision. The downstream `build_night_prompt` does include the block, but by the time night-action runs, `wolf_chat_history` is in the prompt as committed consensus and biases both wolves to follow through on the chat-time pick. Inject `_build_role_block(state.role)` into the wolf-chat `user_parts` so the chat-side reasoning has the same master tactical rules the night-action side already had. Test `test_build_wolf_chat_prompt_includes_role_strategy_block` verifies the role-strategy header is present and spot-checks two rules from the WEREWOLF block (multi-CO attack avoidance + no-4th-seer-CO). --- src/wolfbot/npc/decision_service.py | 13 +++++++++++++ tests/test_npc_decision_service.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/wolfbot/npc/decision_service.py b/src/wolfbot/npc/decision_service.py index 6988da9..7a29c2f 100644 --- a/src/wolfbot/npc/decision_service.py +++ b/src/wolfbot/npc/decision_service.py @@ -308,9 +308,20 @@ def build_wolf_chat_prompt( - propose / agree / counter on a target, - stay under 80 chars, - speak in the persona's voice (this is still character). + + The role-strategy block (= ``build_strategy_block(WEREWOLF)``) is + INJECTED here too. Without it, the chat's "今夜誰を噛む?" decision + runs on the persona block + game ledger only; the master tactical + rules (multi-CO attack avoidance, GJ rebite, info-role priority, + knight-candidate scoring) live in the WEREWOLF strategy block and + were missing from the chat prompt — so wolves agreed to attack a + seer in a 3-CO board (game ``38627df1ade1`` night 1), and the + night-action prompt that follows already carries the chat history + as commitment, biasing both wolves to follow through. """ persona_block = _build_persona_block(persona) state_block = _build_state_block(state) + role_block = _build_role_block(state.role) candidates_str = ( "、".join(f"席{seat_no} {name}" for seat_no, name in candidates) or "(なし)" @@ -336,6 +347,8 @@ def build_wolf_chat_prompt( "", persona_block, "", + role_block, + "", "## 自分の状況 (非公開)", state_block, "", diff --git a/tests/test_npc_decision_service.py b/tests/test_npc_decision_service.py index 9bbcabd..c12dd67 100644 --- a/tests/test_npc_decision_service.py +++ b/tests/test_npc_decision_service.py @@ -134,3 +134,31 @@ def test_parse_decision_drops_top_level_array() -> None: result = parse_decision("[1, 2]", legal_seats=frozenset({1, 5})) assert result.target_seat is None assert result.reason_summary == "not_object" + + +def test_build_wolf_chat_prompt_includes_role_strategy_block() -> None: + """Wolf chat must include the WEREWOLF role-strategy block so the + chat-side decision sees the master tactical rules (multi-CO attack + avoidance, GJ rebite, info-role priority, knight-candidate scoring). + + Game `38627df1ade1` had wolves agree in chat to attack a seer in a + 3-CO board because the chat prompt was missing the strategy block. + The night-action prompt does carry it, but by then `wolf_chat_history` + already locks the wolves to the chat-time consensus. + """ + from wolfbot.npc.decision_service import build_wolf_chat_prompt + + persona = NPC_PERSONAS_BY_KEY["gina"] + _system, user = build_wolf_chat_prompt( + state=_state(role="WEREWOLF"), + persona=persona, + candidates=((1, "Alice"), (5, "Bob")), + public_state_summary="phase=NIGHT day=1", + ) + # The role-strategy header marks the block's presence. + assert "## 役職別の戦術ヒント" in user + # Spot-check that the multi-CO attack-avoidance line from + # `_ROLE_STRATEGIES[WEREWOLF]` made it through. + assert "対抗 CO が出ている役職" in user + # And the no-4th-seer-CO rule too (same block). + assert "4 件目を出してはならない" in user From a9f2c7e0cc6d5f33a832ad93db07e74c2647efa8 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 10:44:24 +0900 Subject: [PATCH 096/133] feat(llm/template): add minimal markdown template engine for prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-piece API for upcoming prompt extraction: - loader.load_template(id) — slash-id → file path under src/wolfbot/prompts/templates, cached, traversal-safe. - parser.render(text, **ctx) / render_template(id, **ctx) — supports {{var}} substitution + nested {{#if var}}...{{/if}} conditional blocks. Missing required vars fail-fast with TemplateRenderError carrying both the var name and the template id for log triage. Deliberately tiny — no loops, no filters, no auto-escape. Anything beyond {{var}}/{{#if}} stays in Python where it can be unit-tested per item. --- src/wolfbot/llm/template/__init__.py | 48 +++++++ src/wolfbot/llm/template/loader.py | 82 ++++++++++++ src/wolfbot/llm/template/parser.py | 141 +++++++++++++++++++++ tests/test_template_engine.py | 181 +++++++++++++++++++++++++++ 4 files changed, 452 insertions(+) create mode 100644 src/wolfbot/llm/template/__init__.py create mode 100644 src/wolfbot/llm/template/loader.py create mode 100644 src/wolfbot/llm/template/parser.py create mode 100644 tests/test_template_engine.py diff --git a/src/wolfbot/llm/template/__init__.py b/src/wolfbot/llm/template/__init__.py new file mode 100644 index 0000000..ccf26ae --- /dev/null +++ b/src/wolfbot/llm/template/__init__.py @@ -0,0 +1,48 @@ +"""Markdown prompt template engine — load + render with placeholder substitution. + +Templates live under :mod:`wolfbot.prompts.templates` (file-system path +``src/wolfbot/prompts/templates``) and are referenced by a +slash-separated id like ``"master/task_vote"`` (no ``.md`` suffix). + +Two responsibilities split into two modules: + +* :func:`load_template` (``loader``) — find the template on disk, return + the raw string. LRU-cached so prompt files are read at most once per + process. +* :func:`render` (``parser``) — substitute ``{{var}}`` placeholders and + evaluate ``{{#if var}}...{{/if}}`` conditional blocks against a + caller-supplied context dict. + +The two-pieces split lets unit tests cover the parser in isolation +(no I/O) while the loader handles only the FS shape. Callers usually +chain them via :func:`render_template`. + +Why a tiny in-house engine rather than Jinja2 / Mustache: +- The placeholder syntax we need is intentionally minimal — no loops, + no filters, no auto-escaping. Anything more complex stays in Python. +- Zero new dependency for a 9-player Werewolf bot. +- Predictable error surface: a missing variable raises immediately + with the placeholder name, instead of silently rendering as empty. +""" + +from __future__ import annotations + +from wolfbot.llm.template.loader import ( + TEMPLATES_ROOT, + TemplateNotFoundError, + load_template, +) +from wolfbot.llm.template.parser import ( + TemplateRenderError, + render, + render_template, +) + +__all__ = [ + "TEMPLATES_ROOT", + "TemplateNotFoundError", + "TemplateRenderError", + "load_template", + "render", + "render_template", +] diff --git a/src/wolfbot/llm/template/loader.py b/src/wolfbot/llm/template/loader.py new file mode 100644 index 0000000..f3b0b0c --- /dev/null +++ b/src/wolfbot/llm/template/loader.py @@ -0,0 +1,82 @@ +"""Locate and read .md template files under ``prompts/templates``. + +Public API: + +* :data:`TEMPLATES_ROOT` — the on-disk directory used as the search + base. Tests can monkeypatch this to point at a fixture dir. +* :func:`load_template` — given a slash-separated id (``"master/task_vote"`` + → ``master/task_vote.md``), return the raw template body as a string. + Caches by template id so repeated calls during one process share the + same string. Raises :class:`TemplateNotFoundError` when the file + doesn't exist. + +The loader is deliberately minimal — it does not parse / render. That +job lives in :mod:`wolfbot.llm.template.parser` so unit tests can cover +parsing without touching the filesystem. +""" + +from __future__ import annotations + +from functools import cache +from pathlib import Path + +TEMPLATES_ROOT: Path = ( + Path(__file__).resolve().parent.parent.parent / "prompts" / "templates" +) +"""Filesystem directory containing the .md templates. + +Resolved relative to this module so the layout works regardless of +where the project is installed (editable, wheel, frozen). All template +ids are interpreted relative to this root. +""" + + +class TemplateNotFoundError(FileNotFoundError): + """Raised when :func:`load_template` is asked for a missing id. + + Inherits from ``FileNotFoundError`` so callers that just want + "treat as missing file" can keep their existing handlers; the + distinct subclass name surfaces in tracebacks where the difference + matters (template-id typo vs unrelated I/O failure). + """ + + +def _resolve(template_id: str) -> Path: + """Map ``"master/task_vote"`` → ``/master/task_vote.md``. + + Refuses absolute paths and ``..`` components defensively — template + ids must stay inside the templates root. + """ + if not template_id: + msg = "template id must be non-empty" + raise ValueError(msg) + parts = template_id.split("/") + for part in parts: + if not part or part in (".", ".."): + msg = f"illegal template id segment in {template_id!r}" + raise ValueError(msg) + return TEMPLATES_ROOT.joinpath(*parts).with_suffix(".md") + + +@cache +def load_template(template_id: str) -> str: + """Read a template file by id. + + The id is the path under :data:`TEMPLATES_ROOT` without the ``.md`` + extension, using forward slashes (``"npc/decision_vote_user"``). + + LRU-cached unbounded — templates are tiny and bounded in count, so + one cache miss per id per process is fine. Tests that need a fresh + read should call :func:`load_template.cache_clear`. + """ + path = _resolve(template_id) + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + msg = ( + f"template not found: {template_id!r} (resolved to {path})" + ) + raise TemplateNotFoundError(msg) from exc + + +__all__ = ["TEMPLATES_ROOT", "TemplateNotFoundError", "load_template"] diff --git a/src/wolfbot/llm/template/parser.py b/src/wolfbot/llm/template/parser.py new file mode 100644 index 0000000..8d87f49 --- /dev/null +++ b/src/wolfbot/llm/template/parser.py @@ -0,0 +1,141 @@ +"""Render a markdown template by substituting placeholders. + +Syntax: + +* ``{{name}}`` — simple substitution. ``name`` is a snake-case + identifier that must exist in the supplied context. Missing keys + raise :class:`TemplateRenderError` immediately (fail-fast); rendering + to an empty string would silently corrupt the prompt. +* ``{{#if name}}...{{/if}}`` — conditional block. The body is included + iff ``context[name]`` is truthy (``bool(value)``). Nesting is + supported. Unknown ``name`` is treated as falsy (block omitted) — + this matches how callers typically phrase optional sections without + needing a separate "is the variable defined" check. + +Deliberately not supported: +* loops (``{{#each}}``) — rendered lists belong in Python where the + separator / formatting can be tested per item. +* filters / auto-escape — prompt text is plain markdown bound for an + LLM, not HTML. +* nested attribute access (``{{persona.name}}``) — flatten the context + dict at the call site instead. + +If the syntax above is ever insufficient, escalate to Jinja2 — don't +extend this module ad-hoc. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any + +from wolfbot.llm.template.loader import load_template + +_PLACEHOLDER_RE = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}") +# Tempered-greedy body: match anything that does NOT start a nested +# ``{{#if`` opener, so a single regex pass picks up the *innermost* if +# blocks first. The caller loop expands repeatedly until no openers +# remain — by that point each outer block has had its inner blocks +# replaced, so the now-flat outer becomes innermost on the next pass. +# Without this, ``S{{#if A}}-{{#if B}}I{{/if}}-{{/if}}E`` would match +# ``{{#if A}}-{{#if B}}I{{/if}}`` greedily on the first pass and drop +# the trailing ``-{{/if}}E`` outside the regex. +_IF_BLOCK_RE = re.compile( + r"\{\{#if\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}" + r"(?P(?:(?!\{\{#if\b).)*?)" + r"\{\{/if\}\}", + re.DOTALL, +) + + +class TemplateRenderError(KeyError): + """A required ``{{var}}`` had no entry in the render context. + + Inherits from ``KeyError`` so callers that intentionally want to + catch "missing variable" can use the broader builtin; the distinct + subclass name carries the template id for log triage. + """ + + def __init__(self, variable: str, *, template_id: str | None = None) -> None: + suffix = f" (template {template_id!r})" if template_id else "" + super().__init__( + f"missing required template variable {variable!r}{suffix}" + ) + self.variable = variable + self.template_id = template_id + + +def _expand_if_blocks(text: str, context: Mapping[str, Any]) -> str: + """Resolve ``{{#if var}}...{{/if}}`` repeatedly until none remain. + + Inner-first expansion supports nesting because :data:`_IF_BLOCK_RE` + is non-greedy on ``body`` and the loop walks until the regex finds + no more matches. Without the loop, an outer block whose body + contains an inner ``{{/if}}`` would be matched as one greedy span. + """ + while True: + match = _IF_BLOCK_RE.search(text) + if match is None: + return text + var = match.group(1) + body = match.group("body") + keep = bool(context.get(var)) + replacement = body if keep else "" + text = text[: match.start()] + replacement + text[match.end() :] + + +def _expand_placeholders( + text: str, + context: Mapping[str, Any], + *, + template_id: str | None, +) -> str: + """Replace every ``{{var}}`` with ``str(context[var])``. + + Missing keys raise :class:`TemplateRenderError` immediately so a + typo never silently produces a half-rendered prompt. + """ + + def _sub(match: re.Match[str]) -> str: + name = match.group(1) + if name not in context: + raise TemplateRenderError(name, template_id=template_id) + return str(context[name]) + + return _PLACEHOLDER_RE.sub(_sub, text) + + +def render( + template_text: str, + /, + *, + template_id: str | None = None, + **context: Any, +) -> str: + """Render a raw template string against ``**context``. + + Order: ``{{#if}}`` blocks first, then ``{{var}}`` substitution. + Reverse order would force callers to escape placeholders inside an + ``{{#if}}`` body that should also reference variables. + + ``template_id`` is purely for error messages — pass it when the + text was loaded from disk so :class:`TemplateRenderError` includes + the id in its message. + """ + expanded = _expand_if_blocks(template_text, context) + return _expand_placeholders(expanded, context, template_id=template_id) + + +def render_template(template_id: str, /, **context: Any) -> str: + """Convenience: load a template by id and render it in one call.""" + return render( + load_template(template_id), template_id=template_id, **context + ) + + +__all__ = [ + "TemplateRenderError", + "render", + "render_template", +] diff --git a/tests/test_template_engine.py b/tests/test_template_engine.py new file mode 100644 index 0000000..073ea37 --- /dev/null +++ b/tests/test_template_engine.py @@ -0,0 +1,181 @@ +"""Unit tests for the markdown prompt template engine. + +Covers two-piece API: +* :func:`load_template` (loader): id → file path resolution, missing-id + error, and that the cache returns the same string for repeated reads. +* :func:`render` (parser): placeholder substitution, nested ``{{#if}}`` + conditional blocks, fail-fast on missing variables. +* :func:`render_template`: the convenience load+render integration. + +The parser tests deliberately operate on raw strings (no I/O) so a FS +mock isn't needed; only the loader-level tests touch tmp_path. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from wolfbot.llm import template as tmpl +from wolfbot.llm.template import ( + TemplateNotFoundError, + TemplateRenderError, + load_template, + render, + render_template, +) +from wolfbot.llm.template import loader as loader_module + +# -------------------------------------------------------- parser tests + + +def test_render_simple_placeholder() -> None: + out = render("Hello, {{name}}!", name="Sakura") + assert out == "Hello, Sakura!" + + +def test_render_multiple_placeholders_and_repeats() -> None: + out = render( + "{{role}} は {{day}} 日目に発言。{{role}} は再度言及。", + role="占い師", + day=2, + ) + assert out == "占い師 は 2 日目に発言。占い師 は再度言及。" + + +def test_render_missing_variable_raises_with_var_name() -> None: + with pytest.raises(TemplateRenderError) as exc_info: + render("Hi {{missing}}!", existing="ok") + assert exc_info.value.variable == "missing" + assert "missing" in str(exc_info.value) + + +def test_render_template_id_surfaces_in_error_message() -> None: + with pytest.raises(TemplateRenderError) as exc_info: + render( + "Hi {{missing}}!", + template_id="master/example", + existing="ok", + ) + assert "master/example" in str(exc_info.value) + assert exc_info.value.template_id == "master/example" + + +def test_render_if_block_truthy_keeps_body() -> None: + out = render( + "頭 {{#if extra}}追加: {{detail}}{{/if}} 末尾", + extra=True, + detail="ABC", + ) + assert out == "頭 追加: ABC 末尾" + + +def test_render_if_block_falsy_omits_body() -> None: + out = render( + "頭 {{#if extra}}非表示{{/if}} 末尾", + extra=False, + ) + assert out == "頭 末尾" + + +def test_render_if_block_unknown_var_treated_as_falsy() -> None: + """A missing context key for an `{{#if}}` block is treated as + falsy rather than raising — this matches the documented behaviour + so callers can phrase optional sections without first checking + that the variable is even defined.""" + out = render( + "前 {{#if maybe}}HIDE{{/if}} 後", + ) + assert out == "前 後" + + +def test_render_nested_if_blocks() -> None: + text = ( + "S{{#if outer}}-O[{{#if inner}}I{{/if}}]-{{/if}}E" + ) + assert render(text, outer=True, inner=True) == "S-O[I]-E" + assert render(text, outer=True, inner=False) == "S-O[]-E" + # Outer falsy → body (including the inner block) is dropped wholesale. + assert render(text, outer=False, inner=True) == "SE" + + +def test_render_if_body_can_reference_placeholder() -> None: + out = render( + "{{#if active}}seat={{seat}}{{/if}}", + active=True, + seat=3, + ) + assert out == "seat=3" + + +def test_render_substitutes_non_string_via_str_cast() -> None: + out = render("count={{n}}", n=42) + assert out == "count=42" + + +# -------------------------------------------------------- loader tests + + +@pytest.fixture +def temp_templates_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Point the loader at a clean tmp dir for the duration of the test.""" + root = tmp_path / "templates" + (root / "master").mkdir(parents=True) + (root / "master" / "hello.md").write_text( + "Hi, {{name}}!", encoding="utf-8" + ) + monkeypatch.setattr(loader_module, "TEMPLATES_ROOT", root) + monkeypatch.setattr(tmpl, "TEMPLATES_ROOT", root, raising=False) + # Cache is module-global; clear so the new root is honoured. + load_template.cache_clear() + yield root + load_template.cache_clear() + + +def test_load_template_reads_file_under_root(temp_templates_root: Path) -> None: + body = load_template("master/hello") + assert body == "Hi, {{name}}!" + + +def test_load_template_caches_repeat_reads(temp_templates_root: Path) -> None: + """Two reads of the same id should return identical strings without + a second filesystem hit. Probe by mutating the file after the first + read — the cached value must NOT pick up the change.""" + first = load_template("master/hello") + (temp_templates_root / "master" / "hello.md").write_text( + "Different!", encoding="utf-8" + ) + second = load_template("master/hello") + assert first == second == "Hi, {{name}}!" + + +def test_load_template_missing_id_raises(temp_templates_root: Path) -> None: + with pytest.raises(TemplateNotFoundError): + load_template("master/does_not_exist") + + +def test_load_template_rejects_traversal(temp_templates_root: Path) -> None: + """`..` segments must be refused so a typo can't escape the + templates root.""" + with pytest.raises(ValueError, match="illegal template id segment"): + load_template("master/../escape") + + +def test_load_template_rejects_empty_id(temp_templates_root: Path) -> None: + with pytest.raises(ValueError, match="non-empty"): + load_template("") + + +def test_render_template_loads_and_substitutes(temp_templates_root: Path) -> None: + out = render_template("master/hello", name="セツ") + assert out == "Hi, セツ!" + + +def test_render_template_propagates_missing_variable( + temp_templates_root: Path, +) -> None: + with pytest.raises(TemplateRenderError) as exc_info: + render_template("master/hello") # no `name` supplied + assert exc_info.value.variable == "name" + assert exc_info.value.template_id == "master/hello" From e1b6f2490ed8f0e04c15c66f0d957d825829d87f Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 10:51:04 +0900 Subject: [PATCH 097/133] refactor(prompts): extract DeepSeek contracts + 9p game rules to .md templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First batch of inline-prompt → markdown extraction. Wires three of the easiest blocks through the new template engine (commit a9f2c7e): * shared/game_rules_9p.md (~110 bullets) — the canonical 9-player ruleset. Builder fills {{village_size}} + {{distribution}} from ROLE_DISTRIBUTION/ROLE_JA so the seat counts stay derived. Legacy inline form is preserved as _build_game_rules_block_legacy_inline and asserted byte-equal to the rendered template via a parity test, so a one-sided edit fails loudly. * master/deepseek_contract_gameplay.md — JSON contract appended to the gameplay-LLM system prompt under DeepSeek json_object mode (LLMAction schema). * npc/deepseek_contract_speech.md — JSON contract appended to the NPC speech-LLM system prompt under DeepSeek json_object mode (NpcGeneratedSpeech schema). Builders now call render_template(...) instead of carrying the literal block as a Python triple-quoted string. No prompt content changes — verified via 1238 tests + ruff + mypy all green. --- src/wolfbot/llm/prompt_builder.py | 45 ++++++- .../npc/openai_compatible_generator.py | 33 ++--- .../master/deepseek_contract_gameplay.md | 15 +++ .../templates/npc/deepseek_contract_speech.md | 16 +++ .../prompts/templates/shared/game_rules_9p.md | 113 ++++++++++++++++++ src/wolfbot/services/llm_service.py | 28 ++--- tests/test_llm_prompt_builder.py | 11 ++ 7 files changed, 212 insertions(+), 49 deletions(-) create mode 100644 src/wolfbot/prompts/templates/master/deepseek_contract_gameplay.md create mode 100644 src/wolfbot/prompts/templates/npc/deepseek_contract_speech.md create mode 100644 src/wolfbot/prompts/templates/shared/game_rules_9p.md diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index d5cfa99..d6e801c 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -21,6 +21,7 @@ ) from wolfbot.domain.models import Game, Player, Seat from wolfbot.llm.persona_base import Persona +from wolfbot.llm.template import render_template SYSTEM_TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "prompts" / "llm_system_prompt.md" @@ -29,15 +30,47 @@ def _load_template() -> str: return SYSTEM_TEMPLATE_PATH.read_text(encoding="utf-8") +_GAME_RULES_TEMPLATE = "shared/game_rules_9p" + + def _build_game_rules_block() -> str: """Return the fixed 9-player ruleset shared by every LLM seat. - Includes role distribution (derived from ROLE_DISTRIBUTION + ROLE_JA so we - don't duplicate the canonical numbers), win conditions matching - `rules.check_victory`, and the invariants the LLM must never violate - (NIGHT_0 random white is non-wolf, seer/medium see only real wolves as - black, wolves split → attack fails, knight can't guard the same target - twice, `target_name` must match a candidate token). + Body lives in `prompts/templates/shared/game_rules_9p.md` so the + canonical ~110-bullet ruleset is editable in plain markdown without + a Python diff. Two placeholders are filled here: ``village_size`` + (= ``VILLAGE_SIZE`` constant) and ``distribution`` (rendered from + ``ROLE_DISTRIBUTION`` + ``ROLE_JA`` so the canonical seat counts + stay derived, not duplicated). Win conditions and the invariants + the LLM must never violate (NIGHT_0 random white is non-wolf, + seer/medium see only real wolves as black, wolves split → attack + fails, knight can't guard the same target twice, `target_name` + must match a candidate token, etc.) are encoded as fixed text + inside the template. + + The legacy concatenated-string form below is kept as the canonical + Japanese reference for the template; updating one without the + other will fail the round-trip test in + ``test_template_engine_integration``. + """ + distribution = " / ".join( + f"{ROLE_JA[role]}{count}" for role, count in ROLE_DISTRIBUTION.items() + ) + return render_template( + _GAME_RULES_TEMPLATE, + village_size=VILLAGE_SIZE, + distribution=distribution, + ) + + +def _build_game_rules_block_legacy_inline() -> str: + """Original inline form retained for the round-trip parity test. + + Do NOT call from production code paths — the live code uses + :func:`_build_game_rules_block` which loads the markdown template. + This function exists so a unit test can compare the inline string + against the rendered template and fail loudly the moment they + drift apart, catching accidental edits to only one side. """ distribution = " / ".join( f"{ROLE_JA[role]}{count}" for role, count in ROLE_DISTRIBUTION.items() diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index 42c5fbe..86da759 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -46,6 +46,7 @@ build_judgment_profile_block, build_speech_profile_block, ) +from wolfbot.llm.template import render_template from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY from wolfbot.npc.speech_service import NpcGeneratedSpeech @@ -501,27 +502,12 @@ def _format_candidate(c: LogicCandidate) -> str: # DeepSeek does not support strict json_schema; it only supports # json_object. To make the model emit the right field names without -# walking the full schema in the system prompt, we append this per-call -# contract that mirrors the keys in ``_RESPONSE_SCHEMA``. Module-level -# so tests can assert on substrings without instantiating AsyncOpenAI. -_DEEPSEEK_JSON_CONTRACT_SUFFIX = """\ - ---- -出力形式 (json): -必ず次のキーを持つ JSON オブジェクトのみを返してください。前後にテキストや markdown コードフェンスを付けないでください。 -- "text": string (発話本体、最大 300 文字) -- "intent": "speak" | "agree" | "disagree" | "question" | "accuse" | "defend" | "skip" -- "used_logic_ids": string の配列 (空配列でもよい) -- "co_declaration": "seer" | "medium" | "knight" | null -- "addressed_seat_nos": integer の配列 (向ける席番号たち。1人なら [3]、複数なら [2, 3]、誰宛でもない一般発言は []) -- "claimed_seer_result": object | null (今回の発話で新しく占い結果を発表する場合のみ非 null。形式は {"target_seat": integer(1-9), "is_wolf": boolean}。本物でも偽でも同じ形式で出す。発表しないなら null) -- "claimed_medium_result": object | null (今回の発話で新しく霊媒結果を発表する場合のみ非 null。形式は {"target_seat": integer(1-9), "is_wolf": boolean | null}。is_wolf=null は「昨日処刑なし」) - -例: -{"text": "私もそこは引っかかってた。", "intent": "agree", "used_logic_ids": [], "co_declaration": null, "addressed_seat_nos": [], "claimed_seer_result": null, "claimed_medium_result": null} -{"text": "実は私、占い師なんだ。昨夜セツを占ったら人狼じゃなかったよ。", "intent": "speak", "used_logic_ids": [], "co_declaration": "seer", "addressed_seat_nos": [], "claimed_seer_result": {"target_seat": 6, "is_wolf": false}, "claimed_medium_result": null} -{"text": "霊媒結果を伝える。昨日処刑されたジョナスは人狼だった。", "intent": "speak", "used_logic_ids": [], "co_declaration": "medium", "addressed_seat_nos": [], "claimed_seer_result": null, "claimed_medium_result": {"target_seat": 2, "is_wolf": true}} -""" +# walking the full schema in the system prompt, we append a per-call +# contract that mirrors the keys in ``_RESPONSE_SCHEMA``. Body lives +# in `prompts/templates/npc/deepseek_contract_speech.md` so it can be +# tweaked without a Python diff. Module-level constant so tests can +# assert on substrings without instantiating AsyncOpenAI. +_DEEPSEEK_NPC_SPEECH_CONTRACT_TEMPLATE = "npc/deepseek_contract_speech" @dataclass @@ -543,7 +529,8 @@ class OpenAICompatibleConfig: # ``json_schema`` (default) sends ``response_format={"type":"json_schema", # "json_schema": _RESPONSE_SCHEMA}`` for strict structured output (xAI, # OpenAI). ``json_object`` falls back to ``{"type":"json_object"}`` and - # appends ``_DEEPSEEK_JSON_CONTRACT_SUFFIX`` to the system prompt. + # appends the rendered ``npc/deepseek_contract_speech`` template to + # the system prompt. mode: Literal["json_schema", "json_object"] = "json_schema" # DeepSeek-only knobs. Forwarded via ``extra_body`` only when # ``mode == "json_object"``. @@ -615,7 +602,7 @@ async def generate( role_strategy=request.role_strategy, ) if self.config.mode == "json_object": - system += _DEEPSEEK_JSON_CONTRACT_SUFFIX + system += render_template(_DEEPSEEK_NPC_SPEECH_CONTRACT_TEMPLATE) user = _build_user(logic, request, state) client = AsyncOpenAI( diff --git a/src/wolfbot/prompts/templates/master/deepseek_contract_gameplay.md b/src/wolfbot/prompts/templates/master/deepseek_contract_gameplay.md new file mode 100644 index 0000000..4929081 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/deepseek_contract_gameplay.md @@ -0,0 +1,15 @@ + +--- +出力形式 (json): +必ず次のキーを持つ JSON オブジェクトのみを返してください。前後にテキストや markdown コードフェンスを付けないでください。 +- "intent": "speak" | "vote" | "night_action" | "skip" +- "public_message": string (最大 400 文字) +- "target_name": string または null +- "reason_summary": string (最大 200 文字) +- "confidence": number (0 から 1) +- "co_declaration": "seer" | "medium" | "knight" | null +- "claimed_seer_result": object | null (今回新しく占い結果を発表する場合のみ非 null。形式 {"target_seat": integer(1-9), "is_wolf": boolean}。本物でも騙りでも同じ形式) +- "claimed_medium_result": object | null (霊媒も同様。形式 {"target_seat": integer(1-9), "is_wolf": boolean | null}。is_wolf=null は「昨日処刑なし」) + +例: +{"intent": "speak", "public_message": "私は占い師です。昨夜セツを占ったら人狼じゃなかった。", "target_name": null, "reason_summary": "CO + 結果発表", "confidence": 0.7, "co_declaration": "seer", "claimed_seer_result": {"target_seat": 6, "is_wolf": false}, "claimed_medium_result": null} diff --git a/src/wolfbot/prompts/templates/npc/deepseek_contract_speech.md b/src/wolfbot/prompts/templates/npc/deepseek_contract_speech.md new file mode 100644 index 0000000..14eccb2 --- /dev/null +++ b/src/wolfbot/prompts/templates/npc/deepseek_contract_speech.md @@ -0,0 +1,16 @@ + +--- +出力形式 (json): +必ず次のキーを持つ JSON オブジェクトのみを返してください。前後にテキストや markdown コードフェンスを付けないでください。 +- "text": string (発話本体、最大 300 文字) +- "intent": "speak" | "agree" | "disagree" | "question" | "accuse" | "defend" | "skip" +- "used_logic_ids": string の配列 (空配列でもよい) +- "co_declaration": "seer" | "medium" | "knight" | null +- "addressed_seat_nos": integer の配列 (向ける席番号たち。1人なら [3]、複数なら [2, 3]、誰宛でもない一般発言は []) +- "claimed_seer_result": object | null (今回の発話で新しく占い結果を発表する場合のみ非 null。形式は {"target_seat": integer(1-9), "is_wolf": boolean}。本物でも偽でも同じ形式で出す。発表しないなら null) +- "claimed_medium_result": object | null (今回の発話で新しく霊媒結果を発表する場合のみ非 null。形式は {"target_seat": integer(1-9), "is_wolf": boolean | null}。is_wolf=null は「昨日処刑なし」) + +例: +{"text": "私もそこは引っかかってた。", "intent": "agree", "used_logic_ids": [], "co_declaration": null, "addressed_seat_nos": [], "claimed_seer_result": null, "claimed_medium_result": null} +{"text": "実は私、占い師なんだ。昨夜セツを占ったら人狼じゃなかったよ。", "intent": "speak", "used_logic_ids": [], "co_declaration": "seer", "addressed_seat_nos": [], "claimed_seer_result": {"target_seat": 6, "is_wolf": false}, "claimed_medium_result": null} +{"text": "霊媒結果を伝える。昨日処刑されたジョナスは人狼だった。", "intent": "speak", "used_logic_ids": [], "co_declaration": "medium", "addressed_seat_nos": [], "claimed_seer_result": null, "claimed_medium_result": {"target_seat": 2, "is_wolf": true}} diff --git a/src/wolfbot/prompts/templates/shared/game_rules_9p.md b/src/wolfbot/prompts/templates/shared/game_rules_9p.md new file mode 100644 index 0000000..47c1b46 --- /dev/null +++ b/src/wolfbot/prompts/templates/shared/game_rules_9p.md @@ -0,0 +1,113 @@ +- この村は {{village_size}} 人村固定。プレイヤーは {{village_size}} 名。 +- 初期配役は {{distribution}} (合計 {{village_size}} 名) で固定。途中で配役は変わらない。 +- 陣営: 人狼・狂人は人狼陣営、占い師・霊媒師・騎士・村人は村人陣営。 +- 村人陣営勝利: 生存人狼数が 0 になった時点。 +- 人狼陣営勝利: 生存人狼数が生存非人狼人数以上になった時点 (狂人はこの計算で非人狼として数えるが、勝敗判定は人狼陣営の勝利)。 +- 昼の発言は、公開ログと自分が知る私的情報だけを根拠にする。他プレイヤーの役職・夜行動・占い/霊媒判定・人狼同士の仲間関係など、自分に公開されていない情報を事実として断言してはならない。 +- 占い師と霊媒師の判定は、本物の人狼だけを黒と表示する。狂人は黒判定されない (白として扱われる)。 +- 占い/霊媒の判定色は黒 (本物の人狼)/白 (本物の人狼ではない) の 2 値のみ。灰・グレー・不明・保留・確定不能など第 3 の色はこの bot のルール上存在せず、いかなる役職 (真/騙りを含む) も第 3 の色を判定として主張してはならない。CO 者が第 3 の色を判定として出した時点で、その CO は破綻として確定扱いし、聞き手側は他のすべての判定材料に優先してその CO を切る根拠にしてよい。 +- 霊媒結果の白 (『人狼ではありませんでした』) は、対象が本物の人狼ではないことだけを示す。役職名 (占い師・霊媒師・騎士・村人・狂人) までは特定できない。 +- 処刑された占い師 CO に霊媒結果で白が出ても、真占い師だった可能性と矛盾しない。霊媒白だけを理由にその占い師 CO を偽扱いしない。偽視するなら、対抗 CO、占い結果の破綻、発言時系列、投票、襲撃結果、死亡タイミングとの整合性で判断する。 +- 逆に処刑された占い師 CO に霊媒結果で黒が出た場合は、その人物は本物の人狼なので、真占い師ではなく人狼の騙りだったと強く判断してよい。 +- NIGHT_0 に占い師へ提示されるランダム白は、本物の人狼ではない相手が選ばれる。ただし真に村であることは保証されない (狂人の可能性はある)。 +- **NIGHT_0 (= 初日夜・ゲーム開始直後の最初の夜) には人狼の襲撃も騎士の護衛も発生しない。**発生する夜行動は占い師の初回ランダム白だけで、それ以外の役職は何も行動しない。そのため day 1 (1日目) の朝は構造的に必ず『平和な朝』になり、『平和な朝』が **守られたことの根拠にも襲撃失敗の根拠にも一切ならない**。day 1 朝の平和は『誰かが護衛した』『騎士のGJ』『襲撃が失敗した』のいずれの解釈とも結びつかない。そのような解釈・推理を発話に出すと **構造ルール違反として破綻**として扱われる。 +- **day 1 朝の段階で占い師 (真/騙りを問わず) が公的に提示できる占い結果は NIGHT_0 のランダム白 1 件のみ。**NIGHT_1 はまだ発生していないため、day 1 朝に語る『昨夜』は NIGHT_0 を指し、結果は必ず白で対象も 1 人だけ。day 1 朝に 2 件目以降の占い結果を主張する (例: 同じ朝に『コメットを占って白だった』と言ってから別ターンで『ジナを占って白だった』と続ける) のは時系列上不可能で、即座に偽占いとして切る根拠になる。同様に **day 1 朝の霊媒結果は構造的に存在しない** (前日の処刑がまだ無いため)。day 1 朝に霊媒結果を語った時点で偽霊媒確定として扱う。 +- **自分が公の場で主張した占い結果・霊媒結果・護衛履歴は、後のターンで対象や色を変えてはならない。**同じ夜について『Alice を占った白』と言った後で別ターンで『Bob を占った』と言い直したり、過去に白と言った対象を後から黒に塗り替えたりするのは、本物の役職者には絶対に起き得ない構造的矛盾である。聞き手側はそのような対象差し替え・色反転を観測した時点で、その CO を即座に偽として切る根拠にしてよい。日が進んで新しい夜行動の結果を公表する (例: day 2 の朝に day 1 夜の占い結果を新規発表) のは可能だが、それは『過去に発表済みの結果に新しい行を追加する』操作であり、過去の行を書き換える操作ではない。 +- 人狼は day 1 の夜 (= NIGHT_1) から襲撃を行い、騎士も day 1 の夜から護衛できるようになる。GJ (グッジョブ) や護衛成功・襲撃失敗の議論が初めて意味を持つのは day 2 の朝以降である。day 1 朝の時点ではまだ夜行動の結果は何も発生していないが、**人狼 2 名は最初から村に存在している** (NIGHT_0 で襲撃しないだけで、潜伏中)。『初日朝に死んでいないから人狼はまだ動いていない / 人狼疑いの根拠が無い』というのも誤りで、人狼は day 1 の議論・投票で吊り逃れを狙って発言・誘導している前提で疑い始めてよい。 +- 人狼同士で夜の襲撃対象の意見が割れた場合、Master 側で最終的に必ず 1 つの対象が確定する: (a) 片方が人間プレイヤーで片方が LLM 席のときは人間プレイヤーの選択がそのまま採用される、(b) 双方 LLM の不一致では Master が 2 つの選択肢からランダムに 1 つを採用して襲撃を成立させる、(c) 双方人間で不一致のときも Master がランダムに 1 つを採用する。つまり人狼同士で襲撃先が割れても空振りにはならず、どちらか片方の選択が必ず実行される。意図的に割って撹乱を狙っても、もう片方の人狼の襲撃先が選ばれる確率も同じだけあるため得にならない。狙いを揃えた方が情報役を確実に噛める利点が大きいので、人狼専用チャットで襲撃先を 1 人に揃えることを最優先にする。 +- **夜の襲撃で死亡した席は本物の人狼ではない (人狼は仲間を襲わないため、公開ログで `(襲撃)` 表示の死亡席は構造的に非狼確定)。**誰かが公の場で『襲撃された席は人狼だった』『襲撃で沈んだ者こそ狼だった』のように主張した場合、それは構造ルールに反する明白な嘘であり、その発言者を強い人狼候補 (騙りや狂人を含む狼陣営位置) として扱ってよい。また聞き手側がこの嘘に同調・追認する発話 (『なるほど襲撃された◯◯は狼だったんだね』等) も村陣営にとっては破綻発言で、その発言者の信用も落ちる。霊媒結果より優先される HARD ファクトとして扱う。 +- 騎士は同じ相手を連続で護衛できない (前夜と同じ対象は選べない)。 +- **同一役職の CO 数には構造的な上限がある。**占い師は 1 (真) + 2 (人狼が騙れる) + 1 (狂人が騙れる) で **理論最大 4** だが、**実戦では公開ログ上の占い CO が通算 3 件に達した時点で、4 件目を出すのは戦略的に破綻**する (村陣営の村人 1 名が騙りに出る合理的理由がなく、4 件目 CO は強い狼/狂人疑いを浴びるため)。霊媒師は 1 (真) + 1 (人狼か狂人の騙り) で **理論最大 2**、3 件目以降は出した時点で偽確定。騎士は 1 (真) + 1 (人狼/狂人の騙り) で **理論最大 2**、3 件目以降は偽確定。聞き手側はこの上限を超えた CO を観測した時点で、その CO 者を強い狼陣営疑いとして扱う。 +- 投票先や夜行動対象は、プロンプトで提示された合法な候補トークン (例: `席3 Alice`) の中からだけ選ぶ。候補外の名前を返してはならない。 +- 特定役職 (占い師・霊媒師・騎士) の CO が 1 人だけで、同じ役職への対抗 CO が公開ログ上一度も出ていない場合、その単独 CO 者は原則として真の役職者にかなり近い位置として扱う。根拠なくその CO 者を処刑候補にしない。 +- **特に初日朝 (まだ誰も死亡していない時点) の単独 CO は、真として扱う。**初日朝はまだ霊媒結果も襲撃結果もなく、対抗 CO が出る時間も十分に残っているため、単独で CO した者を疑う公開情報は実質まだ存在しない。それでも疑う発話・投票を行うと『初日に CO したから怪しい』という根拠にならない疑い方になり、村陣営にとって最大の損失になる。対抗 CO がその後も出ず、判定矛盾・票筋・襲撃結果といった具体的破綻もない限り、初日の単独 CO 者は真置きで進行する。 +- ただし単独 CO は絶対確定ではない。公開ログ上の発言破綻・投票矛盾・判定結果の矛盾・噛み筋との不整合など、通常より強い根拠がある場合に限り疑ってよい。 +- **単独 CO 者を疑う『強い根拠』には、具体的な公開情報の矛盾が必要である。**他のプレイヤー (人間プレイヤー含む) が『〜は怪しい』『〜は人狼』と単に表明しただけ、占い結果や霊媒結果の具体的な指摘・投票や噛み筋の矛盾の指摘がない直感的疑い表明は、単独 CO 真置きルールを上書きする根拠としては弱い。そうした表明に同調して単独 CO 者へ票を入れる前に、自分視点で『単独 CO 者を切る具体的な公開情報』が1 つでも挙げられるかを確認する。挙げられないなら同調しない。特に真役職を持つ自分は、公開情報と矛盾しない単独 CO を切ると村の情報を失う側に動くことになる。 +- 人間プレイヤーの直感的な疑い表明は、議論を再評価するきっかけとして参照してよいが、それ自体を独立した証拠として扱わない。人間も狼/狂人として騙る可能性があるし、村役でも誤推理するため、根拠の中身 (具体的な公開情報の指摘) で重み付けする。 +- ただし「現在生存している CO 者が 1 人だけ」というだけでは単独 CO 扱いしない。同じ役職 CO が過去に 2 人以上存在したことがある場合、対抗者が処刑・襲撃などで死亡して現在 1 人だけ残っていても、その残存 CO 者を自動的に真置きしない。 +- **CO 通算件数は『生存・死亡を問わず過去に CO した全 seat の数』で数える。**プロンプトの `## 公開された占い/霊媒CO結果` ブロックには死亡席の CO も列挙されており、そのブロックの『通算 N 件』が公式の件数。「現在生存している占い CO は 1 人」と「占い CO は単独 (= 通算 1 件)」は別の概念で、後者は通算ベースで判断する。「占い師は◯◯さんだけ」「単独のCO」「単独で出ているから真」と判断する **前に必ず** ledger ブロックの通算件数を確認すること。通算 2 件以上なら、たとえ生存者が 1 人でも『単独 CO』とは呼ばない。死亡した CO 者の主張は記録に残り、推理材料として依然として有効である。 +- 対抗 CO が出た場合は、死亡済み CO 者も推理対象として保持し、判定結果・発言の時系列・投票・襲撃結果・死亡タイミングとの整合性で真偽を比較し、どちらをより真寄りとするか判断する。 +- 「最後まで生き残った CO 者」は真とは限らない。狼が情報役を噛まずに残した、対抗を吊らせて信用を取った、囲いに使う狙いで残した、といった可能性も平行して見る。対抗 CO 履歴がある役職で残存 CO が 1 人になった時点で「単独 CO だから真」と短絡しない。 +- 「占いCOが出たら」「霊媒COについて」「占いCOしている人をどう見るか」など、CO 語彙が発言中に登場するだけでは、その発言者自身の CO ではない。CO 語彙の話題化と本人による名乗りを区別する。 +- CO として扱うのは、本人が「私は占い師です」「占い師COします」「霊媒師として出ます」のように自分の役職として明確に宣言した場合だけである。仮定・話題提示・他者への言及はどれも CO ではない。 +- 疑わしい場合は、公開ログの前後関係、主語、引用や仮定の語尾 (〜なら / 〜について / 〜どう見る)、自分自身の宣言か他者への言及かを確認する。判断に迷うときは CO として数えない。 +- 死亡した席は、過去の発言・投票・判定の信用評価対象としては引き続き議論してよいが、今日の処刑対象 (vote target) としては議題に含めない。今日の vote 候補は生存席だけ。死者の信用議論を尽くすこと自体は構わないが、発言の結論を『死者を吊ろう』『今日◯◯ (死亡席) を処刑すべき』のように死者を今日の処刑対象として語るのは破綻発言として扱う。 +- 異端票 (例: 多数派と違う対象に day 1 で投票している席) を狼疑いとして数える前に、その投票者が真占い視点で動いていた場合に整合するかを必ず一度確認する。真占いは NIGHT_0 ランダム白で得た情報を持ち、自分視点の黒読み・灰読みで多数派と違う票を入れる動機が自然に発生する。後日その異端票の対象が黒判定されて処刑され、霊媒結果でも黒が出た場合、その異端票は『真占い視点での黒読み先制』として説明される側に強く寄るため、異端票そのものを狼の異常行動として扱うのは誤読になる。票の異端さだけで疑うのではなく、判定履歴・霊媒結果・噛み筋との整合性で再評価する。 +- 自分が役職持ちでまだ未公開の能力結果 (霊媒結果・占い結果・護衛日記など) を抱えている場合、発言の番が回ってきたとき、その発話の冒頭で必ず CO + 結果公表を行う。他者から呼びかけられて (addressed) いてその質問に答えたい場合でも、未公開の能力結果が手元にあるならその公表を最優先にし、addressed への返答は CO + 結果の後に短く添える。addressed 文脈に飲まれて CO + 結果を欠落させると、聞き手側 NPC は構造化フィールド (`co_declaration`) しか見られず公開ログには CO の自然言語が一切残らないため、他席は『自分が CO した』ことを認識できない。 +- 占い師・霊媒師・騎士の各 CO 数を時系列で整理し、各役職について『CO 数 - 1』(下限 0) を「対抗 CO 超過分」(騙り最低数) として数える。真役職は各 1 人だけのため、超過分はその役職で少なくとも騙りである。 +- 占い師 CO 超過分 + 霊媒師 CO 超過分 + 騎士 CO 超過分 の超過分合計が 3 に達した場合、人狼 2 + 狂人 1 の狼陣営 3 名が能力役職 CO 群に出切っている。村陣営の騙り・CO 撤回・同一人物の複数 CO・曖昧な CO 文言の誤読・死亡済み CO 見落とし・前提破綻がない限り、能力役職 CO していない位置は配役上の消去法で村陣営の確白級として扱う。同条件下で対抗のない単独 CO 役職が別にある場合、その単独 CO 者も狼陣営ではないため真役職としてかなり強く扱える。 +- この超過分合計 3 による非 CO 確白は、単発の白判定 (狂人も白に出るため『村陣営確定』とは言い切らない) とは別根拠で、固定配役上の消去法で狼陣営 3 名が CO 群に出切ったと数えられる点で村陣営まで強く推せる。ただし、対抗 CO 群の中で誰が真役職かまでは超過分合計だけでは特定できない。判定結果・霊媒結果・投票・襲撃・死亡タイミング・破綻で詰める。 +- 超過分合計が 0〜2 の段階では、狼陣営が非 CO や単独 CO に残っている可能性があるため、非 CO 位置を CO 数だけで確白とは断定しない。 +- 超過分合計が 4 以上に見える場合は固定配役と矛盾する。CO 撤回、同一人物の複数 CO、話題としての CO 語彙の誤読、村騙り、死亡済み CO 見落とし、ログ見落としを疑い、確白扱いを保留して時系列を再整理する。 +- 例: 3-2-1 (占い師 CO 3 / 霊媒師 CO 2 / 騎士 CO 1) → 超過分 2 + 1 + 0 = 3。前提崩壊がなければ非 CO 位置は村陣営の確白級、対抗のない単独騎士 CO も狼陣営ではないため真騎士寄り。 +- 例: 2-2-2 (占い師 CO 2 / 霊媒師 CO 2 / 騎士 CO 2) → 超過分 1 + 1 + 1 = 3。各 CO 群に 1 人ずつ真役職、残り 3 人が狼陣営という形が基本。非 CO 位置は確白級。各 CO 群内の真偽は判定結果・霊媒結果・投票・襲撃で詰める。 +- 例: 3-1-1 (占い師 CO 3 / 霊媒師 CO 1 / 騎士 CO 1) → 超過分 2 + 0 + 0 = 2。狼陣営 1 名が非 CO や単独 CO に残る可能性があるため、非 CO 全員を確白とは断定しない。 +- 例: 4-1-1 (占い師 CO 4 / 霊媒師 CO 1 / 騎士 CO 1) → 超過分 3 + 0 + 0 = 3。占い師 CO 群に狼陣営 3 名が固まっている可能性が高く、対抗のない単独霊媒・単独騎士は強い白寄り。CO 撤回や村騙りが出たら再整理する。 +- 占い師 CO が 3 人・霊媒師 CO が 1 人の盤面を『3-1』と呼ぶ。3-1 では占い 3 人のうち 2 人が騙りである可能性が高く、単独の霊媒師 CO は対抗がいない限り原則として真寄りの進行軸として扱い、初日は占い師 CO 側から処刑候補を検討するのが基本線になる。 +- 3-1 の基本進行は占いローラーまたは黒ストップの 2 択。占いローラーは、偽っぽい・狼っぽい・視点漏れしている占い師 CO から順に処刑し、処刑後の霊媒結果を占い結果・投票・襲撃結果の整合性と突き合わせて真偽を絞り込む。 +- 黒ストップとは、単独霊媒が占い師 CO の誰かに黒判定を出した時、残る占い師 CO をその場で処刑せず、灰 (役職 CO していない位置) の精査へ切り替える進行を指す。霊媒が真であれば処刑された占い師 CO は本物の人狼として確定しているので、占いローラー続行より灰の精査の方が有利になる局面が多いからである。 +- ただし黒ストップは絶対ではない。真狼狼 (2 人の狼が共に占い師 CO) の可能性、霊媒師 CO 側が偽だった可能性、残る占い師 CO の発言・投票・判定が破綻している場合、あるいはローラー続行しないと決選投票で PP (パワープレイ) を許す残り人数である場合は、黒ストップをやめて占いローラーを続行する判断があり得る。 +- 3-1 で占い師 CO が 3 人並んだ盤面では、CO 者のうち 2 人が公開情報上『本物の人狼ではない』と確定した場合、残る 1 人の占い師 CO を固定配役上の消去法として確定黒級の人狼位置と推定する。人狼 2 人固定の配役で『占い師 CO 3 人のうち 2 人が非狼確定』なら、残る占い師 CO 位置に少なくとも 1 人の人狼がいる以外に整合する配役がないためである。 +- ただし『白判定』と『非狼確定』を混同しない。信用が未確定な占い師 CO が出した白、偽が混じり得る霊媒結果、印象だけの白寄り評価は非狼確定として数えない。狂人も白判定されるため、単発の白だけで非狼扱いを固定しない。 +- 非狼確定として数えてよい根拠は、公開ログ・霊媒結果・襲撃死・真寄り情報役の判定・CO 破綻整理など、この bot のルールと公開情報の整合から説明できるものに限る。霊媒師 CO 側が真寄りと十分判断できる時点での霊媒白、本物の人狼ではないと示す襲撃死、対抗 CO や判定矛盾で偽が破綻した結果としての非狼整理などが具体的な根拠になる。 +- 2 人非狼確定が成立した場合は、残る占い師 CO を『まだ灰の 1 人』ではなく、固定配役上の狼位置として投票・発言・進行提案へ反映させる。黒ストップによる灰精査ではなく、残る占い師 CO の処刑提案や、その人物を相方候補ペア仮説の片側として扱う議論を優先してよい。 +- ただし前提が崩れた場合は確定黒扱いを解除して時系列から再整理する。村陣営の騙り (例: 狂人や村人が占い師 CO していた)、CO 撤回、霊媒師 CO 側が偽だった可能性が後から浮上した状況、非狼確定の根拠が後から破綻した場合などが該当する。前提が崩れたと分かった時点で、確定黒扱いをやめ、CO 履歴と判定履歴を時系列で再整理する。 +- 占い師 CO が 2 人・霊媒師 CO が 2 人の盤面を『2-2』と呼ぶ。2-2 では占い・霊媒のどちらも真が確定しておらず、霊媒ローラー (または霊媒切り) が基本進行軸となる。 +- 2-2 で霊媒師 CO が 2 人出ている場合、片方を根拠なく真置きせず、霊媒結果は偽が混ざっている可能性を常に織り込んで推理する。一度霊媒ローラーを開始したら原則として完走させ、途中で止めるには通常よりも強い根拠 (公開ログ上の破綻・襲撃・投票・占い結果との不整合) を要する。 +- 占い師 CO が 2 人・霊媒師 CO が 1 人の盤面を『2-1』と呼ぶ。2-1 では単独霊媒師を原則として真寄りの進行役候補とし、占い師 2 人の真偽比較とグレー精査を並行する。白進行ならグレー吊り/グレランが基本候補になりやすく、縄数・囲い候補・決め打ち日を意識する。占い黒が出ている場合は黒吊りで霊媒結果を見る選択肢が強いが、黒を出した占い師の信用と黒先の発言も必ず見る。 +- 占い師 CO が 1 人・霊媒師 CO が 2 人の盤面を『1-2』と呼ぶ。1-2 では占い師 CO は真寄りになりやすい一方、霊媒師は騙り混じりとして扱い、霊媒ローラーまたは霊媒切りを基本候補にする。ただし霊媒内訳が真狂寄りでグレー狼が濃いと判断できる場合だけ、グレー精査も比較する。どちらを選ぶかは縄数・占い結果・霊媒の破綻・投票で判断する。 +- 嘘をつける役職 (人狼・狂人) が偽 CO するときでも、偽の占い結果・霊媒結果・騎士日記はこの bot の実ルール上あり得る内容だけに留める。 +- 偽占い師は、占ったと主張する対象がその夜まで生存していたか、過去に自分が出した結果と矛盾しないかを確認する。 +- NIGHT_0 で占い師に提示されるランダム白は、占い師の初回占い結果として扱う。そのため day 1 の朝に占い師 CO する者 (真でも騙りでも) は、初回の占い結果として必ず白を主張する。 +- day 1 の朝に偽占い師として初回結果を黒と主張するのは、この bot の実ルール上の NIGHT_0 タイムラインと矛盾するため破綻要素として扱われる。day 1 で初回黒主張はしない。 +- 占い結果・霊媒結果を発言で出すときは、**対象席名 + 判定色 (黒/白) を必ず一対一で添える**。例: 『セツさんは白でした』『コメットを占って黒、ジナを占って白』のように、各対象を名指しで列挙する。**対象を特定せずに『すべて白』『全員白』『全部白』『みんな白』のように複数件をまとめて主張するのは破綻発言**として扱う。聞き手はそうした主張をした CO を破綻確定として切ってよい。 +- 特に day 1 朝の占い師 CO は、NIGHT_0 のランダム白 1 件しか手元にない。**day 1 の占い結果は必ず対象 1 名 + 白判定の 1 件のみ**で、複数対象の主張や『すべて白』のような対象不明な白主張は実ルール上ありえないため、その時点で CO 破綻として扱う。 +- 偽占い師の黒結果主張は day 2 以降にのみ行う。前夜に占ったという想定で、公開ログ・生存状況・過去に自分が出した判定履歴・対抗 CO の発表内容と矛盾しない場合だけ出す。 +- 偽霊媒師は、処刑がなかった日に霊媒結果を捏造しない。 +- 偽騎士は、自分護衛・同一対象連続護衛・死亡済み対象への護衛・存在しない護衛成功を主張しない。 +- どの偽 CO でも、実際には知らない他者役職や狼位置を事実として断言しない。 +- 自分が占い師/霊媒師として一度公表した判定 (対象 + 黒/白) は、原則として後日撤回・色変更・対象差し替えをしない。前夜の能力使用結果として一度言い切った内容は、その後の発言でも同じ対象を同じ色で扱う。打ち間違いに気づいた場合のみ、『訂正』『失礼、◯◯でした』などの訂正文言を明示してから直し、訂正後の内容を以降の発言でも保持する。 +- プロンプトの『## 公開された占い/霊媒CO結果 (公式記録)』ブロックは、Master が `claimed_seer_result` / `claimed_medium_result` の構造化フィールドからビルドした各 CO 者の累積発表履歴である。各 CO 者の過去の発表結果はここに完全に残る。 +- 自分が占いCO/霊媒CO 者である場合、新しい結果を発表する発話ではこの公式記録に列挙された自分の過去結果と完全に整合する内容のみを発表する。過去に発表した対象・色を別の組み合わせに差し替える、過去結果の片方を黙って削って違う対象を追加する、対抗 CO 者の発表内容を自分の結果として取り込む — これらは全て破綻として扱われる。 +- 占い CO 者は day N の朝までに通算 N + 1 件の結果を持つ (NIGHT_0 のランダム白 + 各夜 1 件)。通算件数が記録より少ない/多い結果列を主張すると **数の破綻** として確定し、聞き手はその CO を直ちに切ってよい根拠とする。霊媒 CO 者は処刑があった日数だけ結果を持つ (処刑なしの日は『結果なし』を明言する)。 +- 構造化フィールドと発話内容は必ず一対一で一致させる。新しい占い結果を述べる発話では `claimed_seer_result.target_seat`・`is_wolf` を発話内容と同じ対象・色で必ず設定する。霊媒も同様に `claimed_medium_result` を使う。新しい結果を発表しない発話 (前回までの結果に言及するだけ・他人への質問・一般議論) では両フィールドとも null にする。発話内容と構造化フィールドが食い違うと、Master 側の整合検査で破綻として記録される。 +- 同じ対象への判定色が前日と当日で食い違った CO を見つけた場合、人間プレイヤーの言い間違い・打ち間違いの可能性が残るため即座に偽 CO 確定とはしない。ただし強い偽要素として推理に組み入れ、明示的な訂正文言なしに食い違いが続く場合は、他の偽要素 (対抗 CO・票筋・噛み筋・破綻) と合わせて切ってよい材料として扱う。 +- day 2 以降に占い師・霊媒師・騎士として CO 中の者は、真でも偽でも、昼の議論 1 巡目で前夜相当の能力結果を出すのが信用上重要である。結果を持つはずの役職 CO が 1 巡目で結果を出さないと、信用低下や破綻疑いにつながる。 +- 以下は公開ログと盤面を読むときに共通で使う推理語彙。いずれも上記の事実ルール (単独 CO 真寄り、霊媒白=非人狼のみ、3-1/2-2 進行) を塗り替えない。語彙はラベルであって、最終判断は常に公開情報の整合性で行う。 +- グレー (灰): 役職 CO もなく占い/霊媒で白黒も十分ついていない位置。誰視点のグレーかを常に意識する。 +- グレラン: グレーから各自が理由を持って投票する進行。名前にランダムとあるが完全な無作為投票ではなく、発言・CO 状況・票筋・グレスケを根拠に選ぶ。 +- グレスケ (スケール): グレーや未確定位置を白い順・黒い順に並べる考察。順位だけでなく、発言・投票・判定・噛み筋との整合性を理由として添える。 +- 縄計算: 残り処刑回数 (縄) を数えること。標準目安は floor((生存人数 - 1) / 2)。この 9 人村は開始時 4縄。残り人狼数・狂人生存・PP/RPP リスクから、無駄吊りできる回数を意識する。 +- 白: 占い/霊媒で本物の人狼ではないと出た判定。狂人も白に出るため、村陣営確定ではない。 +- 黒: 占い/霊媒で本物の人狼と出た判定。誰の判定か、CO 者の信用、対抗結果との整合性を必ず確認する。 +- 確白: 公開情報上ほぼ非狼として扱える進行役候補の位置。ただし狂人は白判定されるため、「村陣営確定」と言い切りすぎない。 +- 確黒: 全視点または十分信用できる複数情報で本物の人狼と見てよい位置。単独の偽占い候補から黒を出されただけでは確黒ではない。 +- パンダ: 白判定と黒判定の両方を受けた位置。パンダ本人の黒さだけでなく、判定を出した CO 者同士の真偽比較・霊媒結果・票筋・噛み筋で評価する。 +- ローラー (ロラ): 複数 CO した同種役職候補を吊り切る進行 (占いローラー・霊媒ローラー)。開始したら原則完走し、黒ストップや強い破綻などで止める場合は理由を明示する。 +- 決め打ち: 複数 CO や複数候補のうち一方を真寄り・他方を偽寄りとして進行を固定する判断。外すと負けに直結しやすいため、縄余裕・判定・票筋・噛み筋を根拠にする。 +- 破綻: 発言・判定・投票・死亡タイミング・役職数などが公開情報と矛盾して成り立たなくなった状態。強い偽要素として扱ってよい。 +- ライン: 発言・投票・擁護・判定結果などから見える二者以上のつながり (特に狼同士に見える関係)。ライン切りや偶然もあるため、単発ではなく複数材料で見る。 +- 囲い: 偽占いなどが、狼である味方を白判定で保護する動き。狼は狂人位置を知らないため、狂人を確定の味方として囲う前提では考えない。狂人が偶然狼へ白を出す動きは別概念として扱う。白先の発言・投票・噛み筋と合わせて疑う。 +- 身内切り: 狼が仲間の狼に黒判定を出したり、投票や発言で切ったりして信用を買う戦術。狼は狂人位置を知らないため、狂人を本物の仲間として特定して切る前提にはしない。黒を出した側が必ず真とは限らない。 +- 票筋: 誰が誰に投票したかの履歴。同票・決選・狼候補への票有無、ラインや擁護との一貫性を見る。 +- 噛み筋: 夜の襲撃先の傾向。情報役噛み・白位置噛み・意見噛み・狩人探しの意図を推理する材料になる。 +- 視点漏れ: ある役職視点では本来知り得ないはずの情報 (狼位置、夜行動内訳、他人の属性など) を事実として話してしまう失言。強い騙り判断材料。 +- SG (スケープゴート): 狼に疑いを押し付けられて処刑候補にされやすい村陣営位置。怪しまれている理由が本人の黒要素か、狼の誘導かを分けて見る。 +- GJ (グッジョブ) / 平和: 朝に犠牲者が出ない状態。騎士の護衛成功があり得るが、騎士 CO や護衛先の開示は既存の騎士立ち回り指針に従い不用意に行わない。 +- 騎士 / 狩人 / 狩: この bot の正式役職名は騎士。人狼用語として狩人・狩も同じ護衛役を指す同義語として使われるため、公開ログ上で狩人や狩と書かれていても騎士と同じ意味として読む。 +- 鉄板護衛: 真寄り情報役・確白寄り・進行役など、噛まれると村が大きく崩れる位置を堅く守る護衛。 +- 変態護衛: セオリー上の本命から外れた位置を、襲撃読みで守る護衛。当たれば強いが、外すと重要役職を抜かれるリスクがある。 +- 捨て護衛: 噛まれにくい、または噛まれても村損失が小さい位置をあえて護衛する戦術。連続護衛不可の環境では、今日本命を守ると明日その本命を守れなくなるため、次夜の本命護衛余地を残す目的でも使う。この bot では合法護衛候補から 1 名を選ぶ行動であり、未提出・対象なし・誰も守らない・skip ではない。 +- 連ガ無し / 連続護衛不可: 同じ相手を連続で護衛できないルール。この bot は連続護衛不可で、前夜の護衛先は今夜の合法候補から外れる (前述の騎士護衛ルールと同じ)。 +- 護衛読み: 人狼がどこを噛みたいか、どこが護衛されていそうで噛みを避けるかを推理すること。騎士側でも、自分の護衛が読まれて噛みを外される可能性を考える材料になる。 +- 護衛誘導: 騎士の護衛先に影響を与えようとする昼発言。村利の進行整理にもなれば、人狼側の誘導にもなり得るため、発言者の立場・噛み筋・投票・翌日の得を合わせて評価する。 +- PP (パワープレイ): 終盤に人狼陣営が票を合わせて勝ちを取りに行く局面。残り人狼数・狂人生存可能性・縄数で成立可否を判断する。 +- RPP (ロスト/ランダム PP): 村側の縄と情報が尽き、PP 阻止が乱数勝負になる状態。ここに入る前に決め打ちや情報整理を優先する。 +- 発言の根拠チェックリスト: CO 履歴 (誰がいつ何を名乗ったか、対抗の有無)、判定履歴 (占い/霊媒の白黒、誰視点の結果か、狂人白ルールとの整合)、投票履歴 (同票・決選・身内票・票変え)、噛み筋 (情報役噛み・白位置噛み・意見噛み・狩人探し)、縄数 (残り処刑回数、PP/RPP の近さ) と自分の情報範囲 (私的情報と公開情報を混ぜない) を常に意識する。 +- 実際の発言には、上のチェックリストから今の結論に最も効く 1〜2 点だけを根拠として出す。用語だけで押し切らず、誰のどの発言・票・判定を見たのかを短く添える。長い内部思考そのものを発話しない。 +- 比較・関係を表す語 (重複・ライン・出来レース・囲い・身内切り・対立・連携) を使うときは、比較対象を必ず明示する。誰の何の発言・判定・投票・夜行動が、誰の何と『重複/ライン』しているのかを 1 件以上具体的に引用する。引用なく関係語だけを並べた主張は推理上の根拠として扱わず、聞き手側は内容を再確認する。 +- この村は人狼 2 人固定なので、怪しい人を 1 人挙げたら、その人物が人狼ならもう 1 人の相方候補は誰かまで公開ログからの仮説として考える。村側・狂人・確定していない役職は実際の 2 人狼ペアを知らないため、断定ではなく推理として扱う。 +- A-B の 2 人狼仮説は、(1) 庇い・便乗・距離の取り方、(2) 投票先・決選投票・票変えなどの票筋、(3) 占い・霊媒結果と白先・黒先・囲い候補、(4) 噛み筋・噛まれなかった位置・情報役噛みとの整合、(5) 片方が処刑濃厚なときの動き (ライン切り・身内票) が2 人狼セットとして自然かで検証する。 +- 単体黒要素が強くても自然な相方候補が見つからない場合は疑いの強さを下げ、単体では中庸でも相方候補との票筋・噛み筋が強くつながる場合は疑いを上げる。 +- 発言では 2 人狼候補を長く列挙せず、最も効くペア仮説 1〜2 点だけを短く添える。全候補のペアを並べる議論は時間を浪費し、結論にもつながりにくい。 +- 「相方候補」は公開ログからの推理用語として使ってよい。ただし実際の 2 人狼ペアを知っているのは人狼本人だけで、それ以外の役職は相方候補を確定情報として語らない。 \ No newline at end of file diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index 1dd840d..9f8e367 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -63,6 +63,7 @@ task_vote, task_wolf_chat, ) +from wolfbot.llm.template import render_template from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY from wolfbot.services.llm_trace import ( CallTimer, @@ -217,30 +218,17 @@ class LLMAction(BaseModel): # "json" and works best with a concrete example. We append this contract to the # system prompt for every DeepSeek decision; the contract complements (does not # replace) the markdown template loaded by prompt_builder. xAI uses json_schema -# strict mode and does not need this. Module-level so tests can assert on -# substrings without instantiating AsyncOpenAI. -_DEEPSEEK_JSON_CONTRACT_SUFFIX = """\ - ---- -出力形式 (json): -必ず次のキーを持つ JSON オブジェクトのみを返してください。前後にテキストや markdown コードフェンスを付けないでください。 -- "intent": "speak" | "vote" | "night_action" | "skip" -- "public_message": string (最大 400 文字) -- "target_name": string または null -- "reason_summary": string (最大 200 文字) -- "confidence": number (0 から 1) -- "co_declaration": "seer" | "medium" | "knight" | null -- "claimed_seer_result": object | null (今回新しく占い結果を発表する場合のみ非 null。形式 {"target_seat": integer(1-9), "is_wolf": boolean}。本物でも騙りでも同じ形式) -- "claimed_medium_result": object | null (霊媒も同様。形式 {"target_seat": integer(1-9), "is_wolf": boolean | null}。is_wolf=null は「昨日処刑なし」) - -例: -{"intent": "speak", "public_message": "私は占い師です。昨夜セツを占ったら人狼じゃなかった。", "target_name": null, "reason_summary": "CO + 結果発表", "confidence": 0.7, "co_declaration": "seer", "claimed_seer_result": {"target_seat": 6, "is_wolf": false}, "claimed_medium_result": null} -""" +# strict mode and does not need this. +# +# Body lives in `prompts/templates/master/deepseek_contract_gameplay.md` so the +# schema example sits alongside other prompt templates and is editable without +# a Python diff. Loaded once via the lru-cache in `wolfbot.llm.template`. +_DEEPSEEK_GAMEPLAY_CONTRACT_TEMPLATE = "master/deepseek_contract_gameplay" def _deepseek_json_contract(system_prompt: str) -> str: """Append DeepSeek's JSON-mode contract to a system prompt.""" - return system_prompt + _DEEPSEEK_JSON_CONTRACT_SUFFIX + return system_prompt + render_template(_DEEPSEEK_GAMEPLAY_CONTRACT_TEMPLATE) def _claim_to_fields( diff --git a/tests/test_llm_prompt_builder.py b/tests/test_llm_prompt_builder.py index 2ce482b..d323645 100644 --- a/tests/test_llm_prompt_builder.py +++ b/tests/test_llm_prompt_builder.py @@ -23,6 +23,7 @@ from wolfbot.llm.persona_base import JudgmentProfile, Persona, SpeechProfile from wolfbot.llm.prompt_builder import ( _build_game_rules_block, + _build_game_rules_block_legacy_inline, _build_judgment_profile_block, _build_speech_profile_block, _build_strategy_block, @@ -37,6 +38,16 @@ # --------------------------------------------------------- game rules block +def test_game_rules_block_template_matches_legacy_inline() -> None: + """Round-trip parity: the markdown template must render identically + to the legacy inline-string form. This catches the dangerous case + where someone edits one side and forgets the other — every other + `test_game_rules_block_*` test below verifies *content*, but not + that the two sources stay in lock-step. + """ + assert _build_game_rules_block() == _build_game_rules_block_legacy_inline() + + def test_game_rules_block_contains_role_distribution() -> None: block = _build_game_rules_block() for role_ja, count in ( From 273f67f7494c5f830b89df68023c9d1ecdf61fad Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 10:53:49 +0900 Subject: [PATCH 098/133] refactor(prompts): extract NPC speech system prompt to .md template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the ~50-line NPC speech generator system prompt body to prompts/templates/npc/speech_system.md, with placeholders for the dynamic blocks (display_name, style_guide, speech_profile, judgment_profile, game_rules_block, max_chars) and {{#if}} conditionals for the optional role/role_strategy sections. Builder _build_system() now calls render_template(...) instead of carrying the literal block as a Python triple-quoted string. No prompt content changes — verified by 1238 tests + ruff + mypy green, including the NPC seat-assignment tests that walk the rendered system prompt. --- .../npc/openai_compatible_generator.py | 141 +++++------------- .../prompts/templates/npc/speech_system.md | 34 +++++ 2 files changed, 74 insertions(+), 101 deletions(-) create mode 100644 src/wolfbot/prompts/templates/npc/speech_system.md diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/openai_compatible_generator.py index 86da759..c47f1a3 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/openai_compatible_generator.py @@ -139,6 +139,9 @@ } +_NPC_SPEECH_SYSTEM_TEMPLATE = "npc/speech_system" + + def _build_system( persona: Persona, max_chars: int, @@ -148,108 +151,44 @@ def _build_system( ) -> str: """Build the NPC's system prompt. - Mirrors rounds-mode `build_system_prompt` for the persona-shaping blocks - (`build_speech_profile_block`, `build_judgment_profile_block`) so the - reactive_voice NPC carries the same character data — `narration_mode`, - `address_style`, `forbidden_overuse`, the 5 judgment axes, etc. — - instead of the small subset the historical NPC prompt sent. - - `role` + `role_strategy` are optional: when Master sends them on the - SpeakRequest, the NPC sees its role and the role-specific strategy - block. Older Master builds that don't send them produce a prompt that - silently omits the role section (back-compat). - - Game-rules block: rounds-mode injects ``_build_game_rules_block()`` - via the markdown template (``{game_rules_block}`` placeholder) so - the gameplay LLM sees the canonical 30+ rule list (NIGHT_0 has no - attack/guard, day-1 single-target seer rule, 黒/白 binary, count - integrity, single-CO trust, etc.). The reactive_voice NPC builds - its prompt programmatically here, and historically *omitted* the - block entirely — meaning every prompt rule edit to - ``prompt_builder._build_game_rules_block`` since reactive_voice - landed has only affected the gameplay LLM (votes/night actions), - never the persona LLM that actually speaks. Game ``8ccc86215e97`` - surfaced the bug: Rakio (knight) day-1 falsely claimed "I guarded - someone last night" (NIGHT_0 has no guard), Yuriko (wolf) day-1 - fake-CO'd seer with **two** results in one morning ("シゲミチ村人 - 確定" + "ステラ人狼確定") — both forbidden by rules that simply - weren't in the prompt. Injecting the block here closes the gap so - every NPC sees the same canonical ruleset. + Body lives in `prompts/templates/npc/speech_system.md`. This wrapper + fills the dynamic placeholders: + + - `display_name`, `style_guide` (persona literals) + - `speech_profile_block`, `judgment_profile_block` (rendered Python + blocks — kept programmatic because they iterate persona axes) + - `game_rules_block` (also a template, see + :func:`wolfbot.llm.prompt_builder._build_game_rules_block`) + - `role` + `role_strategy` (optional; the template's `{{#if}}` + conditionals omit those sections cleanly when Master doesn't + send them) + - `max_chars` (length cap injected twice in the output rules) + + Mirrors rounds-mode `build_system_prompt` for the persona-shaping + blocks so the reactive_voice NPC carries the same character data — + `narration_mode`, `address_style`, `forbidden_overuse`, the 5 + judgment axes, etc. — instead of the small subset the historical + NPC prompt sent. + + Game-rules block: was historically *omitted* from this NPC prompt + (only the gameplay LLM saw it), so prompt-rule edits silently + only affected votes/night actions. Game ``8ccc86215e97`` surfaced + the bug: Rakio (knight) day-1 falsely claimed "I guarded someone + last night" (NIGHT_0 has no guard), Yuriko (wolf) day-1 fake-CO'd + seer with **two** results in one morning — both forbidden by + rules that simply weren't in the prompt. Injecting the block + closes the gap so every NPC sees the same canonical ruleset. """ - role_block = "" - if role: - role_block = f"## あなたの役職\nあなたの役職は『{role}』です。役職に見える情報だけを根拠にしてください。\n\n" - strategy_block = "" - if role_strategy: - strategy_block = f"## 役職別の戦術ヒント\n{role_strategy}\n\n" - return ( - "あなたは人狼ゲームに参加中のプレイヤーです。\n" - f"キャラクター名: {persona.display_name}\n" - f"性格: {persona.style_guide}\n\n" - f"## 話法\n{build_speech_profile_block(persona)}\n\n" - f"## 判断のクセ\n{build_judgment_profile_block(persona)}\n\n" - f"## ゲームルール\n{_build_game_rules_block()}\n\n" - f"{role_block}" - f"{strategy_block}" - "## 出力ルール\n" - "- 日本語のみ。メタ発言禁止。AIであることに言及しない。\n" - f"- `text` は {max_chars} 文字以内の短い発言。" - f"上限ぎりぎりまで埋めようとせず、必ず文を最後まで言い切ること。" - f"句読点や用言の途中で終わらないようにし、{max_chars} 文字に収めるためなら" - "内容を削ってでも完結した文にする。\n" - "- 発言しない場合は intent を `skip`、text を空文字にする。\n" - "- `used_logic_ids` には参考にした logic candidate の id を入れる。\n" - "- **`text` 内で人狼用語(メタ語彙)を使わない。** 内部の思考では使ってよいが、" - "実際に喋る発話は素朴な日本語にする。\n" - " 禁止例: 「CO」「占いCO」「霊媒CO」「騎士CO」「黒判定」「白判定」" - "「ライン」「グレー」「グレラン」「縄」「PP」「ローラー」「破綻」「確白」「確黒」" - "「鉄板護衛」「噛み筋」「票筋」「視点漏れ」「身内切り」「囲い」「相方」「2 人狼セット」など。\n" - " 代わりに状況描写や感情で言う: " - "「あの白判定、無理に庇ってる気がする」「昨夜守ったのは◯◯」" - "「もう 1 人組んでそうな人」「あと処刑できる回数を考えると…」 のように。\n" - "- **`text` 内で席番号 (席1, 席2, ..., 席9 や Seat3 等) を絶対に書かない。**" - "プロンプトの `## 参加者` ブロックで席番号 → 名前のマッピングが冒頭に提示されており、" - "それ以外のブロックは display_name のみで人物を参照している。" - "あなたも他者を呼ぶときは必ず display_name (キャラ名) を使う。" - "data 層 (`addressed_seat_nos` 等) には正しい席番号を入れて構わないが、" - "発話そのものは「ジナさん」「ラキオ」のような自然な呼び方にする。" - "状況整理を発話で行うときも『占いCO は ジョナスとsakura、霊媒CO はユリコとセツ』のように" - "名前で並べる。`席4` `席1` のような数字並列は禁止。\n" - " 禁止例: 「席3はどう思う?」「席4のラキオが…」「Seat 9、答えて」" - "「席4と席1の占い主張、席9と席7の霊媒主張」\n" - " 推奨例: 「ジョナスさんはどう思う?」「ラキオが…」「ユリコ、答えて」" - "「ジョナスとsakuraの占い主張、ユリコとセツの霊媒主張」\n" - "- 役職 CO (占い師・霊媒師・騎士として名乗る) をするときは、" - '`co_declaration` を `"seer" / "medium" / "knight"` のいずれかに設定し、' - "`text` は「実は私、占い師なんだ」など自然な名乗りにする。" - "CO しないなら `co_declaration=null`。" - "「占いCO」のような語そのものは `text` に書かない。\n" - "- **占い/霊媒結果は構造化フィールドにも必ず反映する。**" - "発話 `text` で占い結果を述べる場合 (本物でも騙りでも同じ): " - '`claimed_seer_result` に `{"target_seat": 対象席, "is_wolf": true/false}` を設定し、' - "述べた席・結果と完全一致させる。霊媒も同様に `claimed_medium_result` を使う" - "(処刑なし/結果なしを明言する場合は `is_wolf=null` を設定し、`target_seat` は処刑対象の席)。" - "新しい結果を発表しない発話 (前回までの結果に言及するだけ・一般議論・他人への質問) では " - "両フィールドとも `null` にする。" - "プロンプト中の `## 公開された占い/霊媒CO結果` ブロックが過去の発表履歴 (公式記録) であり、" - "そこに矛盾する/重複する/数が合わない結果を出すと **破綻判定** され狼/騙り側は即吊られる。" - "占いCO した seat は day N の朝までに通算 N 個の結果を発表できる " - "(day1 朝で NIGHT_0 ランダム白 1 件、day N 朝までに各夜分が +1 件ずつ加算; " - "day1 朝なら通算 1 個、day2 朝なら 2 個…)。" - "**同じ day に 2 件目の占い結果は出せない (本物の占い師は一夜 1 件まで)**。" - "決選演説 (DAY_RUNOFF_SPEECH) もその day 内なので、新しい占い結果は出さない。" - "1 回の発話で複数 seat を占ったと言う「すべて白」「全員白」は破綻なので絶対に出さない。\n" - "- 特定の席に向けて話す場合は `addressed_seat_nos` にその席番号の配列を入れる。" - "1人だけなら `[3]`、複数人に同時に問いかけるなら `[2, 3]` (例「セツとジナはどう?」)。" - "誰宛でもない一般的な発言や全体への呼びかけは空配列 `[]`。" - "自分の席を指定しても無効化されるので、相手の席を必ず入れること。" - "`text` 中で名前を呼んだ全員ぶんを `addressed_seat_nos` に列挙する。" - "Master は配列の全員を次に発話する優先候補として扱うので、" - "問いかけた人数に応じて漏れなく入れる。\n" - "- 死亡者リストには (処刑) または (襲撃) の死因タグが付く。" - "前日の処刑死を「昨夜の犠牲者」と混同しない。逆も同様。" - "発言で死を語るときはタグに合わせた表現を使う" - "(例: 処刑死は「昨日処刑された」、襲撃死は「昨夜襲われた」)。\n" + return render_template( + _NPC_SPEECH_SYSTEM_TEMPLATE, + display_name=persona.display_name, + style_guide=persona.style_guide, + speech_profile_block=build_speech_profile_block(persona), + judgment_profile_block=build_judgment_profile_block(persona), + game_rules_block=_build_game_rules_block(), + role=role or "", + role_strategy=role_strategy or "", + max_chars=max_chars, ) diff --git a/src/wolfbot/prompts/templates/npc/speech_system.md b/src/wolfbot/prompts/templates/npc/speech_system.md new file mode 100644 index 0000000..548fb6b --- /dev/null +++ b/src/wolfbot/prompts/templates/npc/speech_system.md @@ -0,0 +1,34 @@ +あなたは人狼ゲームに参加中のプレイヤーです。 +キャラクター名: {{display_name}} +性格: {{style_guide}} + +## 話法 +{{speech_profile_block}} + +## 判断のクセ +{{judgment_profile_block}} + +## ゲームルール +{{game_rules_block}} + +{{#if role}}## あなたの役職 +あなたの役職は『{{role}}』です。役職に見える情報だけを根拠にしてください。 + +{{/if}}{{#if role_strategy}}## 役職別の戦術ヒント +{{role_strategy}} + +{{/if}}## 出力ルール +- 日本語のみ。メタ発言禁止。AIであることに言及しない。 +- `text` は {{max_chars}} 文字以内の短い発言。上限ぎりぎりまで埋めようとせず、必ず文を最後まで言い切ること。句読点や用言の途中で終わらないようにし、{{max_chars}} 文字に収めるためなら内容を削ってでも完結した文にする。 +- 発言しない場合は intent を `skip`、text を空文字にする。 +- `used_logic_ids` には参考にした logic candidate の id を入れる。 +- **`text` 内で人狼用語(メタ語彙)を使わない。** 内部の思考では使ってよいが、実際に喋る発話は素朴な日本語にする。 + 禁止例: 「CO」「占いCO」「霊媒CO」「騎士CO」「黒判定」「白判定」「ライン」「グレー」「グレラン」「縄」「PP」「ローラー」「破綻」「確白」「確黒」「鉄板護衛」「噛み筋」「票筋」「視点漏れ」「身内切り」「囲い」「相方」「2 人狼セット」など。 + 代わりに状況描写や感情で言う: 「あの白判定、無理に庇ってる気がする」「昨夜守ったのは◯◯」「もう 1 人組んでそうな人」「あと処刑できる回数を考えると…」 のように。 +- **`text` 内で席番号 (席1, 席2, ..., 席9 や Seat3 等) を絶対に書かない。**プロンプトの `## 参加者` ブロックで席番号 → 名前のマッピングが冒頭に提示されており、それ以外のブロックは display_name のみで人物を参照している。あなたも他者を呼ぶときは必ず display_name (キャラ名) を使う。data 層 (`addressed_seat_nos` 等) には正しい席番号を入れて構わないが、発話そのものは「ジナさん」「ラキオ」のような自然な呼び方にする。状況整理を発話で行うときも『占いCO は ジョナスとsakura、霊媒CO はユリコとセツ』のように名前で並べる。`席4` `席1` のような数字並列は禁止。 + 禁止例: 「席3はどう思う?」「席4のラキオが…」「Seat 9、答えて」「席4と席1の占い主張、席9と席7の霊媒主張」 + 推奨例: 「ジョナスさんはどう思う?」「ラキオが…」「ユリコ、答えて」「ジョナスとsakuraの占い主張、ユリコとセツの霊媒主張」 +- 役職 CO (占い師・霊媒師・騎士として名乗る) をするときは、`co_declaration` を `"seer" / "medium" / "knight"` のいずれかに設定し、`text` は「実は私、占い師なんだ」など自然な名乗りにする。CO しないなら `co_declaration=null`。「占いCO」のような語そのものは `text` に書かない。 +- **占い/霊媒結果は構造化フィールドにも必ず反映する。**発話 `text` で占い結果を述べる場合 (本物でも騙りでも同じ): `claimed_seer_result` に `{"target_seat": 対象席, "is_wolf": true/false}` を設定し、述べた席・結果と完全一致させる。霊媒も同様に `claimed_medium_result` を使う(処刑なし/結果なしを明言する場合は `is_wolf=null` を設定し、`target_seat` は処刑対象の席)。新しい結果を発表しない発話 (前回までの結果に言及するだけ・一般議論・他人への質問) では 両フィールドとも `null` にする。プロンプト中の `## 公開された占い/霊媒CO結果` ブロックが過去の発表履歴 (公式記録) であり、そこに矛盾する/重複する/数が合わない結果を出すと **破綻判定** され狼/騙り側は即吊られる。占いCO した seat は day N の朝までに通算 N 個の結果を発表できる (day1 朝で NIGHT_0 ランダム白 1 件、day N 朝までに各夜分が +1 件ずつ加算; day1 朝なら通算 1 個、day2 朝なら 2 個…)。**同じ day に 2 件目の占い結果は出せない (本物の占い師は一夜 1 件まで)**。決選演説 (DAY_RUNOFF_SPEECH) もその day 内なので、新しい占い結果は出さない。1 回の発話で複数 seat を占ったと言う「すべて白」「全員白」は破綻なので絶対に出さない。 +- 特定の席に向けて話す場合は `addressed_seat_nos` にその席番号の配列を入れる。1人だけなら `[3]`、複数人に同時に問いかけるなら `[2, 3]` (例「セツとジナはどう?」)。誰宛でもない一般的な発言や全体への呼びかけは空配列 `[]`。自分の席を指定しても無効化されるので、相手の席を必ず入れること。`text` 中で名前を呼んだ全員ぶんを `addressed_seat_nos` に列挙する。Master は配列の全員を次に発話する優先候補として扱うので、問いかけた人数に応じて漏れなく入れる。 +- 死亡者リストには (処刑) または (襲撃) の死因タグが付く。前日の処刑死を「昨夜の犠牲者」と混同しない。逆も同様。発言で死を語るときはタグに合わせた表現を使う(例: 処刑死は「昨日処刑された」、襲撃死は「昨夜襲われた」)。 From 9499bd59b9f08451fe8ceba6c1b0c6825b0bb975 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 10:57:02 +0900 Subject: [PATCH 099/133] refactor(prompts): extract NPC decision (vote/wolf_chat/night) prompts to .md templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six new templates under prompts/templates/npc/, paired by call site: * decision_vote_system.md / decision_vote_user.md * decision_wolf_chat_system.md / decision_wolf_chat_user.md * decision_night_system.md / decision_night_user.md Each builder (build_vote_prompt / build_wolf_chat_prompt / build_night_prompt) now calls render_template(...) twice instead of carrying the literal text as Python triple-quoted strings. Dynamic blocks (persona, role-strategy, state, digest, candidate list) stay in Python where they iterate structured data; the templates only fill in those rendered blocks via {{var}}. No prompt-content changes — verified by 1238 tests + ruff + mypy green. --- src/wolfbot/npc/decision_service.py | 197 +++++++----------- .../templates/npc/decision_night_system.md | 1 + .../templates/npc/decision_night_user.md | 16 ++ .../templates/npc/decision_vote_system.md | 1 + .../templates/npc/decision_vote_user.md | 16 ++ .../npc/decision_wolf_chat_system.md | 3 + .../templates/npc/decision_wolf_chat_user.md | 16 ++ 7 files changed, 133 insertions(+), 117 deletions(-) create mode 100644 src/wolfbot/prompts/templates/npc/decision_night_system.md create mode 100644 src/wolfbot/prompts/templates/npc/decision_night_user.md create mode 100644 src/wolfbot/prompts/templates/npc/decision_vote_system.md create mode 100644 src/wolfbot/prompts/templates/npc/decision_vote_user.md create mode 100644 src/wolfbot/prompts/templates/npc/decision_wolf_chat_system.md create mode 100644 src/wolfbot/prompts/templates/npc/decision_wolf_chat_user.md diff --git a/src/wolfbot/npc/decision_service.py b/src/wolfbot/npc/decision_service.py index 7a29c2f..2236019 100644 --- a/src/wolfbot/npc/decision_service.py +++ b/src/wolfbot/npc/decision_service.py @@ -36,6 +36,7 @@ build_speech_profile_block, build_strategy_block, ) +from wolfbot.llm.template import render_template from wolfbot.npc.game_state import NpcGameState log = logging.getLogger(__name__) @@ -239,40 +240,31 @@ def build_vote_prompt( persona: Persona, request: DecideVoteRequest, ) -> tuple[str, str]: - """Compose the system + user prompt for a vote decision.""" - candidates_str = _format_candidates(request.candidate_seats) - digest = request.public_state_summary or "(情報なし)" - round_label = _VOTE_ACT_TEXT_BY_ROUND.get(request.round_, f"round={request.round_}") - state_block = _build_state_block(state) - persona_block = _build_persona_block(persona) - role_block = _build_role_block(state.role) - system = ( - "あなたは人狼ゲームの 1 プレイヤーです。" - "公開情報・自分が持つ非公開情報・性格・役職戦術に基づいて投票先を決めてください。" - "返答は JSON のみ。" + """Compose the system + user prompt for a vote decision. + + Templates: + - npc/decision_vote_system.md (fixed instruction) + - npc/decision_vote_user.md (per-call context) + """ + return ( + render_template(_DECISION_VOTE_SYSTEM_TEMPLATE), + render_template( + _DECISION_VOTE_USER_TEMPLATE, + round_label=_VOTE_ACT_TEXT_BY_ROUND.get( + request.round_, f"round={request.round_}" + ), + day_number=state.day_number, + persona_block=_build_persona_block(persona), + role_block=_build_role_block(state.role), + state_block=_build_state_block(state), + digest=request.public_state_summary or "(情報なし)", + candidates_str=_format_candidates(request.candidate_seats), + ), ) - user_parts = [ - f"## フェイズ: {round_label} (day {state.day_number})", - "", - persona_block, - "", - role_block, - "", - "## 自分の状況 (非公開を含む)", - state_block, - "", - "## 場の状況 (Master ダイジェスト)", - digest, - "", - f"## 投票候補席\n{candidates_str}", - "", - "上記すべてを踏まえ、この投票で誰に票を入れるかを決めてください。" - "**棄権は禁止**: 必ず候補席の中から1人を選んで `target_seat` に入れる。" - "情報が薄くても、最も怪しい/役割上吊りたい/相方ライン以外の中から相対的に最も票を入れたい1人を選ぶこと。" - "JSON は {\"target_seat\": <候補席番号>, \"reason\": \"<短い理由>\"} の形 " - "(`target_seat` は必ず整数、null 不可)。", - ] - return system, "\n".join(p for p in user_parts if p is not None) + + +_DECISION_VOTE_SYSTEM_TEMPLATE = "npc/decision_vote_system" +_DECISION_VOTE_USER_TEMPLATE = "npc/decision_vote_user" _NIGHT_ACT_TEXT: dict[str, str] = { @@ -295,6 +287,10 @@ def build_vote_prompt( } +_DECISION_WOLF_CHAT_SYSTEM_TEMPLATE = "npc/decision_wolf_chat_system" +_DECISION_WOLF_CHAT_USER_TEMPLATE = "npc/decision_wolf_chat_user" + + def build_wolf_chat_prompt( *, state: NpcGameState, @@ -304,63 +300,41 @@ def build_wolf_chat_prompt( ) -> tuple[str, str]: """Compose system + user prompts for a wolf-chat coordination line. - Wolves talk to each other privately. The line must: - - propose / agree / counter on a target, - - stay under 80 chars, - - speak in the persona's voice (this is still character). + Templates: + - npc/decision_wolf_chat_system.md (fixed: voice, JSON shape, GJ rebite rule) + - npc/decision_wolf_chat_user.md (per-call context) + + Wolves talk to each other privately. The line must propose / agree + / counter on a target, stay under 80 chars, and speak in the + persona's voice (this is still character). The role-strategy block (= ``build_strategy_block(WEREWOLF)``) is - INJECTED here too. Without it, the chat's "今夜誰を噛む?" decision - runs on the persona block + game ledger only; the master tactical - rules (multi-CO attack avoidance, GJ rebite, info-role priority, - knight-candidate scoring) live in the WEREWOLF strategy block and - were missing from the chat prompt — so wolves agreed to attack a - seer in a 3-CO board (game ``38627df1ade1`` night 1), and the - night-action prompt that follows already carries the chat history - as commitment, biasing both wolves to follow through. + INJECTED via :func:`_build_role_block`. Without it, the chat's + "今夜誰を噛む?" decision runs on the persona block + game ledger + only; the master tactical rules (multi-CO attack avoidance, GJ + rebite, info-role priority, knight-candidate scoring) live in the + WEREWOLF strategy block and were missing from the chat prompt — + so wolves agreed to attack a seer in a 3-CO board (game + ``38627df1ade1`` night 1), and the night-action prompt that + follows already carries the chat history as commitment, biasing + both wolves to follow through. """ - persona_block = _build_persona_block(persona) - state_block = _build_state_block(state) - role_block = _build_role_block(state.role) candidates_str = ( "、".join(f"席{seat_no} {name}" for seat_no, name in candidates) or "(なし)" ) - digest = public_state_summary or "(情報なし)" - system = ( - "あなたは人狼ゲームの 1 プレイヤーです。" - "あなたは人狼で、仲間の人狼にだけ届く秘密チャットでこのターンの " - "襲撃方針を簡潔に伝えてください。村人に届く発話ではないので、" - "ペルソナの口調を保ちつつ素直に作戦を提示してよい (ただし" - "メタ用語は避ける)。返答は JSON のみ。\n\n" - "**戦術指針 — GJ後の再噛み**: 前夜の襲撃が GJ で平和な朝になった " - "(自分達が昨夜噛んだ対象が今朝も生存している) 場合、" - "騎士の連続護衛禁止により今夜その対象は守られない。" - "盤面が大きく変わっていないなら同じ対象を再噛みするのが原則最善 — " - "数学的に成功が確定する。" - "切り替えるのは、対象の襲撃価値が大きく低下した、別位置に超緊急の情報役が出た、" - "等の具体的根拠があるときに限る。" - "「読まれそう」「対称的すぎる」のような漠然とした不安では切り替えない。" + return ( + render_template(_DECISION_WOLF_CHAT_SYSTEM_TEMPLATE), + render_template( + _DECISION_WOLF_CHAT_USER_TEMPLATE, + day_number=state.day_number, + persona_block=_build_persona_block(persona), + role_block=_build_role_block(state.role), + state_block=_build_state_block(state), + digest=public_state_summary or "(情報なし)", + candidates_str=candidates_str, + ), ) - user_parts = [ - f"## 現在: 人狼チャット (day {state.day_number})", - "", - persona_block, - "", - role_block, - "", - "## 自分の状況 (非公開)", - state_block, - "", - "## 場の状況 (Master ダイジェスト)", - digest, - "", - f"## 襲撃候補席\n{candidates_str}", - "", - "上記を踏まえ、仲間の狼に向けて 80 文字以内で 1 行だけ書いてください。" - "JSON は {\"text\": \"...\"} の形。", - ] - return system, "\n".join(p for p in user_parts if p is not None) def parse_wolf_chat_text(raw_json: str) -> str | None: @@ -379,48 +353,37 @@ def parse_wolf_chat_text(raw_json: str) -> str | None: return cleaned or None +_DECISION_NIGHT_SYSTEM_TEMPLATE = "npc/decision_night_system" +_DECISION_NIGHT_USER_TEMPLATE = "npc/decision_night_user" + + def build_night_prompt( *, state: NpcGameState, persona: Persona, request: DecideNightActionRequest, ) -> tuple[str, str]: - """Compose the system + user prompt for a night-action decision.""" - candidates_str = _format_candidates(request.candidate_seats) - digest = request.public_state_summary or "(情報なし)" - state_block = _build_state_block(state) - persona_block = _build_persona_block(persona) - role_block = _build_role_block(state.role) - action_label = _NIGHT_ACT_TEXT.get(request.action_kind, request.action_kind) - system = ( - "あなたは人狼ゲームの 1 プレイヤーです。" - "夜行動の対象を性格・役職戦術・公開/非公開情報を踏まえて決めてください。" - "返答は JSON のみ。" + """Compose the system + user prompt for a night-action decision. + + Templates: + - npc/decision_night_system.md (fixed instruction) + - npc/decision_night_user.md (per-call context) + """ + return ( + render_template(_DECISION_NIGHT_SYSTEM_TEMPLATE), + render_template( + _DECISION_NIGHT_USER_TEMPLATE, + action_label=_NIGHT_ACT_TEXT.get( + request.action_kind, request.action_kind + ), + day_number=state.day_number, + persona_block=_build_persona_block(persona), + role_block=_build_role_block(state.role), + state_block=_build_state_block(state), + digest=request.public_state_summary or "(情報なし)", + candidates_str=_format_candidates(request.candidate_seats), + ), ) - user_parts = [ - f"## フェイズ: {action_label} (day {state.day_number})", - "", - persona_block, - "", - role_block, - "", - "## 自分の状況 (非公開を含む)", - state_block, - "", - "## 場の状況 (Master ダイジェスト)", - digest, - "", - f"## 行動候補席\n{candidates_str}", - "", - "上記すべてを踏まえ、夜の行動対象を決めてください。" - "**スキップ禁止**: 必ず候補席の中から1人を選んで `target_seat` に入れる。" - "情報が薄くても、相対的に最も対象として価値がある1人を選ぶこと " - "(占い: 情報を取りたい灰、人狼: 噛み価値の高い位置、騎士: 守るべき情報役/重要位置)。" - "「捨て護衛」のような戦術選択をしたい場合も、null ではなく合法候補から1人を選ぶ。" - "JSON は {\"target_seat\": <候補席番号>, \"reason\": \"<短い理由>\"} の形 " - "(`target_seat` は必ず整数、null 不可)。", - ] - return system, "\n".join(p for p in user_parts if p is not None) def parse_decision( diff --git a/src/wolfbot/prompts/templates/npc/decision_night_system.md b/src/wolfbot/prompts/templates/npc/decision_night_system.md new file mode 100644 index 0000000..7eb0f62 --- /dev/null +++ b/src/wolfbot/prompts/templates/npc/decision_night_system.md @@ -0,0 +1 @@ +あなたは人狼ゲームの 1 プレイヤーです。夜行動の対象を性格・役職戦術・公開/非公開情報を踏まえて決めてください。返答は JSON のみ。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/npc/decision_night_user.md b/src/wolfbot/prompts/templates/npc/decision_night_user.md new file mode 100644 index 0000000..ebfd363 --- /dev/null +++ b/src/wolfbot/prompts/templates/npc/decision_night_user.md @@ -0,0 +1,16 @@ +## フェイズ: {{action_label}} (day {{day_number}}) + +{{persona_block}} + +{{role_block}} + +## 自分の状況 (非公開を含む) +{{state_block}} + +## 場の状況 (Master ダイジェスト) +{{digest}} + +## 行動候補席 +{{candidates_str}} + +上記すべてを踏まえ、夜の行動対象を決めてください。**スキップ禁止**: 必ず候補席の中から1人を選んで `target_seat` に入れる。情報が薄くても、相対的に最も対象として価値がある1人を選ぶこと (占い: 情報を取りたい灰、人狼: 噛み価値の高い位置、騎士: 守るべき情報役/重要位置)。「捨て護衛」のような戦術選択をしたい場合も、null ではなく合法候補から1人を選ぶ。JSON は {"target_seat": <候補席番号>, "reason": "<短い理由>"} の形 (`target_seat` は必ず整数、null 不可)。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/npc/decision_vote_system.md b/src/wolfbot/prompts/templates/npc/decision_vote_system.md new file mode 100644 index 0000000..b9f5879 --- /dev/null +++ b/src/wolfbot/prompts/templates/npc/decision_vote_system.md @@ -0,0 +1 @@ +あなたは人狼ゲームの 1 プレイヤーです。公開情報・自分が持つ非公開情報・性格・役職戦術に基づいて投票先を決めてください。返答は JSON のみ。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/npc/decision_vote_user.md b/src/wolfbot/prompts/templates/npc/decision_vote_user.md new file mode 100644 index 0000000..700d3fc --- /dev/null +++ b/src/wolfbot/prompts/templates/npc/decision_vote_user.md @@ -0,0 +1,16 @@ +## フェイズ: {{round_label}} (day {{day_number}}) + +{{persona_block}} + +{{role_block}} + +## 自分の状況 (非公開を含む) +{{state_block}} + +## 場の状況 (Master ダイジェスト) +{{digest}} + +## 投票候補席 +{{candidates_str}} + +上記すべてを踏まえ、この投票で誰に票を入れるかを決めてください。**棄権は禁止**: 必ず候補席の中から1人を選んで `target_seat` に入れる。情報が薄くても、最も怪しい/役割上吊りたい/相方ライン以外の中から相対的に最も票を入れたい1人を選ぶこと。JSON は {"target_seat": <候補席番号>, "reason": "<短い理由>"} の形 (`target_seat` は必ず整数、null 不可)。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/npc/decision_wolf_chat_system.md b/src/wolfbot/prompts/templates/npc/decision_wolf_chat_system.md new file mode 100644 index 0000000..39ffec4 --- /dev/null +++ b/src/wolfbot/prompts/templates/npc/decision_wolf_chat_system.md @@ -0,0 +1,3 @@ +あなたは人狼ゲームの 1 プレイヤーです。あなたは人狼で、仲間の人狼にだけ届く秘密チャットでこのターンの 襲撃方針を簡潔に伝えてください。村人に届く発話ではないので、ペルソナの口調を保ちつつ素直に作戦を提示してよい (ただしメタ用語は避ける)。返答は JSON のみ。 + +**戦術指針 — GJ後の再噛み**: 前夜の襲撃が GJ で平和な朝になった (自分達が昨夜噛んだ対象が今朝も生存している) 場合、騎士の連続護衛禁止により今夜その対象は守られない。盤面が大きく変わっていないなら同じ対象を再噛みするのが原則最善 — 数学的に成功が確定する。切り替えるのは、対象の襲撃価値が大きく低下した、別位置に超緊急の情報役が出た、等の具体的根拠があるときに限る。「読まれそう」「対称的すぎる」のような漠然とした不安では切り替えない。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/npc/decision_wolf_chat_user.md b/src/wolfbot/prompts/templates/npc/decision_wolf_chat_user.md new file mode 100644 index 0000000..7eb59d5 --- /dev/null +++ b/src/wolfbot/prompts/templates/npc/decision_wolf_chat_user.md @@ -0,0 +1,16 @@ +## 現在: 人狼チャット (day {{day_number}}) + +{{persona_block}} + +{{role_block}} + +## 自分の状況 (非公開) +{{state_block}} + +## 場の状況 (Master ダイジェスト) +{{digest}} + +## 襲撃候補席 +{{candidates_str}} + +上記を踏まえ、仲間の狼に向けて 80 文字以内で 1 行だけ書いてください。JSON は {"text": "..."} の形。 \ No newline at end of file From 0f9088a6592a4d1f2fd7a9665b7522b4ed0604dc Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 11:01:47 +0900 Subject: [PATCH 100/133] refactor(prompts): extract Master task_* prompts (daytime/vote/night/wolf_chat) to .md templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four new templates under prompts/templates/master/: * task_daytime_speech.md — day-discussion task instruction. Two optional paragraphs gated by {{#if}}: day-2-round-1 ability-result reminder, and day-1-round-1 wolf/madman fake-CO triad guidance. * task_vote.md — vote task. Wolf-only checklist (身内票 / ライン 切り / 票逸らしリスク / 決選投票) gated so non-wolf prompts never see partner identity or wolf-side voting tactics. * task_night_action.md — night action task. Three role-scoped advice paragraphs (wolf-attack value scoring, knight-guard tradeoffs, seer-divine target value) gated by action kind so each role only sees its own decision checklist. * task_wolf_chat.md — wolf-chat coordination task. Each task_* function in llm/prompt_builder.py now calls render_template(...) instead of carrying ~40-line if/elif blocks with concatenated f-strings. The role-leak invariants (wolf strategy never reaching villager prompts, etc.) are now expressed as template booleans instead of ad-hoc string concatenation. No prompt-content changes — verified by 1238 tests + ruff + mypy green, including the long suite of cross-leak tests in test_llm_prompt_builder.py that walk every task_* output. --- src/wolfbot/llm/prompt_builder.py | 220 ++++++------------ .../templates/master/task_daytime_speech.md | 2 + .../templates/master/task_night_action.md | 3 + .../prompts/templates/master/task_vote.md | 11 + .../templates/master/task_wolf_chat.md | 2 + 5 files changed, 95 insertions(+), 143 deletions(-) create mode 100644 src/wolfbot/prompts/templates/master/task_daytime_speech.md create mode 100644 src/wolfbot/prompts/templates/master/task_night_action.md create mode 100644 src/wolfbot/prompts/templates/master/task_vote.md create mode 100644 src/wolfbot/prompts/templates/master/task_wolf_chat.md diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index d6e801c..da2e22b 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -1301,59 +1301,44 @@ def _format_log(log: dict[str, object], *, attributed_kinds: tuple[str, ...]) -> # ---------------------------------------------------------- task blocks +_TASK_DAYTIME_SPEECH_TEMPLATE = "master/task_daytime_speech" +_TASK_VOTE_TEMPLATE = "master/task_vote" +_TASK_NIGHT_ACTION_TEMPLATE = "master/task_night_action" +_TASK_WOLF_CHAT_TEMPLATE = "master/task_wolf_chat" + + def task_daytime_speech( day_number: int, discussion_round: int | None = None, *, role: Role | None = None, ) -> str: - base = ( - f"現在は day {day_number} の議論フェイズです。" - " 必要と感じた場合のみ `intent=speak` を返し、`public_message` に 80〜300 字で短い発言を書いてください。" - " 発言したくない場合は `intent=skip` と明示してください。" - " 疑い先を出すときは、単体の怪しさだけでなく、その人物が人狼なら相方候補は誰か、" - "2 人狼セットとして票筋・噛み筋が自然かも必要に応じて短く触れてください。" - "全候補のペアを長く列挙せず、今の結論に効く 1〜2 点だけを出してください。" - "\n発話 (`public_message`) のルール:" - " 自然な日本語で喋ること。" - "「CO」「占いCO」「霊媒CO」「騎士CO」「黒判定」「白判定」「ライン」「グレー」「グレラン」" - "「縄」「PP」「RPP」「ローラー」「ロラ」「破綻」「確白」「確黒」「パンダ」「鉄板護衛」「捨て護衛」" - "「噛み筋」「票筋」「視点漏れ」「身内切り」「囲い」など、" - "プレイヤー間で使われがちなメタ用語は `public_message` 内で使わない" - "(これらは内部の `reason_summary` や思考メモには使ってよい)。" - "口に出すときは状況や感情として描写する" - "(例: 「あの白判定、無理に庇ってる気がして信用できない」「昨夜守ったのは◯◯です」" - "「あと処刑できる回数を考えると…」)。" - f"役職 CO したい場合は `co_declaration` を `{format_co_claim_options(separator=' / ')}` のいずれかに設定し、" - "`public_message` 側は「実は私、占い師なんだ」のように自然に名乗ってから能力結果を続ける。" - "`co_declaration` を設定しないなら `null`。" + """Day-discussion task instruction. + + Body lives in ``master/task_daytime_speech.md``. Two optional + paragraphs are gated by template ``{{#if}}`` blocks: + + * ``include_day2_round1_results_block`` — turns on when + ``day_number >= 2 and discussion_round == 1`` so the LLM is + reminded to surface previous-night ability results in their + first speech of the day. + * ``include_day1_round1_wolf_madman_block`` — wolf/madman-only + branch on day-1 round-1 that walks the 占い師騙り / 霊媒師騙り + / 潜伏 triad. Other roles never see partner / fake-CO tactics. + """ + return render_template( + _TASK_DAYTIME_SPEECH_TEMPLATE, + day_number=day_number, + co_claim_options=format_co_claim_options(separator=" / "), + include_day2_round1_results_block=( + day_number >= 2 and discussion_round == 1 + ), + include_day1_round1_wolf_madman_block=( + day_number == 1 + and discussion_round == 1 + and role in (Role.WEREWOLF, Role.MADMAN) + ), ) - if day_number >= 2 and discussion_round == 1: - base += ( - " これは day 2 以降の 1 巡目発言です。" - " 占い師・霊媒師・騎士として既に名乗っている、または今初めて名乗る場合は、" - "前夜の能力結果をこの発言で添えてください。" - " 占い師なら対象 + 白/黒 + 占い理由、" - "霊媒師なら前日処刑者 + 人狼/人狼ではない/結果なし、" - "騎士なら名乗る局面で合法な護衛履歴 (護衛日 + 護衛先) を日付順に出し、" - "平和な朝なら護衛成功も合わせて伝えます。" - " 結果を持つはずの役職を名乗っているのに 1 巡目で結果を出さないと、" - "信用低下や破綻疑いにつながります。" - " ただし `public_message` 内ではこの「白/黒」「結果なし」のような内部メモ語彙を" - "そのまま読み上げず、「結果は人狼でした」「占ったけど人狼じゃなかった」" - "「占ってみた感触は白かな」のように自然な発話に書き直す。" - " 名乗り直すならこのターンで `co_declaration` を立て、" - "`public_message` でも自然な日本語で名乗りと結果を述べてください。" - ) - if day_number == 1 and discussion_round == 1 and role in (Role.WEREWOLF, Role.MADMAN): - base += ( - " day 1 の 1 巡目で偽 CO を選ぶ場合、占い師騙り・霊媒師騙り・潜伏の 3 択を比較してください。" - " 霊媒師騙りに出ると、真霊媒との 1-2、または別の騙り狼との組み合わせで 2-2 を作りに行けます。" - " 占い師騙り CO 数・縄数・潜伏で白を取る余地と比べて、得な方を選びます。" - " 霊媒師騙りで day 1 に CO する場合は、まだ処刑がないので初日の霊媒結果は出さず、" - "翌日処刑があった時に結果を出す立ち回りだと添えてください。" - ) - return base def task_vote( @@ -1363,118 +1348,67 @@ def task_vote( role: Role | None = None, wolf_partner_tokens: Sequence[str] = (), ) -> str: - """Candidates are `席{N} {display_name}` tokens; target_name must echo one back. + """Vote-phase task instruction. + + Body lives in ``master/task_vote.md``. Candidates are + ``席{N} {display_name}`` tokens; target_name must echo one back. - `role` + `wolf_partner_tokens` are an additive, wolf-only enrichment: when the - voter is a werewolf and at least one alive partner exists, append a checklist - that names the partner and walks熟練狼's vote-discipline tradeoffs (身内票 / - ライン切り / 票逸らしリスク / 決選投票). Other roles always get the base text - so partner identity and wolf-side voting tactics never reach non-wolf prompts. + ``role`` + ``wolf_partner_tokens`` are an additive, wolf-only + enrichment: the template's ``{{#if has_wolf_block}}`` branch + appends a checklist that names the partner and walks 熟練狼's + vote-discipline tradeoffs (身内票 / ライン切り / 票逸らしリスク + / 決選投票). Other roles flip ``has_wolf_block`` off so partner + identity and wolf-side voting tactics never reach non-wolf + prompts. """ - names = "、".join(candidate_tokens) - runoff_note = "これは決選投票です。" if runoff else "" - base = ( - f"{runoff_note}投票先として合法な候補は: {names}\n" - " `intent=vote`、`target_name` に候補トークン (例: `席3 Alice`) のいずれかを" - " 厳密に一致させて返してください。`席番号` を含めないと同名の別席と区別できません。" - " どうしても棄権したい場合は `intent=skip` を返し、`target_name` は `null` にします。\n" - "- 投票先は単体黒要素だけでなく、その人物が人狼なら相方候補は誰か、" - "2 人狼セットとして票筋・噛み筋が自然かも考えて選んでください。" - "投票理由は長くせず、最終判断で相方候補との整合を加味します。" - ) - if role is not Role.WEREWOLF or not wolf_partner_tokens: - return base - partners = "、".join(wolf_partner_tokens) - runoff_extra = ( - "\n- 決選投票では、相方救済の成功見込み、自分の透けリスク、" - "相方を切って翌日以降に残る価値、PP/RPP の近さと縄数を比較してください。" - if runoff - else "" - ) - wolf_block = ( - "\n\nあなたは人狼です。仲間の人狼: " - f"{partners}。" - "\n- 投票は翌日以降に票筋として読まれます。" - "仲間が公開ログ上で処刑濃厚なときに自分だけ弱い別候補へ票を逸らすと、" - "相方を庇った狼として透けやすくなります。" - "\n- 相方を救えない局面では、身内票やライン切りで相方へ投票することも検討してください。" - "投票理由は公開ログ上の発言・CO 矛盾・票筋・視点漏れに沿って自然に作り、" - "前日までの自分の発言や騙り結果と矛盾しないようにします。" - "\n- 相方を救う票は、自然に票が集まり得る対抗候補がいて、" - "自分の過去発言・投票理由・騙り結果と矛盾しないときだけ選んでください。" - "根拠の薄い票逸らしは狼ラインを濃くします。" - "\n- 救う票・切る票・票逸らしのどれも、翌日以降に自分と相方の 2 人狼セットとして読まれても" - "説明できるかを必ず確認してください。" - f"{runoff_extra}" - "\n- 最後は必ず合法候補トークンから 1 名を返します。" - "困っても安易に skip しないでください。" + has_wolf_block = role is Role.WEREWOLF and bool(wolf_partner_tokens) + return render_template( + _TASK_VOTE_TEMPLATE, + runoff_note="これは決選投票です。" if runoff else "", + names="、".join(candidate_tokens), + has_wolf_block=has_wolf_block, + partners="、".join(wolf_partner_tokens) if has_wolf_block else "", + runoff=runoff and has_wolf_block, ) - return base + wolf_block def task_night_action(kind: SubmissionType, candidate_tokens: Sequence[str]) -> str: - """Candidates are `席{N} {display_name}` tokens; target_name must echo one back.""" - names = "、".join(candidate_tokens) + """Night-action task instruction. + + Body lives in ``master/task_night_action.md``. Candidates are + ``席{N} {display_name}`` tokens; target_name must echo one back. + + Three role-scoped advice paragraphs (wolf-attack value scoring, + knight-guard tradeoffs, seer-divine target value) are gated by + template ``{{#if}}`` blocks keyed off the action kind so each + role only sees its own decision checklist. + """ label = { SubmissionType.WOLF_ATTACK: "襲撃", SubmissionType.SEER_DIVINE: "占い", SubmissionType.KNIGHT_GUARD: "護衛", }[kind] - extra = "" - if kind is SubmissionType.WOLF_ATTACK: - extra = ( - " 仲間の人狼が人狼チャットで案を出している場合、強い反対理由がなければ" - " その案に合わせてください。意見が割れると襲撃が空振りになります。\n" - " 候補ごとに「襲撃価値」「護衛されやすさ」「騎士候補度」「翌日の説明しやすさ」" - "を短く比較してから 1 名に決めてください。" - " 単独真寄りの情報役・確白寄り・進行役は襲撃価値も護衛されやすさも高い両刃です。" - " 騎士っぽい相手を「騎士探し」として先に噛む選択も忘れずに検討してください。" - ) - elif kind is SubmissionType.KNIGHT_GUARD: - extra = ( - " 候補ごとに「鉄板護衛すべき価値」" - "「今夜実際に噛まれそうか (護衛を読まれて避けられそうか含む)」" - "「GJ の価値」「次夜に同じ相手を守れないリスク」" - "「捨て護衛で本命護衛余地を残す価値」" - "を短く比較してから 1 名に決めてください。" - " 捨て護衛を選ぶ場合も、この bot では合法候補から 1 名を選びます。" - " 未提出や対象なしや skip にしてはいけません。" - ) - elif kind is SubmissionType.SEER_DIVINE: - extra = ( - " 候補ごとに「占い価値 (灰を狭める効果、CO 真偽比較への寄与、自分視点の進行整理)」" - "「対抗 CO の白先や囲い候補と比較して真偽判定に効くか」" - "「投票・票変え・誘導との整合 (浮いた位置・根拠の薄い強い誘導をしている位置)」" - "「白でも黒でも情報が落ちるか (白なら進行役候補、黒なら処刑提案)」" - "を短く比較してから 1 名に決めてください。" - " 既に自分が占った位置・今日ほぼ処刑されそうな位置・" - "公開情報で十分処理対象になっている位置・極端に発言が少なく白黒どちらでも議論が伸びにくい位置は占い価値が下がります。" - " 今夜襲撃される可能性が高い位置でも、結果が CO 真偽や囲い疑惑の整理に直結するなら候補に残してよいです" - "(死亡しても占い結果は得られます)。" - ) - return ( - f"夜です。{label} 対象を 1 名選んでください。合法候補: {names}\n" - " `intent=night_action`、`target_name` に候補トークン (例: `席3 Alice`) のいずれかを" - " 厳密に一致させて返してください。`席番号` を含めないと同名の別席と区別できません。" - f"{extra}" + return render_template( + _TASK_NIGHT_ACTION_TEMPLATE, + label=label, + names="、".join(candidate_tokens), + is_wolf_attack=kind is SubmissionType.WOLF_ATTACK, + is_knight_guard=kind is SubmissionType.KNIGHT_GUARD, + is_seer_divine=kind is SubmissionType.SEER_DIVINE, ) def task_wolf_chat(partner_tokens: Sequence[str], candidate_tokens: Sequence[str]) -> str: - """Ask a wolf to post a short coordination message to the wolves-only chat.""" - partners = "、".join(partner_tokens) if partner_tokens else "(なし)" - names = "、".join(candidate_tokens) - return ( - f"夜になりました。仲間の人狼: {partners}。人狼チャット (村人には非公開) で" - f" 襲撃対象を調整してください。候補: {names}\n" - " `intent=speak` と `public_message` に 1 名の襲撃候補とその理由を" - " 80〜150 字で書いてください。" - " 理由には「襲撃価値 (情報役噛み/白位置噛み/意見噛み/騎士探し/SG 残しのどれか)」" - "「護衛されそうか」「本人が騎士っぽいか (騎士候補)」「相方案への賛否」のうち" - " 重要な 1〜2 点を含めてください。" - " 仲間が既に案を出している場合は、護衛リスクと襲撃価値を比較したうえで" - " 最終的に 1 人に揃えることを優先してください。" - " 話すことがなければ `intent=skip` を返してください。" + """Wolf-chat coordination task instruction. + + Body lives in ``master/task_wolf_chat.md``. Asks a wolf to post a + short coordination line to the wolves-only chat naming the + intended attack target with concise reasoning. + """ + return render_template( + _TASK_WOLF_CHAT_TEMPLATE, + partners="、".join(partner_tokens) if partner_tokens else "(なし)", + names="、".join(candidate_tokens), ) diff --git a/src/wolfbot/prompts/templates/master/task_daytime_speech.md b/src/wolfbot/prompts/templates/master/task_daytime_speech.md new file mode 100644 index 0000000..2958e5f --- /dev/null +++ b/src/wolfbot/prompts/templates/master/task_daytime_speech.md @@ -0,0 +1,2 @@ +現在は day {{day_number}} の議論フェイズです。 必要と感じた場合のみ `intent=speak` を返し、`public_message` に 80〜300 字で短い発言を書いてください。 発言したくない場合は `intent=skip` と明示してください。 疑い先を出すときは、単体の怪しさだけでなく、その人物が人狼なら相方候補は誰か、2 人狼セットとして票筋・噛み筋が自然かも必要に応じて短く触れてください。全候補のペアを長く列挙せず、今の結論に効く 1〜2 点だけを出してください。 +発話 (`public_message`) のルール: 自然な日本語で喋ること。「CO」「占いCO」「霊媒CO」「騎士CO」「黒判定」「白判定」「ライン」「グレー」「グレラン」「縄」「PP」「RPP」「ローラー」「ロラ」「破綻」「確白」「確黒」「パンダ」「鉄板護衛」「捨て護衛」「噛み筋」「票筋」「視点漏れ」「身内切り」「囲い」など、プレイヤー間で使われがちなメタ用語は `public_message` 内で使わない(これらは内部の `reason_summary` や思考メモには使ってよい)。口に出すときは状況や感情として描写する(例: 「あの白判定、無理に庇ってる気がして信用できない」「昨夜守ったのは◯◯です」「あと処刑できる回数を考えると…」)。役職 CO したい場合は `co_declaration` を `{{co_claim_options}}` のいずれかに設定し、`public_message` 側は「実は私、占い師なんだ」のように自然に名乗ってから能力結果を続ける。`co_declaration` を設定しないなら `null`。{{#if include_day2_round1_results_block}} これは day 2 以降の 1 巡目発言です。 占い師・霊媒師・騎士として既に名乗っている、または今初めて名乗る場合は、前夜の能力結果をこの発言で添えてください。 占い師なら対象 + 白/黒 + 占い理由、霊媒師なら前日処刑者 + 人狼/人狼ではない/結果なし、騎士なら名乗る局面で合法な護衛履歴 (護衛日 + 護衛先) を日付順に出し、平和な朝なら護衛成功も合わせて伝えます。 結果を持つはずの役職を名乗っているのに 1 巡目で結果を出さないと、信用低下や破綻疑いにつながります。 ただし `public_message` 内ではこの「白/黒」「結果なし」のような内部メモ語彙をそのまま読み上げず、「結果は人狼でした」「占ったけど人狼じゃなかった」「占ってみた感触は白かな」のように自然な発話に書き直す。 名乗り直すならこのターンで `co_declaration` を立て、`public_message` でも自然な日本語で名乗りと結果を述べてください。{{/if}}{{#if include_day1_round1_wolf_madman_block}} day 1 の 1 巡目で偽 CO を選ぶ場合、占い師騙り・霊媒師騙り・潜伏の 3 択を比較してください。 霊媒師騙りに出ると、真霊媒との 1-2、または別の騙り狼との組み合わせで 2-2 を作りに行けます。 占い師騙り CO 数・縄数・潜伏で白を取る余地と比べて、得な方を選びます。 霊媒師騙りで day 1 に CO する場合は、まだ処刑がないので初日の霊媒結果は出さず、翌日処刑があった時に結果を出す立ち回りだと添えてください。{{/if}} \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/task_night_action.md b/src/wolfbot/prompts/templates/master/task_night_action.md new file mode 100644 index 0000000..3db0b47 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/task_night_action.md @@ -0,0 +1,3 @@ +夜です。{{label}} 対象を 1 名選んでください。合法候補: {{names}} + `intent=night_action`、`target_name` に候補トークン (例: `席3 Alice`) のいずれかを 厳密に一致させて返してください。`席番号` を含めないと同名の別席と区別できません。{{#if is_wolf_attack}} 仲間の人狼が人狼チャットで案を出している場合、強い反対理由がなければ その案に合わせてください。意見が割れると襲撃が空振りになります。 + 候補ごとに「襲撃価値」「護衛されやすさ」「騎士候補度」「翌日の説明しやすさ」を短く比較してから 1 名に決めてください。 単独真寄りの情報役・確白寄り・進行役は襲撃価値も護衛されやすさも高い両刃です。 騎士っぽい相手を「騎士探し」として先に噛む選択も忘れずに検討してください。{{/if}}{{#if is_knight_guard}} 候補ごとに「鉄板護衛すべき価値」「今夜実際に噛まれそうか (護衛を読まれて避けられそうか含む)」「GJ の価値」「次夜に同じ相手を守れないリスク」「捨て護衛で本命護衛余地を残す価値」を短く比較してから 1 名に決めてください。 捨て護衛を選ぶ場合も、この bot では合法候補から 1 名を選びます。 未提出や対象なしや skip にしてはいけません。{{/if}}{{#if is_seer_divine}} 候補ごとに「占い価値 (灰を狭める効果、CO 真偽比較への寄与、自分視点の進行整理)」「対抗 CO の白先や囲い候補と比較して真偽判定に効くか」「投票・票変え・誘導との整合 (浮いた位置・根拠の薄い強い誘導をしている位置)」「白でも黒でも情報が落ちるか (白なら進行役候補、黒なら処刑提案)」を短く比較してから 1 名に決めてください。 既に自分が占った位置・今日ほぼ処刑されそうな位置・公開情報で十分処理対象になっている位置・極端に発言が少なく白黒どちらでも議論が伸びにくい位置は占い価値が下がります。 今夜襲撃される可能性が高い位置でも、結果が CO 真偽や囲い疑惑の整理に直結するなら候補に残してよいです(死亡しても占い結果は得られます)。{{/if}} \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/task_vote.md b/src/wolfbot/prompts/templates/master/task_vote.md new file mode 100644 index 0000000..a07c88e --- /dev/null +++ b/src/wolfbot/prompts/templates/master/task_vote.md @@ -0,0 +1,11 @@ +{{runoff_note}}投票先として合法な候補は: {{names}} + `intent=vote`、`target_name` に候補トークン (例: `席3 Alice`) のいずれかを 厳密に一致させて返してください。`席番号` を含めないと同名の別席と区別できません。 どうしても棄権したい場合は `intent=skip` を返し、`target_name` は `null` にします。 +- 投票先は単体黒要素だけでなく、その人物が人狼なら相方候補は誰か、2 人狼セットとして票筋・噛み筋が自然かも考えて選んでください。投票理由は長くせず、最終判断で相方候補との整合を加味します。{{#if has_wolf_block}} + +あなたは人狼です。仲間の人狼: {{partners}}。 +- 投票は翌日以降に票筋として読まれます。仲間が公開ログ上で処刑濃厚なときに自分だけ弱い別候補へ票を逸らすと、相方を庇った狼として透けやすくなります。 +- 相方を救えない局面では、身内票やライン切りで相方へ投票することも検討してください。投票理由は公開ログ上の発言・CO 矛盾・票筋・視点漏れに沿って自然に作り、前日までの自分の発言や騙り結果と矛盾しないようにします。 +- 相方を救う票は、自然に票が集まり得る対抗候補がいて、自分の過去発言・投票理由・騙り結果と矛盾しないときだけ選んでください。根拠の薄い票逸らしは狼ラインを濃くします。 +- 救う票・切る票・票逸らしのどれも、翌日以降に自分と相方の 2 人狼セットとして読まれても説明できるかを必ず確認してください。{{#if runoff}} +- 決選投票では、相方救済の成功見込み、自分の透けリスク、相方を切って翌日以降に残る価値、PP/RPP の近さと縄数を比較してください。{{/if}} +- 最後は必ず合法候補トークンから 1 名を返します。困っても安易に skip しないでください。{{/if}} \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/task_wolf_chat.md b/src/wolfbot/prompts/templates/master/task_wolf_chat.md new file mode 100644 index 0000000..0975afd --- /dev/null +++ b/src/wolfbot/prompts/templates/master/task_wolf_chat.md @@ -0,0 +1,2 @@ +夜になりました。仲間の人狼: {{partners}}。人狼チャット (村人には非公開) で 襲撃対象を調整してください。候補: {{names}} + `intent=speak` と `public_message` に 1 名の襲撃候補とその理由を 80〜150 字で書いてください。 理由には「襲撃価値 (情報役噛み/白位置噛み/意見噛み/騎士探し/SG 残しのどれか)」「護衛されそうか」「本人が騎士っぽいか (騎士候補)」「相方案への賛否」のうち 重要な 1〜2 点を含めてください。 仲間が既に案を出している場合は、護衛リスクと襲撃価値を比較したうえで 最終的に 1 人に揃えることを優先してください。 話すことがなければ `intent=skip` を返してください。 \ No newline at end of file From 1183fbeb61a68a5f503076d8e0e2b06776d027cd Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 11:06:24 +0900 Subject: [PATCH 101/133] refactor(prompts): extract Master STT + text-analyzer prompts to .md templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new templates under prompts/templates/master/: * stt_audio_analyzer.md — multimodal Gemini transcribe+analyze prompt (audio in, structured JSON out). * stt_text_analyzer.md — Groq Whisper → text analyzer follow-up (transcript in, structured JSON out). * text_message_analyzer.md — typed-message addressee/CO/role-callout analyzer (text in, structured JSON out). Each template fills {{co_claim_options}} / {{seat_range_label}} from the canonical Python constants. The per-game roster block (from _format_roster_block) is appended OUTSIDE the template since it's a runtime list, keeping the templates static and shareable. Tests in test_stt_roster_prompt.py adapted to compare against the template-rendered body (= _build_*(None)) instead of the removed _SYSTEM_PROMPT_BASE / _ANALYZER_PROMPT_BASE class constants — the behavior under test (roster appended at the bottom, empty roster returns template-only) is unchanged. 1238 tests + ruff + mypy green. --- src/wolfbot/master/stt_service.py | 96 ++++++------------- src/wolfbot/master/text_analyzer.py | 30 +----- .../templates/master/stt_audio_analyzer.md | 30 ++++++ .../templates/master/stt_text_analyzer.md | 22 +++++ .../templates/master/text_message_analyzer.md | 16 ++++ tests/test_stt_roster_prompt.py | 31 +++--- 6 files changed, 119 insertions(+), 106 deletions(-) create mode 100644 src/wolfbot/prompts/templates/master/stt_audio_analyzer.md create mode 100644 src/wolfbot/prompts/templates/master/stt_text_analyzer.md create mode 100644 src/wolfbot/prompts/templates/master/text_message_analyzer.md diff --git a/src/wolfbot/master/stt_service.py b/src/wolfbot/master/stt_service.py index 3de2ae1..8882876 100644 --- a/src/wolfbot/master/stt_service.py +++ b/src/wolfbot/master/stt_service.py @@ -27,6 +27,7 @@ VILLAGE_SIZE, format_co_claim_options, ) +from wolfbot.llm.template import render_template # (seat_no, display_name) pair used to ground the analyzer's # ``addressed_name`` extraction in the actual VC roster. Passing this @@ -323,50 +324,25 @@ class GeminiAudioAnalyzer: input tokens). Does NOT import ``httpx`` at module level. """ - _SYSTEM_PROMPT_BASE: str = ( - "あなたは人狼ゲームの音声ログ分析エンジンです。\n" - "渡された音声(日本語)を書き起こし、以下のJSON形式で返してください。\n" - "JSONのみ返答し、他のテキストは含めないでください。\n\n" - "```json\n" - "{\n" - ' "transcript": "発話の書き起こし全文",\n' - ' "summary": "1文の要約(30文字以内)",\n' - ' "confidence": 0.95,\n' - ' "co_claim": null,\n' - ' "vote_target_seat": null,\n' - ' "stance": {},\n' - ' "addressed_name": null,\n' - ' "addressed_seat_no": null,\n' - ' "role_callout": null\n' - "}\n" - "```\n\n" - "フィールド説明:\n" - "- transcript: 音声の書き起こし全文(日本語)\n" - "- summary: 発言内容の1文要約\n" - "- confidence: 書き起こし精度の自己評価(0.0〜1.0)\n" - f"- co_claim: 役職CO(自称)があれば {format_co_claim_options()}、なければ null\n" - f"- vote_target_seat: 処刑対象として名指しした席番号({_seat_range_label()})、なければ null\n" - "- stance: 言及した席への態度 {\"席番号\": \"positive\"/\"negative\"/\"neutral\"}\n" - "- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 \"セツ\"、\"ジーナさん\"、\"席3\"、\"3番\")、なければ null。" - "「みんな」「全員」など全体への呼びかけは null。さん/くん/ちゃん 等の敬称は付けたままでも構わない。\n" - "- addressed_seat_no: 上の addressed_name と同じ人物の席番号(整数)、または null。roster が与えられているときは必ず埋める。\n" - "- role_callout: 役職への名乗り出を求める呼びかけ、または一般的な情報請求があれば " - "\"seer\"/\"medium\"/\"knight\"/\"info_request\" のいずれか、なければ null。" - "特定役職を名指しした呼びかけ (例「占い師の方は名乗り出てください」「霊媒師いますか?」「騎士は誰?」「占いCO お願いします」) → 該当役職。" - "役職を限定しない一般的な情報請求 (例「誰か怪しい人いる?」「みんな意見を聞かせて」「気になる人を挙げて」" - "「誰か役職持ち出てきて」「みんなどう思う?」「初日だけど何か情報ない?」) → \"info_request\"。" - "ただし役職名を単に話題にしただけ (例「占い師の判定が気になる」「霊媒師の信用は?」) は null。" - "個人への質問 (例「セツさん、どう思う?」) は addressed_name 側で扱い、role_callout は null。" - "全員/全体への問いかけで意見・情報・怪しい相手・役職持ちを求めているときに \"info_request\" を立てる。\n" - "\n音声が不明瞭な場合は confidence を低くし、transcript は聞き取れた範囲で。" - ) + _SYSTEM_PROMPT_TEMPLATE = "master/stt_audio_analyzer" @classmethod def _build_system_prompt( cls, roster: Sequence[RosterEntry] | None ) -> str: - """Static base + (optional) roster grounding block.""" - return cls._SYSTEM_PROMPT_BASE + _format_roster_block(roster) + """Static template body + (optional) roster grounding block. + + Body lives in `prompts/templates/master/stt_audio_analyzer.md` + with `{{co_claim_options}}` / `{{seat_range_label}}` filled + from the canonical Python constants. The roster block is + appended outside the template (it's a runtime list rendered + per-game) so the template stays static. + """ + return render_template( + cls._SYSTEM_PROMPT_TEMPLATE, + co_claim_options=format_co_claim_options(), + seat_range_label=_seat_range_label(), + ) + _format_roster_block(roster) def __init__( self, @@ -569,41 +545,23 @@ class GroqWhisperAudioAnalyzer: flowing even when the analyzer LLM is briefly down. """ - _ANALYZER_PROMPT_BASE: str = ( - "あなたは人狼ゲームの発話内容を分析するエンジンです。\n" - "以下の書き起こし(日本語)を読んで、以下のJSONのみを返してください。\n" - "他の文字は含めないでください。\n\n" - "{\n" - ' "summary": "1文の要約(30文字以内)",\n' - ' "role_callout": null,\n' - ' "co_claim": null,\n' - ' "vote_target_seat": null,\n' - ' "stance": {},\n' - ' "addressed_name": null,\n' - ' "addressed_seat_no": null\n' - "}\n\n" - "フィールド説明:\n" - "- summary: 発言内容の1文要約\n" - f"- co_claim: 役職CO(自称)があれば {format_co_claim_options()}、なければ null\n" - f"- vote_target_seat: 処刑対象として名指しした席番号({_seat_range_label()})、なければ null\n" - "- stance: 言及した席への態度 {\"席番号\": \"positive\"/\"negative\"/\"neutral\"}\n" - "- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 \"セツ\"、\"ジーナさん\"、\"席3\"、\"3番\")、なければ null。" - "「みんな」「全員」など全体への呼びかけは null。\n" - "- addressed_seat_no: 上の addressed_name と同じ人物の席番号(整数)、または null。roster が与えられているときは必ず埋める。\n" - "- role_callout: 役職への名乗り出を求める呼びかけ、または一般的な情報請求があれば " - "\"seer\"/\"medium\"/\"knight\"/\"info_request\" のいずれか、なければ null。" - "特定役職を名指しした呼びかけ (例「占い師の方は名乗り出てください」「霊媒師いますか?」「騎士は誰?」) → 該当役職。" - "役職を限定しない一般的な情報請求 (例「誰か怪しい人いる?」「みんな意見を聞かせて」「気になる人を挙げて」" - "「誰か役職持ち出てきて」「みんなどう思う?」) → \"info_request\"。" - "ただし役職名を単に話題にしただけ (例「占い師の判定が気になる」) は null。" - "全員/全体への問いかけで意見・情報・怪しい相手・役職持ちを求めているときに \"info_request\" を立てる。" - ) + _ANALYZER_PROMPT_TEMPLATE = "master/stt_text_analyzer" @classmethod def _build_analyzer_prompt( cls, roster: Sequence[RosterEntry] | None ) -> str: - return cls._ANALYZER_PROMPT_BASE + _format_roster_block(roster) + """Static template body + (optional) roster grounding block. + + Body lives in `prompts/templates/master/stt_text_analyzer.md`. + The roster block is appended outside the template (per-game + runtime list) so the template stays static. + """ + return render_template( + cls._ANALYZER_PROMPT_TEMPLATE, + co_claim_options=format_co_claim_options(), + seat_range_label=_seat_range_label(), + ) + _format_roster_block(roster) def __init__( self, diff --git a/src/wolfbot/master/text_analyzer.py b/src/wolfbot/master/text_analyzer.py index e828ce3..107b39c 100644 --- a/src/wolfbot/master/text_analyzer.py +++ b/src/wolfbot/master/text_analyzer.py @@ -37,6 +37,7 @@ ROLE_CALLOUT_VALUES, format_co_claim_options, ) +from wolfbot.llm.template import render_template log = logging.getLogger(__name__) @@ -107,32 +108,11 @@ class GeminiTextAnalyzer: flash-lite used for audio so cost stays bounded. """ - _SYSTEM_PROMPT: str = ( - "あなたは人狼ゲームのチャット発言分析エンジンです。\n" - "渡された発言テキスト(日本語)を読み、以下のJSON形式で返してください。\n" - "JSONのみ返答し、他のテキストは含めないでください。\n\n" - "```json\n" - "{\n" - ' "co_claim": null,\n' - ' "addressed_name": null,\n' - ' "role_callout": null\n' - "}\n" - "```\n\n" - "フィールド説明:\n" - f"- co_claim: 役職CO(自称)があれば {format_co_claim_options()}、なければ null\n" - "- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 \"セツ\"、\"ジーナさん\"、\"席3\"、\"3番\")、なければ null。" - "「みんな」「全員」など全体への呼びかけは null。さん/くん/ちゃん 等の敬称は付けたままでも構わない。" - "発言内で他プレイヤーに言及するだけ(例: 『セツの判定が気になる』)は呼びかけではないので null。" - "明確な宛先のある呼びかけ(例: 『セツさん、どう思う』)のみ設定。\n" - "- role_callout: 役職への名乗り出を求める呼びかけ、または一般的な情報請求があれば " - "\"seer\"/\"medium\"/\"knight\"/\"info_request\" のいずれか、なければ null。" - "特定役職を名指しした呼びかけ (例「占い師の方は名乗り出てください」「霊媒師いますか?」「騎士は誰?」) → 該当役職。" - "役職を限定しない一般的な情報請求 (例「誰か怪しい人いる?」「みんな意見を聞かせて」「気になる人を挙げて」" - "「誰か役職持ち出てきて」「みんなどう思う?」「初日だけど何か情報ない?」) → \"info_request\"。" - "ただし役職名を単に話題にしただけ (例「占い師の判定が気になる」「シゲミチが霊媒主張した」) は null。" - "個人への質問 (例「セツさん、どう思う?」) は addressed_name 側で扱い、role_callout は null。" - "全員/全体への問いかけで意見・情報・怪しい相手を求めているときに \"info_request\" を立てる。" + _SYSTEM_PROMPT: str = render_template( + "master/text_message_analyzer", + co_claim_options=format_co_claim_options(), ) + """System prompt body — see prompts/templates/master/text_message_analyzer.md.""" def __init__( self, diff --git a/src/wolfbot/prompts/templates/master/stt_audio_analyzer.md b/src/wolfbot/prompts/templates/master/stt_audio_analyzer.md new file mode 100644 index 0000000..6f66364 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/stt_audio_analyzer.md @@ -0,0 +1,30 @@ +あなたは人狼ゲームの音声ログ分析エンジンです。 +渡された音声(日本語)を書き起こし、以下のJSON形式で返してください。 +JSONのみ返答し、他のテキストは含めないでください。 + +```json +{ + "transcript": "発話の書き起こし全文", + "summary": "1文の要約(30文字以内)", + "confidence": 0.95, + "co_claim": null, + "vote_target_seat": null, + "stance": {}, + "addressed_name": null, + "addressed_seat_no": null, + "role_callout": null +} +``` + +フィールド説明: +- transcript: 音声の書き起こし全文(日本語) +- summary: 発言内容の1文要約 +- confidence: 書き起こし精度の自己評価(0.0〜1.0) +- co_claim: 役職CO(自称)があれば {{co_claim_options}}、なければ null +- vote_target_seat: 処刑対象として名指しした席番号({{seat_range_label}})、なければ null +- stance: 言及した席への態度 {"席番号": "positive"/"negative"/"neutral"} +- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 "セツ"、"ジーナさん"、"席3"、"3番")、なければ null。「みんな」「全員」など全体への呼びかけは null。さん/くん/ちゃん 等の敬称は付けたままでも構わない。 +- addressed_seat_no: 上の addressed_name と同じ人物の席番号(整数)、または null。roster が与えられているときは必ず埋める。 +- role_callout: 役職への名乗り出を求める呼びかけ、または一般的な情報請求があれば "seer"/"medium"/"knight"/"info_request" のいずれか、なければ null。特定役職を名指しした呼びかけ (例「占い師の方は名乗り出てください」「霊媒師いますか?」「騎士は誰?」「占いCO お願いします」) → 該当役職。役職を限定しない一般的な情報請求 (例「誰か怪しい人いる?」「みんな意見を聞かせて」「気になる人を挙げて」「誰か役職持ち出てきて」「みんなどう思う?」「初日だけど何か情報ない?」) → "info_request"。ただし役職名を単に話題にしただけ (例「占い師の判定が気になる」「霊媒師の信用は?」) は null。個人への質問 (例「セツさん、どう思う?」) は addressed_name 側で扱い、role_callout は null。全員/全体への問いかけで意見・情報・怪しい相手・役職持ちを求めているときに "info_request" を立てる。 + +音声が不明瞭な場合は confidence を低くし、transcript は聞き取れた範囲で。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/stt_text_analyzer.md b/src/wolfbot/prompts/templates/master/stt_text_analyzer.md new file mode 100644 index 0000000..7bc95d8 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/stt_text_analyzer.md @@ -0,0 +1,22 @@ +あなたは人狼ゲームの発話内容を分析するエンジンです。 +以下の書き起こし(日本語)を読んで、以下のJSONのみを返してください。 +他の文字は含めないでください。 + +{ + "summary": "1文の要約(30文字以内)", + "role_callout": null, + "co_claim": null, + "vote_target_seat": null, + "stance": {}, + "addressed_name": null, + "addressed_seat_no": null +} + +フィールド説明: +- summary: 発言内容の1文要約 +- co_claim: 役職CO(自称)があれば {{co_claim_options}}、なければ null +- vote_target_seat: 処刑対象として名指しした席番号({{seat_range_label}})、なければ null +- stance: 言及した席への態度 {"席番号": "positive"/"negative"/"neutral"} +- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 "セツ"、"ジーナさん"、"席3"、"3番")、なければ null。「みんな」「全員」など全体への呼びかけは null。 +- addressed_seat_no: 上の addressed_name と同じ人物の席番号(整数)、または null。roster が与えられているときは必ず埋める。 +- role_callout: 役職への名乗り出を求める呼びかけ、または一般的な情報請求があれば "seer"/"medium"/"knight"/"info_request" のいずれか、なければ null。特定役職を名指しした呼びかけ (例「占い師の方は名乗り出てください」「霊媒師いますか?」「騎士は誰?」) → 該当役職。役職を限定しない一般的な情報請求 (例「誰か怪しい人いる?」「みんな意見を聞かせて」「気になる人を挙げて」「誰か役職持ち出てきて」「みんなどう思う?」) → "info_request"。ただし役職名を単に話題にしただけ (例「占い師の判定が気になる」) は null。全員/全体への問いかけで意見・情報・怪しい相手・役職持ちを求めているときに "info_request" を立てる。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/text_message_analyzer.md b/src/wolfbot/prompts/templates/master/text_message_analyzer.md new file mode 100644 index 0000000..7d62671 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/text_message_analyzer.md @@ -0,0 +1,16 @@ +あなたは人狼ゲームのチャット発言分析エンジンです。 +渡された発言テキスト(日本語)を読み、以下のJSON形式で返してください。 +JSONのみ返答し、他のテキストは含めないでください。 + +```json +{ + "co_claim": null, + "addressed_name": null, + "role_callout": null +} +``` + +フィールド説明: +- co_claim: 役職CO(自称)があれば {{co_claim_options}}、なければ null +- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 "セツ"、"ジーナさん"、"席3"、"3番")、なければ null。「みんな」「全員」など全体への呼びかけは null。さん/くん/ちゃん 等の敬称は付けたままでも構わない。発言内で他プレイヤーに言及するだけ(例: 『セツの判定が気になる』)は呼びかけではないので null。明確な宛先のある呼びかけ(例: 『セツさん、どう思う』)のみ設定。 +- role_callout: 役職への名乗り出を求める呼びかけ、または一般的な情報請求があれば "seer"/"medium"/"knight"/"info_request" のいずれか、なければ null。特定役職を名指しした呼びかけ (例「占い師の方は名乗り出てください」「霊媒師いますか?」「騎士は誰?」) → 該当役職。役職を限定しない一般的な情報請求 (例「誰か怪しい人いる?」「みんな意見を聞かせて」「気になる人を挙げて」「誰か役職持ち出てきて」「みんなどう思う?」「初日だけど何か情報ない?」) → "info_request"。ただし役職名を単に話題にしただけ (例「占い師の判定が気になる」「シゲミチが霊媒主張した」) は null。個人への質問 (例「セツさん、どう思う?」) は addressed_name 側で扱い、role_callout は null。全員/全体への問いかけで意見・情報・怪しい相手を求めているときに "info_request" を立てる。 \ No newline at end of file diff --git a/tests/test_stt_roster_prompt.py b/tests/test_stt_roster_prompt.py index 2867e15..6bac7ca 100644 --- a/tests/test_stt_roster_prompt.py +++ b/tests/test_stt_roster_prompt.py @@ -43,7 +43,11 @@ def test_format_roster_block_lists_seats_and_variant_rules() -> None: def test_gemini_build_system_prompt_appends_roster_block() -> None: - base = GeminiAudioAnalyzer._SYSTEM_PROMPT_BASE + """``_build_system_prompt(roster)`` must equal the template-only + body (= ``_build_system_prompt(None)``) followed by the roster + block — so the LLM sees the static instructions first and the + per-game seat list at the bottom.""" + base = GeminiAudioAnalyzer._build_system_prompt(None) with_roster = GeminiAudioAnalyzer._build_system_prompt( [(3, "🦋ラキオ")] ) @@ -51,15 +55,18 @@ def test_gemini_build_system_prompt_appends_roster_block() -> None: assert "席3: 🦋ラキオ" in with_roster -def test_gemini_build_system_prompt_no_roster_equals_base() -> None: - assert ( - GeminiAudioAnalyzer._build_system_prompt(None) - == GeminiAudioAnalyzer._SYSTEM_PROMPT_BASE - ) +def test_gemini_build_system_prompt_no_roster_returns_template_only() -> None: + """No-roster case is the template body alone — the roster block + contributes only when at least one seat is supplied.""" + base = GeminiAudioAnalyzer._build_system_prompt(None) + assert "あなたは人狼ゲームの音声ログ分析エンジン" in base + # No roster section markers when roster is empty. + assert "席1:" not in base + assert "席2:" not in base def test_groq_build_analyzer_prompt_appends_roster_block() -> None: - base = GroqWhisperAudioAnalyzer._ANALYZER_PROMPT_BASE + base = GroqWhisperAudioAnalyzer._build_analyzer_prompt(None) with_roster = GroqWhisperAudioAnalyzer._build_analyzer_prompt( [(5, "🌙セツ")] ) @@ -67,8 +74,8 @@ def test_groq_build_analyzer_prompt_appends_roster_block() -> None: assert "席5: 🌙セツ" in with_roster -def test_groq_build_analyzer_prompt_no_roster_equals_base() -> None: - assert ( - GroqWhisperAudioAnalyzer._build_analyzer_prompt(None) - == GroqWhisperAudioAnalyzer._ANALYZER_PROMPT_BASE - ) +def test_groq_build_analyzer_prompt_no_roster_returns_template_only() -> None: + base = GroqWhisperAudioAnalyzer._build_analyzer_prompt(None) + assert "あなたは人狼ゲームの発話内容を分析するエンジン" in base + assert "席1:" not in base + assert "席2:" not in base From e19d606ccaa44abfcf79f50111f21de48bf94df7 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 11:10:10 +0900 Subject: [PATCH 102/133] refactor(prompts): extract Master narration + runoff intro to .md templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 11 new templates under prompts/templates/master/, one per narration kind / variant: * narration_setup_complete.md * narration_phase_change_day_discussion.md (day-1 / day-N branch) * narration_phase_change_day_vote.md * narration_phase_change_day_runoff.md * narration_phase_change_day_runoff_speech.md * narration_phase_change_night.md * narration_morning.md (casualty / no-casualty branch) * narration_execution.md (with optional NIGHT cue suffix) * narration_no_execution.md (default / runoff-tie / invalid branches) * narration_runoff_start.md * narration_victory.md (village / wolf / fallback raw_text) * narration_runoff_candidate_intro.md Each _narrate_* handler in master/narration.py now calls render_template(...) with template-keyed booleans and substitution vars instead of carrying f-string concatenated voice lines. The Levi tone (polite-machine progression management) lives in markdown where it can be edited per phrase without a Python diff. No content changes — verified by 1238 tests + ruff + mypy green. --- src/wolfbot/master/narration.py | 192 ++++++++++-------- .../templates/master/narration_execution.md | 1 + .../templates/master/narration_morning.md | 1 + .../master/narration_no_execution.md | 1 + .../narration_phase_change_day_discussion.md | 1 + .../narration_phase_change_day_runoff.md | 1 + ...arration_phase_change_day_runoff_speech.md | 1 + .../master/narration_phase_change_day_vote.md | 1 + .../master/narration_phase_change_night.md | 1 + .../narration_runoff_candidate_intro.md | 1 + .../master/narration_runoff_start.md | 1 + .../master/narration_setup_complete.md | 1 + .../templates/master/narration_victory.md | 1 + 13 files changed, 117 insertions(+), 87 deletions(-) create mode 100644 src/wolfbot/prompts/templates/master/narration_execution.md create mode 100644 src/wolfbot/prompts/templates/master/narration_morning.md create mode 100644 src/wolfbot/prompts/templates/master/narration_no_execution.md create mode 100644 src/wolfbot/prompts/templates/master/narration_phase_change_day_discussion.md create mode 100644 src/wolfbot/prompts/templates/master/narration_phase_change_day_runoff.md create mode 100644 src/wolfbot/prompts/templates/master/narration_phase_change_day_runoff_speech.md create mode 100644 src/wolfbot/prompts/templates/master/narration_phase_change_day_vote.md create mode 100644 src/wolfbot/prompts/templates/master/narration_phase_change_night.md create mode 100644 src/wolfbot/prompts/templates/master/narration_runoff_candidate_intro.md create mode 100644 src/wolfbot/prompts/templates/master/narration_runoff_start.md create mode 100644 src/wolfbot/prompts/templates/master/narration_setup_complete.md create mode 100644 src/wolfbot/prompts/templates/master/narration_victory.md diff --git a/src/wolfbot/master/narration.py b/src/wolfbot/master/narration.py index 402c503..170c198 100644 --- a/src/wolfbot/master/narration.py +++ b/src/wolfbot/master/narration.py @@ -38,6 +38,7 @@ from wolfbot.domain.durations import current_phase_durations from wolfbot.domain.enums import Phase from wolfbot.domain.models import LogEntry, Seat +from wolfbot.llm.template import render_template @dataclass(frozen=True) @@ -99,10 +100,9 @@ def _seat_label(seats_by_no: dict[int, Seat], seat_no: int | None) -> str: def _narrate_setup_complete(_entry: LogEntry, ctx: NarrationContext) -> NarrationOutput: return NarrationOutput( - voice_text=( - "本機は進行管理 LEVI でございます。" - f"参加者 {ctx.alive_count} 名で、これより人狼ゲームを開始致します。" - "役職の通知は各参加者の DM へ送付済みです。ご確認をお願い致します。" + voice_text=render_template( + "master/narration_setup_complete", + alive_count=ctx.alive_count, ), ) @@ -112,91 +112,94 @@ def _narrate_phase_change(entry: LogEntry, ctx: NarrationContext) -> NarrationOu target = entry.phase if target is Phase.DAY_DISCUSSION: secs = durations.discussion_for_day(max(1, ctx.day_number)) - if ctx.day_number <= 1: - voice = ( - "夜が明けました。1 日目の議論を開始致します。" - f"制限時間は {secs} 秒でございます。" + return NarrationOutput( + voice_text=render_template( + "master/narration_phase_change_day_discussion", + # Day 2+ skips the dawn line because MORNING already announced + # it with casualty info; the template's two if-branches encode + # this fork. + is_first_day=ctx.day_number <= 1, + is_later_day=ctx.day_number > 1, + day_number=ctx.day_number, + secs=secs, ) - else: - # Day 2+: the MORNING entry already announced "夜が明けました" with - # day_number + casualty info. Skip the duplicate dawn line here. - voice = ( - f"{ctx.day_number} 日目の議論を開始致します。" - f"制限時間は {secs} 秒でございます。" - ) - return NarrationOutput(voice_text=voice) + ) if target is Phase.DAY_VOTE: - voice = ( - "議論時間が終了致しました。" - f"投票フェイズへ移行致します。制限時間は {durations.vote} 秒でございます。" - "投票は DM の選択 UI からお願い致します。" + return NarrationOutput( + voice_text=render_template( + "master/narration_phase_change_day_vote", + secs=durations.vote, + ) ) - return NarrationOutput(voice_text=voice) if target is Phase.DAY_RUNOFF: - voice = ( - f"決選投票へ移行致します。制限時間は {durations.runoff} 秒でございます。" - "DM の選択 UI から再度ご投票をお願い致します。" + return NarrationOutput( + voice_text=render_template( + "master/narration_phase_change_day_runoff", + secs=durations.runoff, + ) ) - return NarrationOutput(voice_text=voice) if target is Phase.DAY_RUNOFF_SPEECH: - voice = ( - f"決選投票候補者の最終演説に移行致します。" - f"演説時間は {durations.runoff_speech_grace} 秒でございます。" + return NarrationOutput( + voice_text=render_template( + "master/narration_phase_change_day_runoff_speech", + secs=durations.runoff_speech_grace, + ) ) - return NarrationOutput(voice_text=voice) if target is Phase.NIGHT: - voice = ( - f"夜のフェイズへ移行致します。制限時間は {durations.night} 秒でございます。" - "役職を持つ参加者の方は、DM の選択 UI から行動をお願い致します。" + return NarrationOutput( + voice_text=render_template( + "master/narration_phase_change_night", + secs=durations.night, + ) ) - return NarrationOutput(voice_text=voice) # Unknown / GAME_OVER fallthrough — voice the raw entry text as-is. return NarrationOutput(voice_text=entry.text) def _narrate_morning(entry: LogEntry, ctx: NarrationContext) -> NarrationOutput: - # MORNING text has the form e.g. "夜が明けました。席3 Bob が無残な姿で発見された..." - # Levi reframes it neutrally with day_number embedded. - if entry.actor_seat is None: - # No one died. - voice = ( - f"夜が明けました。本日は {ctx.day_number} 日目でございます。" - "本日、犠牲者は出ておりません。" - f"現在の生存者は {ctx.alive_count} 名でございます。" - ) - else: - target = _seat_label(ctx.seats_by_no, entry.actor_seat) - voice = ( - f"夜が明けました。本日は {ctx.day_number} 日目でございます。" - f"昨夜、{target} の停止を確認致しました。" - f"現在の生存者は {ctx.alive_count} 名でございます。" + """MORNING entry → Levi reframing. + + Source text is e.g. "夜が明けました。席3 Bob が無残な姿で発見された..." + The template rewrites it neutrally with day_number + casualty info. + Two branches gated by has/no_casualty so the same template covers + both "犠牲者なし" and " の停止を確認" without a Python ternary. + """ + has_casualty = entry.actor_seat is not None + target = ( + _seat_label(ctx.seats_by_no, entry.actor_seat) if has_casualty else "" + ) + return NarrationOutput( + voice_text=render_template( + "master/narration_morning", + day_number=ctx.day_number, + has_casualty=has_casualty, + no_casualty=not has_casualty, + target=target, + alive_count=ctx.alive_count, ) - return NarrationOutput(voice_text=voice) + ) def _narrate_execution(entry: LogEntry, ctx: NarrationContext) -> NarrationOutput: + """EXECUTION entry → Levi confirmation + (optional) NIGHT cue. + + The state-machine path emits no PHASE_CHANGE log between the + EXECUTION row and the NIGHT phase entry, so role-holders would + otherwise get silent DMs without any "夜です" cue. The template's + ``append_night_intro`` branch is on iff the next phase is actually + NIGHT — when execution triggers a victory the game flips to + GAME_OVER and the VICTORY narration takes over instead. + """ headline, tally = _split_headline_and_tally(entry.text) target = _seat_label(ctx.seats_by_no, entry.actor_seat) - voice = f"{target} の処刑が確定致しました。" - # The state-machine path emits no PHASE_CHANGE log between the EXECUTION - # row and the NIGHT phase entry, so role-holders would otherwise get - # silent DMs without any "夜です" cue. Append the same line that - # `_narrate_phase_change` would have voiced for Phase.NIGHT — but only - # when the next phase is actually NIGHT (when execution triggers a - # victory the game flips to GAME_OVER and the VICTORY narration takes - # over instead). - if ctx.phase is Phase.NIGHT: - durations = current_phase_durations() - voice += ( - f"夜のフェイズへ移行致します。制限時間は {durations.night} 秒でございます。" - "役職を持つ参加者の方は、DM の選択 UI から行動をお願い致します。" - ) - # Strip the headline from the chat post — we voice it. Keep the tally so - # the audit trail of who voted whom stays in the channel. + going_to_night = ctx.phase is Phase.NIGHT + voice = render_template( + "master/narration_execution", + target=target, + append_night_intro=going_to_night, + night_secs=current_phase_durations().night if going_to_night else 0, + ) chat = tally if tally else None - # Headline alone is silent in chat (we already said it). If the entry - # text contained ONLY the headline (no tally) we still want a chat - # record for post-mortem; in practice all execution rows carry a tally. if chat is None and headline: chat = headline return NarrationOutput(voice_text=voice, chat_text=chat) @@ -204,11 +207,14 @@ def _narrate_execution(entry: LogEntry, ctx: NarrationContext) -> NarrationOutpu def _narrate_no_execution(entry: LogEntry, ctx: NarrationContext) -> NarrationOutput: headline, tally = _split_headline_and_tally(entry.text) - voice = "有効な投票が成立致しませんでした。本日は処刑なしで夜のフェイズへ移行致します。" - if "決選投票も同票" in headline: - voice = "決選投票も同票となりました。本日は処刑なしで夜のフェイズへ移行致します。" - elif "投票結果が無効" in headline: - voice = "投票結果が無効でございました。本日は処刑なしで夜のフェイズへ移行致します。" + is_runoff_tie = "決選投票も同票" in headline + is_invalid_vote = "投票結果が無効" in headline + voice = render_template( + "master/narration_no_execution", + is_runoff_tie=is_runoff_tie, + is_invalid_vote=is_invalid_vote and not is_runoff_tie, + is_default=not is_runoff_tie and not is_invalid_vote, + ) chat = tally if tally else None if chat is None and headline: chat = headline @@ -217,9 +223,7 @@ def _narrate_no_execution(entry: LogEntry, ctx: NarrationContext) -> NarrationOu def _narrate_runoff_start(entry: LogEntry, _ctx: NarrationContext) -> NarrationOutput: headline, tally = _split_headline_and_tally(entry.text) - voice = ( - "投票が同数となりました。決選投票へ移行致します。候補者については本機からの追って案内をご確認ください。" - ) + voice = render_template("master/narration_runoff_start") # Keep the candidate list + tally as text so players can see who's tied. chat_parts: list[str] = [] if headline: @@ -231,16 +235,27 @@ def _narrate_runoff_start(entry: LogEntry, _ctx: NarrationContext) -> NarrationO def _narrate_victory(entry: LogEntry, _ctx: NarrationContext) -> NarrationOutput: - # state_machine emits VICTORY text like 村人陣営の勝利 or 人狼陣営の勝利 - # (with a fullwidth exclamation). Reframe it in Levi's tone. + """VICTORY entry → Levi end-of-game line. + + state_machine emits text like 村人陣営の勝利 or 人狼陣営の勝利 (with a + fullwidth exclamation). Substring-match picks the template branch; + anything unrecognised falls into the ``is_other`` branch which + quotes the raw text verbatim — keeping a clean fallback for any + future faction string the engine adds. + """ text = entry.text.strip() - if "村人" in text or "村陣営" in text: - voice = "判定致します。村人陣営の勝利でございます。ゲームを終了致します。" - elif "人狼" in text or "狼陣営" in text: - voice = "判定致します。人狼陣営の勝利でございます。ゲームを終了致します。" - else: - voice = f"{text}。ゲームを終了致します。" - return NarrationOutput(voice_text=voice) + is_village = "村人" in text or "村陣営" in text + is_wolf = (not is_village) and ("人狼" in text or "狼陣営" in text) + is_other = not is_village and not is_wolf + return NarrationOutput( + voice_text=render_template( + "master/narration_victory", + is_village=is_village, + is_wolf=is_wolf, + is_other=is_other, + raw_text=text, + ) + ) def _narrate_role_reveal(entry: LogEntry, _ctx: NarrationContext) -> NarrationOutput: @@ -285,13 +300,16 @@ def render_runoff_candidate_intro(seat: Seat) -> str: speech is starting (the same way 「占い師の発表」 patterns name the speaker out loud). Uses the same emoji-stripping logic as ``_seat_label`` so the readout pronounces the persona name cleanly. + + Body: ``master/narration_runoff_candidate_intro.md``. """ name = seat.display_name.lstrip() while name and not (name[0].isalnum() or "぀" <= name[0] <= "ヿ"): name = name[1:] name = name.strip() or seat.display_name - return ( - f"続いて、{name} 様の最終演説でございます。どうぞ。" + return render_template( + "master/narration_runoff_candidate_intro", + name=name, ) diff --git a/src/wolfbot/prompts/templates/master/narration_execution.md b/src/wolfbot/prompts/templates/master/narration_execution.md new file mode 100644 index 0000000..3d0bc76 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_execution.md @@ -0,0 +1 @@ +{{target}} の処刑が確定致しました。{{#if append_night_intro}}夜のフェイズへ移行致します。制限時間は {{night_secs}} 秒でございます。役職を持つ参加者の方は、DM の選択 UI から行動をお願い致します。{{/if}} \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_morning.md b/src/wolfbot/prompts/templates/master/narration_morning.md new file mode 100644 index 0000000..23c503a --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_morning.md @@ -0,0 +1 @@ +夜が明けました。本日は {{day_number}} 日目でございます。{{#if has_casualty}}昨夜、{{target}} の停止を確認致しました。{{/if}}{{#if no_casualty}}本日、犠牲者は出ておりません。{{/if}}現在の生存者は {{alive_count}} 名でございます。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_no_execution.md b/src/wolfbot/prompts/templates/master/narration_no_execution.md new file mode 100644 index 0000000..bb32903 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_no_execution.md @@ -0,0 +1 @@ +{{#if is_runoff_tie}}決選投票も同票となりました。本日は処刑なしで夜のフェイズへ移行致します。{{/if}}{{#if is_invalid_vote}}投票結果が無効でございました。本日は処刑なしで夜のフェイズへ移行致します。{{/if}}{{#if is_default}}有効な投票が成立致しませんでした。本日は処刑なしで夜のフェイズへ移行致します。{{/if}} \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_phase_change_day_discussion.md b/src/wolfbot/prompts/templates/master/narration_phase_change_day_discussion.md new file mode 100644 index 0000000..82d4dab --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_phase_change_day_discussion.md @@ -0,0 +1 @@ +{{#if is_first_day}}夜が明けました。1 日目の議論を開始致します。制限時間は {{secs}} 秒でございます。{{/if}}{{#if is_later_day}}{{day_number}} 日目の議論を開始致します。制限時間は {{secs}} 秒でございます。{{/if}} \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_phase_change_day_runoff.md b/src/wolfbot/prompts/templates/master/narration_phase_change_day_runoff.md new file mode 100644 index 0000000..f98b971 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_phase_change_day_runoff.md @@ -0,0 +1 @@ +決選投票へ移行致します。制限時間は {{secs}} 秒でございます。DM の選択 UI から再度ご投票をお願い致します。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_phase_change_day_runoff_speech.md b/src/wolfbot/prompts/templates/master/narration_phase_change_day_runoff_speech.md new file mode 100644 index 0000000..b4361cf --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_phase_change_day_runoff_speech.md @@ -0,0 +1 @@ +決選投票候補者の最終演説に移行致します。演説時間は {{secs}} 秒でございます。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_phase_change_day_vote.md b/src/wolfbot/prompts/templates/master/narration_phase_change_day_vote.md new file mode 100644 index 0000000..0563417 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_phase_change_day_vote.md @@ -0,0 +1 @@ +議論時間が終了致しました。投票フェイズへ移行致します。制限時間は {{secs}} 秒でございます。投票は DM の選択 UI からお願い致します。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_phase_change_night.md b/src/wolfbot/prompts/templates/master/narration_phase_change_night.md new file mode 100644 index 0000000..299c6a5 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_phase_change_night.md @@ -0,0 +1 @@ +夜のフェイズへ移行致します。制限時間は {{secs}} 秒でございます。役職を持つ参加者の方は、DM の選択 UI から行動をお願い致します。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_runoff_candidate_intro.md b/src/wolfbot/prompts/templates/master/narration_runoff_candidate_intro.md new file mode 100644 index 0000000..ddedf12 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_runoff_candidate_intro.md @@ -0,0 +1 @@ +続いて、{{name}} 様の最終演説でございます。どうぞ。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_runoff_start.md b/src/wolfbot/prompts/templates/master/narration_runoff_start.md new file mode 100644 index 0000000..4db9d2d --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_runoff_start.md @@ -0,0 +1 @@ +投票が同数となりました。決選投票へ移行致します。候補者については本機からの追って案内をご確認ください。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_setup_complete.md b/src/wolfbot/prompts/templates/master/narration_setup_complete.md new file mode 100644 index 0000000..c693f68 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_setup_complete.md @@ -0,0 +1 @@ +本機は進行管理 LEVI でございます。参加者 {{alive_count}} 名で、これより人狼ゲームを開始致します。役職の通知は各参加者の DM へ送付済みです。ご確認をお願い致します。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_victory.md b/src/wolfbot/prompts/templates/master/narration_victory.md new file mode 100644 index 0000000..67eef37 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_victory.md @@ -0,0 +1 @@ +{{#if is_village}}判定致します。村人陣営の勝利でございます。ゲームを終了致します。{{/if}}{{#if is_wolf}}判定致します。人狼陣営の勝利でございます。ゲームを終了致します。{{/if}}{{#if is_other}}{{raw_text}}。ゲームを終了致します。{{/if}} \ No newline at end of file From ef2327a8d66d0ada38a27614ea1eb78529be70bf Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 11:19:49 +0900 Subject: [PATCH 103/133] refactor(master): reorganize into 6 subdomains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Master codebase ballooned to 21 files in a flat directory; this commit splits them along functional axes so unrelated concerns (audio I/O, claim ledger, WS transport, narration, etc.) live in distinct subpackages instead of sharing one namespace. * arbiter/ speak_arbiter, ingest_service, logic_service, public_digest — speech orchestration * voice/ audio_sink, voice_ingest_*, voice_recv_*, voice_debug_dump, stt_service, tts_playback — Discord-VC audio I/O * narration/ narration.py — Levi voice templates * claim/ claim_history, claim_validator — CO ledger * ws/ ws_server, npc_registry, decision_dispatcher, wolf_chat_broker — WS transport + NPC binding * state/ private_state, phase_d_state_pusher, text_analyzer — Phase-D state sync * personas.py stays at master/ root (single-file, Levi persona constants) 22 files moved via 'git mv' (history preserved). 38 source/test files updated to the new import paths (mechanical sed-style rewrite with a Python loop). 1238 tests + ruff + mypy all green — behavior unchanged, only namespace. Templates landed under prompts/templates/master/ in the previous Phase-1 commits already use these new paths transparently. --- src/wolfbot/main.py | 40 +++++++++---------- src/wolfbot/master/arbiter/__init__.py | 0 .../master/{ => arbiter}/ingest_service.py | 2 +- .../master/{ => arbiter}/logic_service.py | 2 +- .../master/{ => arbiter}/public_digest.py | 0 .../master/{ => arbiter}/speak_arbiter.py | 12 +++--- src/wolfbot/master/claim/__init__.py | 0 .../master/{ => claim}/claim_history.py | 2 +- .../master/{ => claim}/claim_validator.py | 2 +- src/wolfbot/master/narration/__init__.py | 0 .../master/{ => narration}/narration.py | 0 src/wolfbot/master/state/__init__.py | 0 .../{ => state}/phase_d_state_pusher.py | 4 +- .../master/{ => state}/private_state.py | 0 .../master/{ => state}/text_analyzer.py | 0 src/wolfbot/master/voice/__init__.py | 0 src/wolfbot/master/{ => voice}/audio_sink.py | 2 +- src/wolfbot/master/{ => voice}/stt_service.py | 0 .../master/{ => voice}/tts_playback.py | 0 .../master/{ => voice}/voice_debug_dump.py | 2 +- .../master/{ => voice}/voice_ingest_client.py | 0 .../{ => voice}/voice_ingest_service.py | 6 +-- .../{ => voice}/voice_recv_dave_patch.py | 0 .../{ => voice}/voice_recv_resilience.py | 0 src/wolfbot/master/ws/__init__.py | 0 .../master/{ => ws}/decision_dispatcher.py | 2 +- src/wolfbot/master/{ => ws}/npc_registry.py | 0 .../master/{ => ws}/wolf_chat_broker.py | 4 +- src/wolfbot/master/{ => ws}/ws_server.py | 2 +- src/wolfbot/services/discord_service.py | 4 +- src/wolfbot/services/llm_service.py | 2 +- src/wolfbot/voicetest/main.py | 20 +++++----- tests/test_addressed_npc_routing.py | 14 +++---- tests/test_claim_history.py | 8 ++-- tests/test_claim_persistence.py | 2 +- tests/test_claim_validator.py | 12 +++--- tests/test_master_decision_dispatcher.py | 4 +- tests/test_master_narration.py | 2 +- tests/test_master_phase_d_state_pusher.py | 4 +- tests/test_master_private_state.py | 2 +- tests/test_master_public_digest.py | 2 +- tests/test_master_stt_groq.py | 4 +- tests/test_master_tts_playback.py | 2 +- tests/test_master_wolf_chat_broker.py | 4 +- tests/test_master_ws_server.py | 8 ++-- tests/test_npc_seat_assignment.py | 2 +- tests/test_npc_seat_lifecycle.py | 2 +- tests/test_reactive_voice_master.py | 20 +++++----- tests/test_reactive_voice_mode.py | 28 ++++++------- tests/test_stt_roster_prompt.py | 2 +- tests/test_text_analyzer.py | 4 +- tests/test_voice_debug_dump.py | 6 +-- tests/test_voice_ingest_service.py | 6 +-- tests/test_voice_recv_resilience.py | 2 +- 54 files changed, 124 insertions(+), 124 deletions(-) create mode 100644 src/wolfbot/master/arbiter/__init__.py rename src/wolfbot/master/{ => arbiter}/ingest_service.py (99%) rename src/wolfbot/master/{ => arbiter}/logic_service.py (99%) rename src/wolfbot/master/{ => arbiter}/public_digest.py (100%) rename src/wolfbot/master/{ => arbiter}/speak_arbiter.py (99%) create mode 100644 src/wolfbot/master/claim/__init__.py rename src/wolfbot/master/{ => claim}/claim_history.py (99%) rename src/wolfbot/master/{ => claim}/claim_validator.py (99%) create mode 100644 src/wolfbot/master/narration/__init__.py rename src/wolfbot/master/{ => narration}/narration.py (100%) create mode 100644 src/wolfbot/master/state/__init__.py rename src/wolfbot/master/{ => state}/phase_d_state_pusher.py (99%) rename src/wolfbot/master/{ => state}/private_state.py (100%) rename src/wolfbot/master/{ => state}/text_analyzer.py (100%) create mode 100644 src/wolfbot/master/voice/__init__.py rename src/wolfbot/master/{ => voice}/audio_sink.py (97%) rename src/wolfbot/master/{ => voice}/stt_service.py (100%) rename src/wolfbot/master/{ => voice}/tts_playback.py (100%) rename src/wolfbot/master/{ => voice}/voice_debug_dump.py (99%) rename src/wolfbot/master/{ => voice}/voice_ingest_client.py (100%) rename src/wolfbot/master/{ => voice}/voice_ingest_service.py (99%) rename src/wolfbot/master/{ => voice}/voice_recv_dave_patch.py (100%) rename src/wolfbot/master/{ => voice}/voice_recv_resilience.py (100%) create mode 100644 src/wolfbot/master/ws/__init__.py rename src/wolfbot/master/{ => ws}/decision_dispatcher.py (99%) rename src/wolfbot/master/{ => ws}/npc_registry.py (100%) rename src/wolfbot/master/{ => ws}/wolf_chat_broker.py (97%) rename src/wolfbot/master/{ => ws}/ws_server.py (99%) diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 538660c..e979d23 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -54,7 +54,7 @@ async def _run() -> None: # NPC registry is always created so /wolf start can consult it for # reactive_voice seat backfill. The WS server (which actually accepts # NPC bot connections) is only started when MASTER_NPC_PSK is set. - from wolfbot.master.npc_registry import InMemoryNpcRegistry + from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry npc_registry = InMemoryNpcRegistry() discord_adapter.set_npc_registry(npc_registry) @@ -194,11 +194,11 @@ async def _master_join_vc_for_game(game: Any) -> None: master_vc_ref[0] = None from discord.ext import voice_recv - from wolfbot.master.audio_sink import WolfbotAudioSink - from wolfbot.master.voice_recv_dave_patch import ( + from wolfbot.master.voice.audio_sink import WolfbotAudioSink + from wolfbot.master.voice.voice_recv_dave_patch import ( apply_dave_decrypt_patch, ) - from wolfbot.master.voice_recv_resilience import ( + from wolfbot.master.voice.voice_recv_resilience import ( apply_packet_router_resilience, ) @@ -286,7 +286,7 @@ async def _push_private_state_snapshot( skipped; the NPC will see the historical empty-state behavior on decision requests until the next snapshot opportunity (re-register). """ - from wolfbot.master.private_state import ( + from wolfbot.master.state.private_state import ( build_snapshot_for_seat, load_private_state_for_seat, ) @@ -680,7 +680,7 @@ async def _on_speech_recorded(game_id: str) -> None: settings.VOICE_STT_PROVIDER == "groq" and settings.GAMEPLAY_LLM_API_KEY is not None ): - from wolfbot.master.text_analyzer import OpenAICompatibleTextAnalyzer + from wolfbot.master.state.text_analyzer import OpenAICompatibleTextAnalyzer analyzer_base_url = ( settings.GAMEPLAY_LLM_BASE_URL or "https://api.x.ai/v1" @@ -691,7 +691,7 @@ async def _on_speech_recorded(game_id: str) -> None: base_url=analyzer_base_url, ) elif settings.VOICE_LLM_API_KEY is not None: - from wolfbot.master.text_analyzer import GeminiTextAnalyzer + from wolfbot.master.state.text_analyzer import GeminiTextAnalyzer text_analyzer = GeminiTextAnalyzer( api_key=settings.VOICE_LLM_API_KEY.get_secret_value(), @@ -731,9 +731,9 @@ async def _on_speech_recorded(game_id: str) -> None: ws_server: Any = None voice_ingest: Any = None if settings.MASTER_NPC_PSK is not None: - from wolfbot.master.ingest_service import MasterIngestService - from wolfbot.master.speak_arbiter import SpeakArbiter - from wolfbot.master.ws_server import ( + from wolfbot.master.arbiter.ingest_service import MasterIngestService + from wolfbot.master.arbiter.speak_arbiter import SpeakArbiter + from wolfbot.master.ws.ws_server import ( MasterHandlers, WebsocketsMasterWsServer, ) @@ -748,7 +748,7 @@ async def _on_speech_recorded(game_id: str) -> None: async def _runoff_announce(seat: Any) -> None: """Levi voice-introduces a tied runoff candidate before TTS.""" - from wolfbot.master.narration import render_runoff_candidate_intro + from wolfbot.master.narration.narration import render_runoff_candidate_intro if not _master_tts_holder: return @@ -784,8 +784,8 @@ def _runoff_wake(game_id: str) -> None: # Wired into LLMAdapter so reactive_voice games skip the gameplay # decider and ask the NPC bot for its own seat's vote / night # action via WS. - from wolfbot.master.decision_dispatcher import NpcDecisionDispatcher - from wolfbot.master.wolf_chat_broker import WolfChatBroker + from wolfbot.master.ws.decision_dispatcher import NpcDecisionDispatcher + from wolfbot.master.ws.wolf_chat_broker import WolfChatBroker decision_dispatcher = NpcDecisionDispatcher( registry=npc_registry, @@ -811,7 +811,7 @@ async def _post_to_wolves_channel(game_id: str, text: str) -> None: # bots after each transition. Wired into GameService via the # late-binding setter (the pusher needs npc_registry which is # only available here, but GameService was constructed earlier). - from wolfbot.master.phase_d_state_pusher import PhaseDStatePusher + from wolfbot.master.state.phase_d_state_pusher import PhaseDStatePusher phase_d_pusher = PhaseDStatePusher( repo=repo, @@ -836,11 +836,11 @@ async def _reactive_voice_reenter(game_id: str) -> None: # Built once per process; the narrator callback is installed on # `discord_adapter` so `post_public` / `post_morning` route through # it whenever the active game is in reactive_voice mode. - from wolfbot.master.narration import ( + from wolfbot.master.narration.narration import ( NarrationContext, render_master_narration, ) - from wolfbot.master.tts_playback import MasterTtsPlayback + from wolfbot.master.voice.tts_playback import MasterTtsPlayback from wolfbot.npc.tts import VoicevoxTtsService master_tts = MasterTtsPlayback( @@ -1055,7 +1055,7 @@ async def get_alive_seat_nos(self, game_id: str) -> list[int]: async def resolve_addressed_seat( self, game_id: str, addressed_name: str ) -> int | None: - from wolfbot.master.ingest_service import resolve_seat_by_name + from wolfbot.master.arbiter.ingest_service import resolve_seat_by_name seats = await repo.load_seats(game_id) players = await repo.load_players(game_id) @@ -1214,12 +1214,12 @@ async def _on_wolf_chat_send(msg: Any, _ctx: Any) -> None: ) ) if _voice_stt_credentialed: - from wolfbot.master.stt_service import ( + from wolfbot.master.voice.stt_service import ( GeminiAudioAnalyzer, GroqWhisperAudioAnalyzer, ) - from wolfbot.master.voice_ingest_client import DirectMasterIngestionClient - from wolfbot.master.voice_ingest_service import ( + from wolfbot.master.voice.voice_ingest_client import DirectMasterIngestionClient + from wolfbot.master.voice.voice_ingest_service import ( VoiceIngestConfig, VoiceIngestService, ) diff --git a/src/wolfbot/master/arbiter/__init__.py b/src/wolfbot/master/arbiter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/master/ingest_service.py b/src/wolfbot/master/arbiter/ingest_service.py similarity index 99% rename from src/wolfbot/master/ingest_service.py rename to src/wolfbot/master/arbiter/ingest_service.py index 7c84f0d..7ab46fe 100644 --- a/src/wolfbot/master/ingest_service.py +++ b/src/wolfbot/master/arbiter/ingest_service.py @@ -30,7 +30,7 @@ from wolfbot.domain.enums import Phase from wolfbot.domain.models import Seat from wolfbot.domain.ws_messages import SpeechEventPayload -from wolfbot.master.npc_registry import NpcRegistry +from wolfbot.master.ws.npc_registry import NpcRegistry from wolfbot.services.discussion_service import ( DiscussionService, new_event_id, diff --git a/src/wolfbot/master/logic_service.py b/src/wolfbot/master/arbiter/logic_service.py similarity index 99% rename from src/wolfbot/master/logic_service.py rename to src/wolfbot/master/arbiter/logic_service.py index 1237abb..20480a8 100644 --- a/src/wolfbot/master/logic_service.py +++ b/src/wolfbot/master/arbiter/logic_service.py @@ -25,7 +25,7 @@ from wolfbot.domain.discussion import PublicDiscussionState from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, RecentSpeech -from wolfbot.master.claim_history import ( +from wolfbot.master.claim.claim_history import ( ClaimHistory, expected_seer_claim_count_for_day, ) diff --git a/src/wolfbot/master/public_digest.py b/src/wolfbot/master/arbiter/public_digest.py similarity index 100% rename from src/wolfbot/master/public_digest.py rename to src/wolfbot/master/arbiter/public_digest.py diff --git a/src/wolfbot/master/speak_arbiter.py b/src/wolfbot/master/arbiter/speak_arbiter.py similarity index 99% rename from src/wolfbot/master/speak_arbiter.py rename to src/wolfbot/master/arbiter/speak_arbiter.py index 13364b9..3c614ac 100644 --- a/src/wolfbot/master/speak_arbiter.py +++ b/src/wolfbot/master/arbiter/speak_arbiter.py @@ -52,16 +52,16 @@ TtsFinished, ) from wolfbot.llm.prompt_builder import build_strategy_block -from wolfbot.master.claim_history import ClaimHistory, collect_claim_history -from wolfbot.master.claim_validator import ( +from wolfbot.master.arbiter.logic_service import build_logic_packet +from wolfbot.master.claim.claim_history import ClaimHistory, collect_claim_history +from wolfbot.master.claim.claim_validator import ( CO_CAP_REASONS, FABRICATION_REASONS, ActualMediumEvent, ActualSeerEvent, validate_claim_against_truth, ) -from wolfbot.master.logic_service import build_logic_packet -from wolfbot.master.npc_registry import NpcRegistry +from wolfbot.master.ws.npc_registry import NpcRegistry from wolfbot.persistence.sqlite_repo import SqliteRepo from wolfbot.services.discussion_service import ( DiscussionService, @@ -635,7 +635,7 @@ async def _load_actual_seer_history( her legitimate Jonas claim as a fabricated target and bounced her 6 times in a row. """ - from wolfbot.master.private_state import load_private_state_for_seat + from wolfbot.master.state.private_state import load_private_state_for_seat try: players = await self.repo.load_players(game_id) seats = await self.repo.load_seats(game_id) @@ -680,7 +680,7 @@ async def _load_actual_medium_history( ``(history, count)`` — history empty unless the speaker IS the real medium, count comes from the public log regardless. """ - from wolfbot.master.private_state import load_private_state_for_seat + from wolfbot.master.state.private_state import load_private_state_for_seat try: players = await self.repo.load_players(game_id) seats = await self.repo.load_seats(game_id) diff --git a/src/wolfbot/master/claim/__init__.py b/src/wolfbot/master/claim/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/master/claim_history.py b/src/wolfbot/master/claim/claim_history.py similarity index 99% rename from src/wolfbot/master/claim_history.py rename to src/wolfbot/master/claim/claim_history.py index d879ea8..58cd043 100644 --- a/src/wolfbot/master/claim_history.py +++ b/src/wolfbot/master/claim/claim_history.py @@ -175,7 +175,7 @@ def expected_seer_claim_count_for_day(day: int) -> int: So by day N morning the seer has announced exactly N entries (one per declared day, day=1..N). This matches - :func:`wolfbot.master.claim_validator._validate_seer_fake`'s + :func:`wolfbot.master.claim.claim_validator._validate_seer_fake`'s "1 entry per declared day" rule (``same_day_priors``). Day 0 has no morning discussion (SETUP / NIGHT_0 are transient), diff --git a/src/wolfbot/master/claim_validator.py b/src/wolfbot/master/claim/claim_validator.py similarity index 99% rename from src/wolfbot/master/claim_validator.py rename to src/wolfbot/master/claim/claim_validator.py index 667edeb..da9edf7 100644 --- a/src/wolfbot/master/claim_validator.py +++ b/src/wolfbot/master/claim/claim_validator.py @@ -29,7 +29,7 @@ from wolfbot.domain.enums import Phase, Role from wolfbot.domain.ws_messages import ClaimedMediumResult, ClaimedSeerResult -from wolfbot.master.claim_history import ( +from wolfbot.master.claim.claim_history import ( ClaimedMediumEntry, ClaimedSeerEntry, ClaimerHistory, diff --git a/src/wolfbot/master/narration/__init__.py b/src/wolfbot/master/narration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/master/narration.py b/src/wolfbot/master/narration/narration.py similarity index 100% rename from src/wolfbot/master/narration.py rename to src/wolfbot/master/narration/narration.py diff --git a/src/wolfbot/master/state/__init__.py b/src/wolfbot/master/state/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/master/phase_d_state_pusher.py b/src/wolfbot/master/state/phase_d_state_pusher.py similarity index 99% rename from src/wolfbot/master/phase_d_state_pusher.py rename to src/wolfbot/master/state/phase_d_state_pusher.py index b0016ed..f74b0a2 100644 --- a/src/wolfbot/master/phase_d_state_pusher.py +++ b/src/wolfbot/master/state/phase_d_state_pusher.py @@ -32,8 +32,7 @@ from wolfbot.domain.enums import Phase, Role, SubmissionType from wolfbot.domain.models import Game, NightAction, Player, Seat from wolfbot.domain.models import LogEntry as DomainLogEntry -from wolfbot.master.npc_registry import NpcEntry, NpcRegistry -from wolfbot.master.private_state import ( +from wolfbot.master.state.private_state import ( make_alive_changed_update, make_day_advanced_update, make_guard_entry_update, @@ -41,6 +40,7 @@ make_medium_result_update, make_seer_result_update, ) +from wolfbot.master.ws.npc_registry import NpcEntry, NpcRegistry from wolfbot.persistence.sqlite_repo import SqliteRepo log = logging.getLogger(__name__) diff --git a/src/wolfbot/master/private_state.py b/src/wolfbot/master/state/private_state.py similarity index 100% rename from src/wolfbot/master/private_state.py rename to src/wolfbot/master/state/private_state.py diff --git a/src/wolfbot/master/text_analyzer.py b/src/wolfbot/master/state/text_analyzer.py similarity index 100% rename from src/wolfbot/master/text_analyzer.py rename to src/wolfbot/master/state/text_analyzer.py diff --git a/src/wolfbot/master/voice/__init__.py b/src/wolfbot/master/voice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/master/audio_sink.py b/src/wolfbot/master/voice/audio_sink.py similarity index 97% rename from src/wolfbot/master/audio_sink.py rename to src/wolfbot/master/voice/audio_sink.py index 6b69831..474992a 100644 --- a/src/wolfbot/master/audio_sink.py +++ b/src/wolfbot/master/voice/audio_sink.py @@ -22,7 +22,7 @@ from discord.ext import voice_recv if TYPE_CHECKING: - from wolfbot.master.voice_ingest_service import VoiceIngestService + from wolfbot.master.voice.voice_ingest_service import VoiceIngestService log = logging.getLogger(__name__) diff --git a/src/wolfbot/master/stt_service.py b/src/wolfbot/master/voice/stt_service.py similarity index 100% rename from src/wolfbot/master/stt_service.py rename to src/wolfbot/master/voice/stt_service.py diff --git a/src/wolfbot/master/tts_playback.py b/src/wolfbot/master/voice/tts_playback.py similarity index 100% rename from src/wolfbot/master/tts_playback.py rename to src/wolfbot/master/voice/tts_playback.py diff --git a/src/wolfbot/master/voice_debug_dump.py b/src/wolfbot/master/voice/voice_debug_dump.py similarity index 99% rename from src/wolfbot/master/voice_debug_dump.py rename to src/wolfbot/master/voice/voice_debug_dump.py index 867bf95..eee2099 100644 --- a/src/wolfbot/master/voice_debug_dump.py +++ b/src/wolfbot/master/voice/voice_debug_dump.py @@ -36,7 +36,7 @@ from dataclasses import dataclass from pathlib import Path -from wolfbot.master.stt_service import SttResult, pcm_to_wav +from wolfbot.master.voice.stt_service import SttResult, pcm_to_wav log = logging.getLogger(__name__) diff --git a/src/wolfbot/master/voice_ingest_client.py b/src/wolfbot/master/voice/voice_ingest_client.py similarity index 100% rename from src/wolfbot/master/voice_ingest_client.py rename to src/wolfbot/master/voice/voice_ingest_client.py diff --git a/src/wolfbot/master/voice_ingest_service.py b/src/wolfbot/master/voice/voice_ingest_service.py similarity index 99% rename from src/wolfbot/master/voice_ingest_service.py rename to src/wolfbot/master/voice/voice_ingest_service.py index 8d1c405..4281ed8 100644 --- a/src/wolfbot/master/voice_ingest_service.py +++ b/src/wolfbot/master/voice/voice_ingest_service.py @@ -38,13 +38,13 @@ VadSpeechEnded, VadSpeechStarted, ) -from wolfbot.master.stt_service import ( +from wolfbot.master.voice.stt_service import ( RosterEntry, SttProviderError, SttResult, SttService, ) -from wolfbot.master.voice_ingest_client import ( +from wolfbot.master.voice.voice_ingest_client import ( MasterIngestionClient, NpcRegistryView, ) @@ -305,7 +305,7 @@ async def _run_stt_inner( *, audio_end_ms: int, ) -> None: - from wolfbot.master.voice_debug_dump import ( + from wolfbot.master.voice.voice_debug_dump import ( SegmentDumpRecord, debug_dir, dump_segment, diff --git a/src/wolfbot/master/voice_recv_dave_patch.py b/src/wolfbot/master/voice/voice_recv_dave_patch.py similarity index 100% rename from src/wolfbot/master/voice_recv_dave_patch.py rename to src/wolfbot/master/voice/voice_recv_dave_patch.py diff --git a/src/wolfbot/master/voice_recv_resilience.py b/src/wolfbot/master/voice/voice_recv_resilience.py similarity index 100% rename from src/wolfbot/master/voice_recv_resilience.py rename to src/wolfbot/master/voice/voice_recv_resilience.py diff --git a/src/wolfbot/master/ws/__init__.py b/src/wolfbot/master/ws/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/master/decision_dispatcher.py b/src/wolfbot/master/ws/decision_dispatcher.py similarity index 99% rename from src/wolfbot/master/decision_dispatcher.py rename to src/wolfbot/master/ws/decision_dispatcher.py index ff696b9..e1fc4f7 100644 --- a/src/wolfbot/master/decision_dispatcher.py +++ b/src/wolfbot/master/ws/decision_dispatcher.py @@ -39,7 +39,7 @@ WolfChatRequest, WolfChatSend, ) -from wolfbot.master.npc_registry import NpcEntry, NpcRegistry +from wolfbot.master.ws.npc_registry import NpcEntry, NpcRegistry log = logging.getLogger(__name__) diff --git a/src/wolfbot/master/npc_registry.py b/src/wolfbot/master/ws/npc_registry.py similarity index 100% rename from src/wolfbot/master/npc_registry.py rename to src/wolfbot/master/ws/npc_registry.py diff --git a/src/wolfbot/master/wolf_chat_broker.py b/src/wolfbot/master/ws/wolf_chat_broker.py similarity index 97% rename from src/wolfbot/master/wolf_chat_broker.py rename to src/wolfbot/master/ws/wolf_chat_broker.py index beff1a1..e0b3397 100644 --- a/src/wolfbot/master/wolf_chat_broker.py +++ b/src/wolfbot/master/ws/wolf_chat_broker.py @@ -27,8 +27,8 @@ from wolfbot.domain.enums import Role from wolfbot.domain.models import LogEntry from wolfbot.domain.ws_messages import WolfChatSend -from wolfbot.master.npc_registry import NpcEntry, NpcRegistry -from wolfbot.master.private_state import make_wolf_chat_update +from wolfbot.master.state.private_state import make_wolf_chat_update +from wolfbot.master.ws.npc_registry import NpcEntry, NpcRegistry from wolfbot.persistence.sqlite_repo import SqliteRepo log = logging.getLogger(__name__) diff --git a/src/wolfbot/master/ws_server.py b/src/wolfbot/master/ws/ws_server.py similarity index 99% rename from src/wolfbot/master/ws_server.py rename to src/wolfbot/master/ws/ws_server.py index a9d0e34..ecfc81b 100644 --- a/src/wolfbot/master/ws_server.py +++ b/src/wolfbot/master/ws/ws_server.py @@ -56,7 +56,7 @@ VoteDecision, WolfChatSend, ) -from wolfbot.master.npc_registry import NpcRegistry +from wolfbot.master.ws.npc_registry import NpcRegistry log = logging.getLogger(__name__) diff --git a/src/wolfbot/services/discord_service.py b/src/wolfbot/services/discord_service.py index 137d795..c9599fb 100644 --- a/src/wolfbot/services/discord_service.py +++ b/src/wolfbot/services/discord_service.py @@ -51,7 +51,7 @@ from wolfbot.ui.views import NightActionView, VoteView if TYPE_CHECKING: - from wolfbot.master.npc_registry import NpcRegistry + from wolfbot.master.ws.npc_registry import NpcRegistry from wolfbot.services.discussion_service import DiscussionService log = logging.getLogger(__name__) @@ -762,7 +762,7 @@ async def on_message(self, message: discord.Message) -> None: co_declaration = analysis.co_declaration role_callout = analysis.role_callout if analysis.addressed_name: - from wolfbot.master.ingest_service import ( + from wolfbot.master.arbiter.ingest_service import ( resolve_seat_by_name, ) diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index 9f8e367..5aa5d36 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -1135,7 +1135,7 @@ async def _build_public_digest(self, game: Game, seats: Sequence[Seat]) -> str: from wolfbot.domain.discussion import ( make_phase_id as _make_phase_id, ) - from wolfbot.master.public_digest import build_public_digest + from wolfbot.master.arbiter.public_digest import build_public_digest from wolfbot.services.discussion_service import ( apply_speech_event, ) diff --git a/src/wolfbot/voicetest/main.py b/src/wolfbot/voicetest/main.py index 1e63a4f..335a26c 100644 --- a/src/wolfbot/voicetest/main.py +++ b/src/wolfbot/voicetest/main.py @@ -42,9 +42,9 @@ VadSpeechEnded, VadSpeechStarted, ) -from wolfbot.master.audio_sink import WolfbotAudioSink -from wolfbot.master.stt_service import RosterEntry, SttResult -from wolfbot.master.voice_ingest_service import ( +from wolfbot.master.voice.audio_sink import WolfbotAudioSink +from wolfbot.master.voice.stt_service import RosterEntry, SttResult +from wolfbot.master.voice.voice_ingest_service import ( VoiceIngestConfig, VoiceIngestService, ) @@ -243,7 +243,7 @@ def _build_production_stt(settings: VoicetestSettings): # type: ignore[no-untyp "requires GAMEPLAY_LLM_API_KEY for the analyzer step " "(set in .env.master)." ) - from wolfbot.master.stt_service import GroqWhisperAudioAnalyzer + from wolfbot.master.voice.stt_service import GroqWhisperAudioAnalyzer analyzer_base_url = ( settings.GAMEPLAY_LLM_BASE_URL or "https://api.x.ai/v1" @@ -269,7 +269,7 @@ def _build_production_stt(settings: VoicetestSettings): # type: ignore[no-untyp "VOICETEST_USE_STT=true with VOICE_STT_PROVIDER=gemini " "requires VOICE_LLM_API_KEY (set in .env.master)." ) - from wolfbot.master.stt_service import GeminiAudioAnalyzer + from wolfbot.master.voice.stt_service import GeminiAudioAnalyzer log.info( "voicetest_stt=ON provider=gemini model=%s", @@ -321,7 +321,7 @@ def _install_silence_gate( actual speech recordings. ``VoiceIngestService._run_stt_inner`` does - ``from wolfbot.master.voice_debug_dump import dump_segment`` + ``from wolfbot.master.voice.voice_debug_dump import dump_segment`` *inside the function body*, so a module-level monkey-patch applied before any segment fires will be picked up by every subsequent call without touching production code. @@ -329,7 +329,7 @@ def _install_silence_gate( import dataclasses from datetime import datetime - import wolfbot.master.voice_debug_dump as _dump_mod + import wolfbot.master.voice.voice_debug_dump as _dump_mod original = _dump_mod.dump_segment @@ -432,7 +432,7 @@ async def _run() -> None: settings.VOICETEST_MIN_DURATION_MS, ) - from wolfbot.master.stt_service import SttService + from wolfbot.master.voice.stt_service import SttService stt_service: SttService if settings.VOICETEST_USE_STT: @@ -457,10 +457,10 @@ async def _run() -> None: vc_ref: list[voice_recv.VoiceRecvClient | None] = [None] async def _join_vc() -> None: - from wolfbot.master.voice_recv_dave_patch import ( + from wolfbot.master.voice.voice_recv_dave_patch import ( apply_dave_decrypt_patch, ) - from wolfbot.master.voice_recv_resilience import ( + from wolfbot.master.voice.voice_recv_resilience import ( apply_packet_router_resilience, ) diff --git a/tests/test_addressed_npc_routing.py b/tests/test_addressed_npc_routing.py index 66b83f0..86de66c 100644 --- a/tests/test_addressed_npc_routing.py +++ b/tests/test_addressed_npc_routing.py @@ -25,14 +25,14 @@ from wolfbot.domain.enums import Phase, Role from wolfbot.domain.models import Game, Seat from wolfbot.domain.ws_messages import SpeechEventPayload -from wolfbot.master.ingest_service import ( +from wolfbot.master.arbiter.ingest_service import ( MasterIngestService, resolve_seat_by_name, ) -from wolfbot.master.logic_service import build_logic_packet -from wolfbot.master.npc_registry import InMemoryNpcRegistry -from wolfbot.master.speak_arbiter import SpeakArbiter -from wolfbot.master.stt_service import GeminiAudioAnalyzer +from wolfbot.master.arbiter.logic_service import build_logic_packet +from wolfbot.master.arbiter.speak_arbiter import SpeakArbiter +from wolfbot.master.voice.stt_service import GeminiAudioAnalyzer +from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry from wolfbot.persistence.sqlite_repo import SqliteRepo from wolfbot.services.discussion_service import ( DiscussionService, @@ -561,7 +561,7 @@ async def test_wolfcog_on_message_uses_text_analyzer_to_set_addressed_seat( from unittest.mock import MagicMock from wolfbot.domain.discussion import SpeechSource - from wolfbot.master.text_analyzer import FakeTextAnalyzer, TextAnalysis + from wolfbot.master.state.text_analyzer import FakeTextAnalyzer, TextAnalysis from wolfbot.services.discord_service import WolfCog from wolfbot.services.discussion_service import SqliteSpeechEventStore @@ -615,7 +615,7 @@ async def test_wolfcog_on_message_skips_addressed_when_analyzer_fails( from unittest.mock import MagicMock from wolfbot.domain.discussion import SpeechSource - from wolfbot.master.text_analyzer import FakeTextAnalyzer + from wolfbot.master.state.text_analyzer import FakeTextAnalyzer from wolfbot.services.discord_service import WolfCog from wolfbot.services.discussion_service import SqliteSpeechEventStore diff --git a/tests/test_claim_history.py b/tests/test_claim_history.py index b787fb7..e0b3786 100644 --- a/tests/test_claim_history.py +++ b/tests/test_claim_history.py @@ -3,8 +3,8 @@ Coverage map: -* :mod:`wolfbot.master.claim_history` — pure fold over SpeechEvent. -* :mod:`wolfbot.master.logic_service` — claim block surfacing in +* :mod:`wolfbot.master.claim.claim_history` — pure fold over SpeechEvent. +* :mod:`wolfbot.master.arbiter.logic_service` — claim block surfacing in ``LogicPacket.public_state_summary``. """ @@ -17,14 +17,14 @@ SpeechSource, ) from wolfbot.domain.enums import Phase -from wolfbot.master.claim_history import ( +from wolfbot.master.arbiter.logic_service import build_logic_packet +from wolfbot.master.claim.claim_history import ( ClaimedMediumEntry, ClaimedSeerEntry, collect_claim_history, expected_medium_claim_count_for_day, expected_seer_claim_count_for_day, ) -from wolfbot.master.logic_service import build_logic_packet def _speech_event( diff --git a/tests/test_claim_persistence.py b/tests/test_claim_persistence.py index 22127b7..20b3511 100644 --- a/tests/test_claim_persistence.py +++ b/tests/test_claim_persistence.py @@ -1,6 +1,6 @@ """Round-trip tests for the claim columns on ``speech_events``. -The claim aggregator (`wolfbot.master.claim_history`) is unit-tested +The claim aggregator (`wolfbot.master.claim.claim_history`) is unit-tested in :mod:`tests.test_claim_history`; this file pins the persistence seam so a column rename or a missed migration block fails noisily. """ diff --git a/tests/test_claim_validator.py b/tests/test_claim_validator.py index 3f7a334..ac2e1b2 100644 --- a/tests/test_claim_validator.py +++ b/tests/test_claim_validator.py @@ -1,4 +1,4 @@ -"""Tests for `wolfbot.master.claim_validator`. +"""Tests for `wolfbot.master.claim.claim_validator`. Covers the matrix: * real seer: legal / wrong target / wrong color @@ -13,12 +13,12 @@ from wolfbot.domain.enums import Phase, Role from wolfbot.domain.ws_messages import ClaimedMediumResult, ClaimedSeerResult -from wolfbot.master.claim_history import ( +from wolfbot.master.claim.claim_history import ( ClaimedMediumEntry, ClaimedSeerEntry, ClaimerHistory, ) -from wolfbot.master.claim_validator import ( +from wolfbot.master.claim.claim_validator import ( REASON_MEDIUM_DAY1, REASON_MEDIUM_FABRICATED_TARGET, REASON_MEDIUM_NO_EXECUTION, @@ -427,7 +427,7 @@ def test_madman_first_seer_claim_passes() -> None: def test_4th_seer_co_rejected() -> None: """Reproduces game `59d5377c6794`: a 4th seat tries to seer-CO when 3 distinct seats already have seer claims in the public ledger.""" - from wolfbot.master.claim_validator import REASON_SEER_CO_CAP_EXCEEDED + from wolfbot.master.claim.claim_validator import REASON_SEER_CO_CAP_EXCEEDED res = validate_claim_against_truth( speaker_role=Role.VILLAGER, @@ -465,7 +465,7 @@ def test_repeat_claim_from_same_speaker_at_cap_passes() -> None: """The cap only blocks NEW CO claimers. A speaker who's already CO'd seer can keep adding new daily results past day 1 even when the ledger has 3 distinct claimers (they're one of those 3).""" - from wolfbot.master.claim_history import ClaimedSeerEntry + from wolfbot.master.claim.claim_history import ClaimedSeerEntry prior = ClaimerHistory( claimer_seat=4, @@ -492,7 +492,7 @@ def test_repeat_claim_from_same_speaker_at_cap_passes() -> None: def test_3rd_medium_co_rejected() -> None: """Medium cap is 2. A 3rd medium CO from a new claimer is rejected.""" from wolfbot.domain.ws_messages import ClaimedMediumResult - from wolfbot.master.claim_validator import REASON_MEDIUM_CO_CAP_EXCEEDED + from wolfbot.master.claim.claim_validator import REASON_MEDIUM_CO_CAP_EXCEEDED res = validate_claim_against_truth( speaker_role=Role.VILLAGER, diff --git a/tests/test_master_decision_dispatcher.py b/tests/test_master_decision_dispatcher.py index d40363b..bece4f2 100644 --- a/tests/test_master_decision_dispatcher.py +++ b/tests/test_master_decision_dispatcher.py @@ -24,11 +24,11 @@ NightActionDecision, VoteDecision, ) -from wolfbot.master.decision_dispatcher import ( +from wolfbot.master.ws.decision_dispatcher import ( DecisionDispatcherConfig, NpcDecisionDispatcher, ) -from wolfbot.master.npc_registry import InMemoryNpcRegistry +from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry def _capture_send(buf: list[str]) -> Callable[[str], Awaitable[None]]: diff --git a/tests/test_master_narration.py b/tests/test_master_narration.py index 2d12092..571f7a5 100644 --- a/tests/test_master_narration.py +++ b/tests/test_master_narration.py @@ -20,7 +20,7 @@ ) from wolfbot.domain.enums import Phase from wolfbot.domain.models import LogEntry, Seat -from wolfbot.master.narration import ( +from wolfbot.master.narration.narration import ( NarrationContext, NarrationOutput, render_master_narration, diff --git a/tests/test_master_phase_d_state_pusher.py b/tests/test_master_phase_d_state_pusher.py index 57325ad..94b3ff1 100644 --- a/tests/test_master_phase_d_state_pusher.py +++ b/tests/test_master_phase_d_state_pusher.py @@ -15,8 +15,8 @@ NightAction, Seat, ) -from wolfbot.master.npc_registry import InMemoryNpcRegistry -from wolfbot.master.phase_d_state_pusher import PhaseDStatePusher +from wolfbot.master.state.phase_d_state_pusher import PhaseDStatePusher +from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry from wolfbot.persistence.sqlite_repo import SqliteRepo diff --git a/tests/test_master_private_state.py b/tests/test_master_private_state.py index e247cb5..0c5e744 100644 --- a/tests/test_master_private_state.py +++ b/tests/test_master_private_state.py @@ -20,7 +20,7 @@ from wolfbot.domain.enums import Phase, Role, SubmissionType from wolfbot.domain.models import Game, LogEntry, NightAction, Player, Seat -from wolfbot.master.private_state import ( +from wolfbot.master.state.private_state import ( build_snapshot_for_seat, load_private_state_for_seat, make_alive_changed_update, diff --git a/tests/test_master_public_digest.py b/tests/test_master_public_digest.py index 6f63cd6..b1c1ba4 100644 --- a/tests/test_master_public_digest.py +++ b/tests/test_master_public_digest.py @@ -11,7 +11,7 @@ make_phase_id, ) from wolfbot.domain.enums import Phase -from wolfbot.master.public_digest import build_public_digest +from wolfbot.master.arbiter.public_digest import build_public_digest def _state(**overrides: object) -> PublicDiscussionState: diff --git a/tests/test_master_stt_groq.py b/tests/test_master_stt_groq.py index 1d1502b..9b2773c 100644 --- a/tests/test_master_stt_groq.py +++ b/tests/test_master_stt_groq.py @@ -25,7 +25,7 @@ import httpx import pytest -from wolfbot.master.stt_service import ( +from wolfbot.master.voice.stt_service import ( GroqWhisperAudioAnalyzer, SttProviderError, ) @@ -342,7 +342,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"text": ""}) return httpx.Response(200, json=_analyzer_json({})) - import wolfbot.master.stt_service as stt_module # noqa: F401 + import wolfbot.master.voice.stt_service as stt_module # noqa: F401 transport = httpx.MockTransport(handler) real = httpx.AsyncClient diff --git a/tests/test_master_tts_playback.py b/tests/test_master_tts_playback.py index b71ee67..ecd15ba 100644 --- a/tests/test_master_tts_playback.py +++ b/tests/test_master_tts_playback.py @@ -17,7 +17,7 @@ import pytest -from wolfbot.master.tts_playback import MasterTtsPlayback +from wolfbot.master.voice.tts_playback import MasterTtsPlayback from wolfbot.npc.tts import FakeTtsService, TtsResult diff --git a/tests/test_master_wolf_chat_broker.py b/tests/test_master_wolf_chat_broker.py index 2c2c373..6e31290 100644 --- a/tests/test_master_wolf_chat_broker.py +++ b/tests/test_master_wolf_chat_broker.py @@ -13,8 +13,8 @@ PrivateStateUpdate, WolfChatSend, ) -from wolfbot.master.npc_registry import InMemoryNpcRegistry -from wolfbot.master.wolf_chat_broker import WolfChatBroker +from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry +from wolfbot.master.ws.wolf_chat_broker import WolfChatBroker from wolfbot.persistence.sqlite_repo import SqliteRepo diff --git a/tests/test_master_ws_server.py b/tests/test_master_ws_server.py index d5724f7..4f1fba2 100644 --- a/tests/test_master_ws_server.py +++ b/tests/test_master_ws_server.py @@ -35,12 +35,12 @@ SpeakResult, SpeechEventPayload, ) -from wolfbot.master.ingest_service import ( +from wolfbot.master.arbiter.ingest_service import ( MasterIngestService, PhaseLookup, ) -from wolfbot.master.npc_registry import InMemoryNpcRegistry -from wolfbot.master.ws_server import ( +from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry +from wolfbot.master.ws.ws_server import ( ConnectionContext, HandlerRegistry, MasterHandlers, @@ -591,7 +591,7 @@ def test_websockets_master_ws_server_constructs_without_starting( psk_match: bool, ) -> None: """Constructor wiring smoke — no actual socket bind.""" - from wolfbot.master.ws_server import WebsocketsMasterWsServer + from wolfbot.master.ws.ws_server import WebsocketsMasterWsServer registry = InMemoryNpcRegistry() handlers = MasterHandlers(registry=registry, now_ms=lambda: 0) diff --git a/tests/test_npc_seat_assignment.py b/tests/test_npc_seat_assignment.py index 23ea016..db2259b 100644 --- a/tests/test_npc_seat_assignment.py +++ b/tests/test_npc_seat_assignment.py @@ -17,7 +17,7 @@ from wolfbot.domain.enums import Phase, Role from wolfbot.domain.models import Game, Seat from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, SpeakRequest -from wolfbot.master.npc_registry import InMemoryNpcRegistry +from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry from wolfbot.npc.openai_compatible_generator import ( _build_system, _build_user, diff --git a/tests/test_npc_seat_lifecycle.py b/tests/test_npc_seat_lifecycle.py index 22fd2d9..9f95f3d 100644 --- a/tests/test_npc_seat_lifecycle.py +++ b/tests/test_npc_seat_lifecycle.py @@ -28,7 +28,7 @@ SeatReleased, SetMuteState, ) -from wolfbot.master.npc_registry import InMemoryNpcRegistry +from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry from wolfbot.npc.client import NpcClient, NpcClientConfig from wolfbot.npc.playback import FakeVoicePlayback from wolfbot.npc.speech_service import FakeNpcGenerator, NpcSpeechService diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index de087fb..4c55606 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -29,9 +29,9 @@ PlaybackRejected, SpeakResult, ) -from wolfbot.master.logic_service import build_logic_packet -from wolfbot.master.npc_registry import InMemoryNpcRegistry -from wolfbot.master.speak_arbiter import SpeakArbiter, SpeakArbiterConfig +from wolfbot.master.arbiter.logic_service import build_logic_packet +from wolfbot.master.arbiter.speak_arbiter import SpeakArbiter, SpeakArbiterConfig +from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry from wolfbot.persistence.sqlite_repo import SqliteRepo from wolfbot.services.discussion_service import ( DiscussionService, @@ -2268,7 +2268,7 @@ async def test_pair_volley_demotion_fires_after_4_low_info_speeches( in the volley. Reproduces the production loop where ラキオ ↔ ジョナス spoke alternately for the entire phase. """ - from wolfbot.master.speak_arbiter import _compute_demoted_seats + from wolfbot.master.arbiter.speak_arbiter import _compute_demoted_seats # 4-event window: 1, 2, 1, 2 (no info) summary = ((1, False), (2, False), (1, False), (2, False)) @@ -2283,7 +2283,7 @@ async def test_pair_volley_resets_when_co_declared() -> None: punished. Note: only the first CO per (seat, role) sets has_info — the fold dedups so re-declaring an existing CO doesn't bypass. """ - from wolfbot.master.speak_arbiter import _compute_demoted_seats + from wolfbot.master.arbiter.speak_arbiter import _compute_demoted_seats summary = ((1, False), (2, False), (1, True), (2, False)) assert _compute_demoted_seats(summary) == frozenset() @@ -2380,7 +2380,7 @@ async def test_repeated_co_from_same_seat_does_not_bypass_gate( # 5 events → summary is capped at 6 → all 5 retained. # Last 4 entries: (2, False), (3, False), (2, False), (3, False) # Pair volley: 2 distinct seats {2,3}, no has_info in window → demote. - from wolfbot.master.speak_arbiter import _compute_demoted_seats + from wolfbot.master.arbiter.speak_arbiter import _compute_demoted_seats assert _compute_demoted_seats(state.recent_speech_summary) == frozenset({2, 3}) @@ -2389,7 +2389,7 @@ async def test_consecutive_cap_demotion_after_3_same_seat() -> None: """Same seat speaking 3 in a row → demote that seat. Mostly fires when a human keeps re-addressing the same NPC, but defensive against any future bug that lets the same seat dispatch repeatedly.""" - from wolfbot.master.speak_arbiter import _compute_demoted_seats + from wolfbot.master.arbiter.speak_arbiter import _compute_demoted_seats summary = ((5, False), (5, False), (5, False)) assert _compute_demoted_seats(summary) == frozenset({5}) @@ -2397,7 +2397,7 @@ async def test_consecutive_cap_demotion_after_3_same_seat() -> None: async def test_compute_demoted_seats_no_op_for_short_window() -> None: """Window shorter than the gate thresholds yields an empty set.""" - from wolfbot.master.speak_arbiter import _compute_demoted_seats + from wolfbot.master.arbiter.speak_arbiter import _compute_demoted_seats assert _compute_demoted_seats(()) == frozenset() assert _compute_demoted_seats(((1, False), (2, False))) == frozenset() @@ -2693,7 +2693,7 @@ async def test_handle_tts_failed_pops_pending_before_returning( "capture game_id first" invariant. """ from wolfbot.domain.ws_messages import TtsFailed - from wolfbot.master.speak_arbiter import _PendingRequest + from wolfbot.master.arbiter.speak_arbiter import _PendingRequest discussion = DiscussionService( store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] @@ -2763,7 +2763,7 @@ async def test_arbiter_cleanup_game_drops_only_target_game(repo: SqliteRepo) -> `_playback_deadlines` across games. Two-game scenario: seed pending state for both, sweep g1, verify g2 untouched. """ - from wolfbot.master.speak_arbiter import _PendingRequest + from wolfbot.master.arbiter.speak_arbiter import _PendingRequest discussion = DiscussionService( store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] diff --git a/tests/test_reactive_voice_mode.py b/tests/test_reactive_voice_mode.py index f9088f9..1fb612f 100644 --- a/tests/test_reactive_voice_mode.py +++ b/tests/test_reactive_voice_mode.py @@ -637,10 +637,10 @@ async def test_main_py_wires_reactive_voice_pipeline_services() -> None: constructors accept the expected parameters.""" import inspect - from wolfbot.master.ingest_service import MasterIngestService - from wolfbot.master.npc_registry import InMemoryNpcRegistry - from wolfbot.master.speak_arbiter import SpeakArbiter - from wolfbot.master.ws_server import ( + from wolfbot.master.arbiter.ingest_service import MasterIngestService + from wolfbot.master.arbiter.speak_arbiter import SpeakArbiter + from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry + from wolfbot.master.ws.ws_server import ( MasterHandlers, WebsocketsMasterWsServer, ) @@ -736,8 +736,8 @@ async def test_arbiter_try_dispatch_next_triggers_on_reactive_voice(repo: Sqlite """SpeakArbiter.try_dispatch_next dispatches a SpeakRequest when a reactive_voice game has an online NPC in a discussion phase.""" from wolfbot.domain.models import Seat - from wolfbot.master.npc_registry import InMemoryNpcRegistry - from wolfbot.master.speak_arbiter import SpeakArbiter + from wolfbot.master.arbiter.speak_arbiter import SpeakArbiter + from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry from wolfbot.services.discussion_service import ( DiscussionService, SqliteSpeechEventStore, @@ -808,8 +808,8 @@ async def _fake_send(msg: str) -> None: async def test_arbiter_try_dispatch_next_noop_for_rounds(repo: SqliteRepo) -> None: """try_dispatch_next must no-op for rounds-mode games.""" - from wolfbot.master.npc_registry import InMemoryNpcRegistry - from wolfbot.master.speak_arbiter import SpeakArbiter + from wolfbot.master.arbiter.speak_arbiter import SpeakArbiter + from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry from wolfbot.services.discussion_service import ( DiscussionService, SqliteSpeechEventStore, @@ -841,8 +841,8 @@ async def test_arbiter_try_dispatch_next_noop_for_rounds(repo: SqliteRepo) -> No async def test_recovery_sweep_closes_open_speak_requests(repo: SqliteRepo) -> None: """reactive_voice_recovery_sweep must close open npc_speak_requests and npc_playback_events with failure_reason=master_restart.""" - from wolfbot.master.npc_registry import InMemoryNpcRegistry - from wolfbot.master.speak_arbiter import SpeakArbiter + from wolfbot.master.arbiter.speak_arbiter import SpeakArbiter + from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry from wolfbot.services.discussion_service import ( DiscussionService, SqliteSpeechEventStore, @@ -1050,8 +1050,8 @@ async def noop_apply(*_a: Any, **_k: Any) -> None: async def test_ws_authenticate_reads_request_path() -> None: """WebsocketsMasterWsServer._authenticate must read ws.request.path (websockets 16.0) rather than the legacy ws.path.""" - from wolfbot.master.npc_registry import InMemoryNpcRegistry - from wolfbot.master.ws_server import MasterHandlers, WebsocketsMasterWsServer + from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry + from wolfbot.master.ws.ws_server import MasterHandlers, WebsocketsMasterWsServer registry = InMemoryNpcRegistry() handlers = MasterHandlers(registry=registry, now_ms=lambda: 0) @@ -1084,8 +1084,8 @@ async def close(self, code: int = 1000, reason: str = "") -> None: async def test_ws_authenticate_rejects_bad_psk() -> None: """Auth must reject when psk doesn't match.""" - from wolfbot.master.npc_registry import InMemoryNpcRegistry - from wolfbot.master.ws_server import MasterHandlers, WebsocketsMasterWsServer + from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry + from wolfbot.master.ws.ws_server import MasterHandlers, WebsocketsMasterWsServer registry = InMemoryNpcRegistry() handlers = MasterHandlers(registry=registry, now_ms=lambda: 0) diff --git a/tests/test_stt_roster_prompt.py b/tests/test_stt_roster_prompt.py index 6bac7ca..68308a5 100644 --- a/tests/test_stt_roster_prompt.py +++ b/tests/test_stt_roster_prompt.py @@ -16,7 +16,7 @@ from __future__ import annotations -from wolfbot.master.stt_service import ( +from wolfbot.master.voice.stt_service import ( GeminiAudioAnalyzer, GroqWhisperAudioAnalyzer, _format_roster_block, diff --git a/tests/test_text_analyzer.py b/tests/test_text_analyzer.py index 01c044b..de235cf 100644 --- a/tests/test_text_analyzer.py +++ b/tests/test_text_analyzer.py @@ -10,7 +10,7 @@ import pytest -from wolfbot.master.text_analyzer import ( +from wolfbot.master.state.text_analyzer import ( FakeTextAnalyzer, GeminiTextAnalyzer, TextAnalysis, @@ -75,7 +75,7 @@ async def test_openai_compatible_text_analyzer_extracts_role_callout( import httpx - from wolfbot.master.text_analyzer import OpenAICompatibleTextAnalyzer + from wolfbot.master.state.text_analyzer import OpenAICompatibleTextAnalyzer captured: dict[str, Any] = {} diff --git a/tests/test_voice_debug_dump.py b/tests/test_voice_debug_dump.py index 367c9d9..2151f4a 100644 --- a/tests/test_voice_debug_dump.py +++ b/tests/test_voice_debug_dump.py @@ -15,8 +15,8 @@ import pytest -from wolfbot.master.stt_service import SttResult -from wolfbot.master.voice_debug_dump import ( +from wolfbot.master.voice.stt_service import SttResult +from wolfbot.master.voice.voice_debug_dump import ( SegmentDumpRecord, debug_dir, dump_segment, @@ -299,7 +299,7 @@ async def test_write_failure_swallowed( ) -> None: """A flaky disk (or read-only mount) must never break the voice path — the dump is best-effort observability only.""" - import wolfbot.master.voice_debug_dump as mod + import wolfbot.master.voice.voice_debug_dump as mod def boom(*_a: object, **_kw: object) -> None: raise OSError("disk full") diff --git a/tests/test_voice_ingest_service.py b/tests/test_voice_ingest_service.py index 49f7766..ff72235 100644 --- a/tests/test_voice_ingest_service.py +++ b/tests/test_voice_ingest_service.py @@ -13,17 +13,17 @@ from __future__ import annotations -from wolfbot.master.stt_service import ( +from wolfbot.master.voice.stt_service import ( FakeSttService, SttProviderError, SttResult, ) -from wolfbot.master.voice_ingest_client import ( +from wolfbot.master.voice.voice_ingest_client import ( FakeMasterIngestionClient, InMemoryNpcRegistryView, make_default_listeners, ) -from wolfbot.master.voice_ingest_service import ( +from wolfbot.master.voice.voice_ingest_service import ( VoiceIngestConfig, VoiceIngestService, ) diff --git a/tests/test_voice_recv_resilience.py b/tests/test_voice_recv_resilience.py index b9ec820..5464274 100644 --- a/tests/test_voice_recv_resilience.py +++ b/tests/test_voice_recv_resilience.py @@ -29,7 +29,7 @@ from discord.opus import OpusError # noqa: E402 -from wolfbot.master.voice_recv_resilience import ( # noqa: E402 +from wolfbot.master.voice.voice_recv_resilience import ( # noqa: E402 apply_packet_router_resilience, ) From 15e83ad14aef1f35cb2ca38a1c70ca59a691810b Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 11:24:14 +0900 Subject: [PATCH 104/133] refactor(npc): reorganize into 4 subdomains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pyproject.toml | 2 +- src/wolfbot/config.py | 2 +- src/wolfbot/llm/decider_config.py | 6 ++--- src/wolfbot/main.py | 2 +- src/wolfbot/master/voice/tts_playback.py | 4 +-- src/wolfbot/npc/audio/__init__.py | 0 src/wolfbot/npc/{ => audio}/playback.py | 0 src/wolfbot/npc/{ => audio}/tts.py | 0 src/wolfbot/npc/decision/__init__.py | 0 .../npc/{ => decision}/decision_service.py | 2 +- src/wolfbot/npc/{ => decision}/game_state.py | 2 +- src/wolfbot/npc/personas.py | 2 +- src/wolfbot/npc/runtime/__init__.py | 0 src/wolfbot/npc/{ => runtime}/client.py | 26 +++++++++---------- src/wolfbot/npc/{ => runtime}/config.py | 2 +- src/wolfbot/npc/{ => runtime}/main.py | 16 ++++++------ src/wolfbot/npc/speech/__init__.py | 0 .../npc/{ => speech}/gemini_generator.py | 8 +++--- .../npc/{ => speech}/generator_factory.py | 12 ++++----- .../npc/{ => speech}/mock_generator.py | 4 +-- .../openai_compatible_generator.py | 8 +++--- .../npc/{ => speech}/speech_service.py | 2 +- src/wolfbot/services/llm_service.py | 4 +-- tests/test_claim_persistence.py | 8 +++--- tests/test_master_private_state.py | 2 +- tests/test_master_tts_playback.py | 2 +- tests/test_npc_config.py | 2 +- tests/test_npc_decision_service.py | 6 ++--- tests/test_npc_game_state.py | 4 +-- tests/test_npc_mock_generator.py | 4 +-- tests/test_npc_seat_assignment.py | 8 +++--- tests/test_npc_seat_lifecycle.py | 8 +++--- tests/test_npc_voice_worker.py | 18 ++++++------- 33 files changed, 83 insertions(+), 83 deletions(-) create mode 100644 src/wolfbot/npc/audio/__init__.py rename src/wolfbot/npc/{ => audio}/playback.py (100%) rename src/wolfbot/npc/{ => audio}/tts.py (100%) create mode 100644 src/wolfbot/npc/decision/__init__.py rename src/wolfbot/npc/{ => decision}/decision_service.py (99%) rename src/wolfbot/npc/{ => decision}/game_state.py (98%) create mode 100644 src/wolfbot/npc/runtime/__init__.py rename src/wolfbot/npc/{ => runtime}/client.py (99%) rename src/wolfbot/npc/{ => runtime}/config.py (98%) rename src/wolfbot/npc/{ => runtime}/main.py (96%) create mode 100644 src/wolfbot/npc/speech/__init__.py rename src/wolfbot/npc/{ => speech}/gemini_generator.py (98%) rename src/wolfbot/npc/{ => speech}/generator_factory.py (89%) rename src/wolfbot/npc/{ => speech}/mock_generator.py (97%) rename src/wolfbot/npc/{ => speech}/openai_compatible_generator.py (99%) rename src/wolfbot/npc/{ => speech}/speech_service.py (99%) diff --git a/pyproject.toml b/pyproject.toml index bfe640d..0c8acc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ [project.scripts] wolfbot = "wolfbot.main:cli" -wolfbot-npc = "wolfbot.npc.main:main" +wolfbot-npc = "wolfbot.npc.runtime.main:main" wolfbot-voicetest = "wolfbot.voicetest.main:run" [build-system] diff --git a/src/wolfbot/config.py b/src/wolfbot/config.py index 1a46ee1..786af4b 100644 --- a/src/wolfbot/config.py +++ b/src/wolfbot/config.py @@ -2,7 +2,7 @@ Loaded once at startup from ``.env.master`` via pydantic-settings. -NPC bot worker settings live in :mod:`wolfbot.npc.config`. +NPC bot worker settings live in :mod:`wolfbot.npc.runtime.config`. """ from __future__ import annotations diff --git a/src/wolfbot/llm/decider_config.py b/src/wolfbot/llm/decider_config.py index 2dcb4d3..e6ae83a 100644 --- a/src/wolfbot/llm/decider_config.py +++ b/src/wolfbot/llm/decider_config.py @@ -9,8 +9,8 @@ :class:`wolfbot.config.MasterSettings` (env prefix ``GAMEPLAY_LLM_*``). * **NPC speech LLM** — drives one NPC bot's short reactive utterance in ``reactive_voice`` mode. Lives in - :mod:`wolfbot.npc.openai_compatible_generator` (and the Vertex Gemini - sibling) and is configured via :class:`wolfbot.npc.config.NpcSettings` + :mod:`wolfbot.npc.speech.openai_compatible_generator` (and the Vertex Gemini + sibling) and is configured via :class:`wolfbot.npc.runtime.config.NpcSettings` (env prefix ``NPC_LLM_*``). Both roles support the same three providers (``xai`` / ``deepseek`` / @@ -42,7 +42,7 @@ class LLMDeciderConfig: Constructed by ``MasterSettings.gameplay_decider_config()`` and ``NpcSettings.npc_decider_config()``; consumed by :func:`wolfbot.services.llm_service.make_llm_decider` (gameplay) and - :func:`wolfbot.npc.generator_factory.make_npc_generator` (NPC). + :func:`wolfbot.npc.speech.generator_factory.make_npc_generator` (NPC). Provider gating: the relevant Settings ``model_validator`` guarantees that the field tied to the chosen provider is non-None / non-empty diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index e979d23..05c6916 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -841,7 +841,7 @@ async def _reactive_voice_reenter(game_id: str) -> None: render_master_narration, ) from wolfbot.master.voice.tts_playback import MasterTtsPlayback - from wolfbot.npc.tts import VoicevoxTtsService + from wolfbot.npc.audio.tts import VoicevoxTtsService master_tts = MasterTtsPlayback( tts=VoicevoxTtsService( diff --git a/src/wolfbot/master/voice/tts_playback.py b/src/wolfbot/master/voice/tts_playback.py index 77250aa..5392f83 100644 --- a/src/wolfbot/master/voice/tts_playback.py +++ b/src/wolfbot/master/voice/tts_playback.py @@ -1,7 +1,7 @@ """Master-side TTS playback — Levi narration via VOICEVOX in VC. NPC bots have their own per-process TTS pipeline -(:mod:`wolfbot.npc.tts` + :mod:`wolfbot.npc.playback`). Master needs a +(:mod:`wolfbot.npc.audio.tts` + :mod:`wolfbot.npc.audio.playback`). Master needs a parallel-but-simpler pipeline: synthesize narration text via the same VOICEVOX HTTP engine, then push the audio through Master's own `discord.VoiceClient.play(...)` so phase-transition announcements are @@ -37,7 +37,7 @@ from collections.abc import AsyncIterator from typing import Any -from wolfbot.npc.tts import ( +from wolfbot.npc.audio.tts import ( InMemoryTtsCache, TtsProviderError, TtsRequest, diff --git a/src/wolfbot/npc/audio/__init__.py b/src/wolfbot/npc/audio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/npc/playback.py b/src/wolfbot/npc/audio/playback.py similarity index 100% rename from src/wolfbot/npc/playback.py rename to src/wolfbot/npc/audio/playback.py diff --git a/src/wolfbot/npc/tts.py b/src/wolfbot/npc/audio/tts.py similarity index 100% rename from src/wolfbot/npc/tts.py rename to src/wolfbot/npc/audio/tts.py diff --git a/src/wolfbot/npc/decision/__init__.py b/src/wolfbot/npc/decision/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/npc/decision_service.py b/src/wolfbot/npc/decision/decision_service.py similarity index 99% rename from src/wolfbot/npc/decision_service.py rename to src/wolfbot/npc/decision/decision_service.py index 2236019..361b90a 100644 --- a/src/wolfbot/npc/decision_service.py +++ b/src/wolfbot/npc/decision/decision_service.py @@ -37,7 +37,7 @@ build_strategy_block, ) from wolfbot.llm.template import render_template -from wolfbot.npc.game_state import NpcGameState +from wolfbot.npc.decision.game_state import NpcGameState log = logging.getLogger(__name__) diff --git a/src/wolfbot/npc/game_state.py b/src/wolfbot/npc/decision/game_state.py similarity index 98% rename from src/wolfbot/npc/game_state.py rename to src/wolfbot/npc/decision/game_state.py index ab8d833..cde9c38 100644 --- a/src/wolfbot/npc/game_state.py +++ b/src/wolfbot/npc/decision/game_state.py @@ -9,7 +9,7 @@ from a Master-sent snapshot at re-register. This module is the pure data container. The dispatch / WS handlers live -in :mod:`wolfbot.npc.client`; the prompt-building consumers live in the +in :mod:`wolfbot.npc.runtime.client`; the prompt-building consumers live in the NPC LLM generators. """ diff --git a/src/wolfbot/npc/personas.py b/src/wolfbot/npc/personas.py index 504a277..e5fb5b8 100644 --- a/src/wolfbot/npc/personas.py +++ b/src/wolfbot/npc/personas.py @@ -5,7 +5,7 @@ ``pick_personas``) and writes the chosen ``persona_key`` onto each LLM ``Seat``; both rounds-mode prompt building (``services.llm_service``) and reactive_voice NPC speech generation -(:mod:`wolfbot.npc.openai_compatible_generator`) look up the persona by +(:mod:`wolfbot.npc.speech.openai_compatible_generator`) look up the persona by that key. Names are taken from Gnosia; style guidelines describe judgment tendency diff --git a/src/wolfbot/npc/runtime/__init__.py b/src/wolfbot/npc/runtime/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/npc/client.py b/src/wolfbot/npc/runtime/client.py similarity index 99% rename from src/wolfbot/npc/client.py rename to src/wolfbot/npc/runtime/client.py index 280f376..9913a34 100644 --- a/src/wolfbot/npc/client.py +++ b/src/wolfbot/npc/runtime/client.py @@ -47,7 +47,17 @@ WolfChatRequest, WolfChatSend, ) -from wolfbot.npc.decision_service import ( +from wolfbot.npc.audio.playback import ( + VoicePlayback, + VoicePlaybackError, +) +from wolfbot.npc.audio.tts import ( + InMemoryTtsCache, + TtsProviderError, + TtsRequest, + TtsService, +) +from wolfbot.npc.decision.decision_service import ( _NIGHT_SCHEMA, _VOTE_SCHEMA, _WOLF_CHAT_SCHEMA, @@ -58,18 +68,8 @@ parse_decision, parse_wolf_chat_text, ) -from wolfbot.npc.game_state import NpcGameState, apply_update, state_from_snapshot -from wolfbot.npc.playback import ( - VoicePlayback, - VoicePlaybackError, -) -from wolfbot.npc.speech_service import NpcSpeechService -from wolfbot.npc.tts import ( - InMemoryTtsCache, - TtsProviderError, - TtsRequest, - TtsService, -) +from wolfbot.npc.decision.game_state import NpcGameState, apply_update, state_from_snapshot +from wolfbot.npc.speech.speech_service import NpcSpeechService log = logging.getLogger(__name__) diff --git a/src/wolfbot/npc/config.py b/src/wolfbot/npc/runtime/config.py similarity index 98% rename from src/wolfbot/npc/config.py rename to src/wolfbot/npc/runtime/config.py index dc43c6d..cd7213d 100644 --- a/src/wolfbot/npc/config.py +++ b/src/wolfbot/npc/runtime/config.py @@ -1,7 +1,7 @@ """NPC bot worker settings. Loaded once at startup via pydantic-settings. The actual env file path -is selected by :mod:`wolfbot.npc.main` from ``WOLFBOT_NPC_ENV``; per- +is selected by :mod:`wolfbot.npc.runtime.main` from ``WOLFBOT_NPC_ENV``; per- persona templates live under ``envs/npc/.env..example`` (see :file:`envs/npc/README.md`). One NPC worker process = one persona = one ``envs/npc/.env.`` file (each NPC needs its own Discord bot diff --git a/src/wolfbot/npc/main.py b/src/wolfbot/npc/runtime/main.py similarity index 96% rename from src/wolfbot/npc/main.py rename to src/wolfbot/npc/runtime/main.py index 6da12d9..63d5fa9 100644 --- a/src/wolfbot/npc/main.py +++ b/src/wolfbot/npc/runtime/main.py @@ -1,6 +1,6 @@ """NPC bot worker entrypoint. -Loads :class:`wolfbot.npc.config.NpcSettings` from the env file pointed +Loads :class:`wolfbot.npc.runtime.config.NpcSettings` from the env file pointed to by ``WOLFBOT_NPC_ENV`` (default ``.env.npc``), connects to Discord VC, opens a WS connection to Master, registers, and runs the heartbeat + message loop. On ``speak_request`` the NPC generates text via the @@ -27,7 +27,7 @@ import discord from dotenv import load_dotenv -from wolfbot.npc.config import NpcSettings +from wolfbot.npc.runtime.config import NpcSettings log = logging.getLogger(__name__) @@ -207,12 +207,12 @@ async def _ensure_vc_left() -> None: discord_user_id = str(bot.user.id) if bot.user else "unknown" # ---- Build NPC pipeline ---- - from wolfbot.npc.client import NpcClient, NpcClientConfig - from wolfbot.npc.decision_service import DecisionLLM - from wolfbot.npc.generator_factory import make_npc_generator - from wolfbot.npc.playback import DiscordVoicePlayback, VoicePlaybackError - from wolfbot.npc.speech_service import NpcSpeechService - from wolfbot.npc.tts import VoicevoxTtsService + from wolfbot.npc.audio.playback import DiscordVoicePlayback, VoicePlaybackError + from wolfbot.npc.audio.tts import VoicevoxTtsService + from wolfbot.npc.decision.decision_service import DecisionLLM + from wolfbot.npc.runtime.client import NpcClient, NpcClientConfig + from wolfbot.npc.speech.generator_factory import make_npc_generator + from wolfbot.npc.speech.speech_service import NpcSpeechService generator = make_npc_generator( settings.npc_decider_config(), diff --git a/src/wolfbot/npc/speech/__init__.py b/src/wolfbot/npc/speech/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/npc/gemini_generator.py b/src/wolfbot/npc/speech/gemini_generator.py similarity index 98% rename from src/wolfbot/npc/gemini_generator.py rename to src/wolfbot/npc/speech/gemini_generator.py index 4fb2cff..cdb9332 100644 --- a/src/wolfbot/npc/gemini_generator.py +++ b/src/wolfbot/npc/speech/gemini_generator.py @@ -1,6 +1,6 @@ """Vertex AI Gemini-backed NPC speech generator. -Mirror of :mod:`wolfbot.npc.openai_compatible_generator` but talks to +Mirror of :mod:`wolfbot.npc.speech.openai_compatible_generator` but talks to Vertex AI's Gemini API via the official ``google-genai`` SDK with ``response_mime_type="application/json"`` + ``response_json_schema`` for structured output and ``ThinkingConfig.thinking_level`` for thinking @@ -25,14 +25,14 @@ from typing import Literal from wolfbot.domain.ws_messages import LogicPacket, SpeakRequest -from wolfbot.npc.openai_compatible_generator import ( +from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY +from wolfbot.npc.speech.openai_compatible_generator import ( _RESPONSE_SCHEMA, _build_speech_from_json, _build_system, _build_user, ) -from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY -from wolfbot.npc.speech_service import NpcGeneratedSpeech +from wolfbot.npc.speech.speech_service import NpcGeneratedSpeech log = logging.getLogger(__name__) diff --git a/src/wolfbot/npc/generator_factory.py b/src/wolfbot/npc/speech/generator_factory.py similarity index 89% rename from src/wolfbot/npc/generator_factory.py rename to src/wolfbot/npc/speech/generator_factory.py index c84d4a3..c698b10 100644 --- a/src/wolfbot/npc/generator_factory.py +++ b/src/wolfbot/npc/speech/generator_factory.py @@ -4,7 +4,7 @@ the NPC speech path: takes a provider-agnostic :class:`wolfbot.llm.decider_config.LLMDeciderConfig` plus the persona key bound to the worker process at startup, and returns a fully -configured :class:`wolfbot.npc.speech_service.NpcGenerator`. +configured :class:`wolfbot.npc.speech.speech_service.NpcGenerator`. Three providers supported, identical to the gameplay LLM path: @@ -24,12 +24,12 @@ from __future__ import annotations from wolfbot.llm.decider_config import LLMDeciderConfig -from wolfbot.npc.speech_service import NpcGenerator +from wolfbot.npc.speech.speech_service import NpcGenerator def make_npc_generator(cfg: LLMDeciderConfig, *, persona_key: str) -> NpcGenerator: if cfg.provider == "xai": - from wolfbot.npc.openai_compatible_generator import ( + from wolfbot.npc.speech.openai_compatible_generator import ( OpenAICompatibleConfig, OpenAICompatibleNpcGenerator, ) @@ -48,7 +48,7 @@ def make_npc_generator(cfg: LLMDeciderConfig, *, persona_key: str) -> NpcGenerat return gen if cfg.provider == "deepseek": - from wolfbot.npc.openai_compatible_generator import ( + from wolfbot.npc.speech.openai_compatible_generator import ( OpenAICompatibleConfig, OpenAICompatibleNpcGenerator, ) @@ -69,7 +69,7 @@ def make_npc_generator(cfg: LLMDeciderConfig, *, persona_key: str) -> NpcGenerat return gen_ds if cfg.provider == "gemini": - from wolfbot.npc.gemini_generator import ( + from wolfbot.npc.speech.gemini_generator import ( GeminiNpcGenerator, GeminiVertexConfig, ) @@ -94,7 +94,7 @@ def make_npc_generator(cfg: LLMDeciderConfig, *, persona_key: str) -> NpcGenerat return gen_gemini if cfg.provider == "mock": - from wolfbot.npc.mock_generator import MockNpcGenerator + from wolfbot.npc.speech.mock_generator import MockNpcGenerator gen_mock = MockNpcGenerator() gen_mock.set_persona(persona_key) diff --git a/src/wolfbot/npc/mock_generator.py b/src/wolfbot/npc/speech/mock_generator.py similarity index 97% rename from src/wolfbot/npc/mock_generator.py rename to src/wolfbot/npc/speech/mock_generator.py index f4f0e8d..6f6513c 100644 --- a/src/wolfbot/npc/mock_generator.py +++ b/src/wolfbot/npc/speech/mock_generator.py @@ -3,7 +3,7 @@ Returns scripted utterances per persona, no network call. Each NPC bot worker is bound to exactly one persona at startup. The factory -:func:`wolfbot.npc.generator_factory.make_npc_generator` calls +:func:`wolfbot.npc.speech.generator_factory.make_npc_generator` calls :meth:`MockNpcGenerator.set_persona` after construction so the mock can pick the right canned-phrase pool. When the persona key is unknown to this module, a generic fallback script is used so a new persona doesn't @@ -20,7 +20,7 @@ from collections.abc import Sequence from wolfbot.domain.ws_messages import LogicPacket, SpeakRequest -from wolfbot.npc.speech_service import NpcGeneratedSpeech +from wolfbot.npc.speech.speech_service import NpcGeneratedSpeech _PERSONA_SCRIPTS: dict[str, tuple[str, ...]] = { "setsu": ( diff --git a/src/wolfbot/npc/openai_compatible_generator.py b/src/wolfbot/npc/speech/openai_compatible_generator.py similarity index 99% rename from src/wolfbot/npc/openai_compatible_generator.py rename to src/wolfbot/npc/speech/openai_compatible_generator.py index c47f1a3..5dced29 100644 --- a/src/wolfbot/npc/openai_compatible_generator.py +++ b/src/wolfbot/npc/speech/openai_compatible_generator.py @@ -25,10 +25,10 @@ ``style_guide`` and ``speech_profile`` are included for voice consistency but the strategic rules sections are omitted. -For Vertex AI Gemini, see :mod:`wolfbot.npc.gemini_generator` — that +For Vertex AI Gemini, see :mod:`wolfbot.npc.speech.gemini_generator` — that provider is not OpenAI-compatible and uses the ``google-genai`` SDK. The right generator for a given ``LLMDeciderConfig`` is picked by -:func:`wolfbot.npc.generator_factory.make_npc_generator`. +:func:`wolfbot.npc.speech.generator_factory.make_npc_generator`. """ from __future__ import annotations @@ -48,7 +48,7 @@ ) from wolfbot.llm.template import render_template from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY -from wolfbot.npc.speech_service import NpcGeneratedSpeech +from wolfbot.npc.speech.speech_service import NpcGeneratedSpeech log = logging.getLogger(__name__) @@ -480,7 +480,7 @@ class OpenAICompatibleConfig: class OpenAICompatibleNpcGenerator: """Production NpcGenerator backed by any OpenAI-compatible LLM endpoint. - Implements :class:`wolfbot.npc.speech_service.NpcGenerator` via the + Implements :class:`wolfbot.npc.speech.speech_service.NpcGenerator` via the ``openai`` SDK's ``chat.completions`` API. The choice of provider is a config decision (``base_url`` + ``model`` + ``mode``), not a code decision. Strict ``json_schema`` mode is the default; DeepSeek diff --git a/src/wolfbot/npc/speech_service.py b/src/wolfbot/npc/speech/speech_service.py similarity index 99% rename from src/wolfbot/npc/speech_service.py rename to src/wolfbot/npc/speech/speech_service.py index 81bde9d..633dc77 100644 --- a/src/wolfbot/npc/speech_service.py +++ b/src/wolfbot/npc/speech/speech_service.py @@ -22,7 +22,7 @@ SpeakRequest, SpeakResult, ) -from wolfbot.npc.game_state import NpcGameState +from wolfbot.npc.decision.game_state import NpcGameState log = logging.getLogger(__name__) diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index 5aa5d36..0ec4fe9 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -16,7 +16,7 @@ `LLMDeciderConfig.provider`. ``cfg`` is built by ``MasterSettings.gameplay_decider_config()`` from ``GAMEPLAY_LLM_*`` env vars (the symmetrical NPC factory in - :mod:`wolfbot.npc.generator_factory` consumes the same config dataclass + :mod:`wolfbot.npc.speech.generator_factory` consumes the same config dataclass built from ``NPC_LLM_*`` instead). - `LLMAdapter`: implements the LLMAdapter Protocol consumed by game_service; iterates LLM seats and submits their actions via GameService.submit_*. @@ -2121,7 +2121,7 @@ def make_llm_decider(cfg: LLMDeciderConfig) -> LLMActionDecider: The same factory is used for both Master's Gameplay LLM (built from ``MasterSettings.gameplay_decider_config()``) and any ad-hoc gameplay decider tests need. NPC speech generation has its own factory in - :mod:`wolfbot.npc.generator_factory` because the NPC schema differs. + :mod:`wolfbot.npc.speech.generator_factory` because the NPC schema differs. """ if cfg.provider == "xai": assert cfg.api_key is not None # validated in Settings diff --git a/tests/test_claim_persistence.py b/tests/test_claim_persistence.py index 20b3511..260e90c 100644 --- a/tests/test_claim_persistence.py +++ b/tests/test_claim_persistence.py @@ -21,8 +21,8 @@ LogicPacket, SpeakRequest, ) -from wolfbot.npc.openai_compatible_generator import _parse_claim_fields -from wolfbot.npc.speech_service import ( +from wolfbot.npc.speech.openai_compatible_generator import _parse_claim_fields +from wolfbot.npc.speech.speech_service import ( NpcGeneratedSpeech, NpcSpeechService, ) @@ -173,7 +173,7 @@ async def test_speech_service_threads_claim_into_speak_result() -> None: """``NpcSpeechService.respond`` must lift the generated speech's ``claimed_*`` fields onto the wire model so Master persists them on the SpeechEvent.""" - from wolfbot.npc.speech_service import FakeNpcGenerator + from wolfbot.npc.speech.speech_service import FakeNpcGenerator speech = NpcGeneratedSpeech( text="昨夜セツを占って白だった。", @@ -229,7 +229,7 @@ async def test_speech_service_drops_self_claim() -> None: """A wolf NPC that names its own seat as the divined target is self-incriminating gibberish; the service drops the structured claim before persisting (the speech itself still goes through).""" - from wolfbot.npc.speech_service import FakeNpcGenerator + from wolfbot.npc.speech.speech_service import FakeNpcGenerator speech = NpcGeneratedSpeech( text="自分を占いました(バグ)", diff --git a/tests/test_master_private_state.py b/tests/test_master_private_state.py index 0c5e744..dc6be90 100644 --- a/tests/test_master_private_state.py +++ b/tests/test_master_private_state.py @@ -31,7 +31,7 @@ make_seer_result_update, make_wolf_chat_update, ) -from wolfbot.npc.game_state import apply_update, state_from_snapshot +from wolfbot.npc.decision.game_state import apply_update, state_from_snapshot from wolfbot.persistence.schema import migrate from wolfbot.persistence.sqlite_repo import SqliteRepo diff --git a/tests/test_master_tts_playback.py b/tests/test_master_tts_playback.py index ecd15ba..fb93433 100644 --- a/tests/test_master_tts_playback.py +++ b/tests/test_master_tts_playback.py @@ -18,7 +18,7 @@ import pytest from wolfbot.master.voice.tts_playback import MasterTtsPlayback -from wolfbot.npc.tts import FakeTtsService, TtsResult +from wolfbot.npc.audio.tts import FakeTtsService, TtsResult @dataclass diff --git a/tests/test_npc_config.py b/tests/test_npc_config.py index 2f15bbf..bfaa09d 100644 --- a/tests/test_npc_config.py +++ b/tests/test_npc_config.py @@ -9,7 +9,7 @@ import pytest from pydantic import SecretStr, ValidationError -from wolfbot.npc.config import NpcSettings +from wolfbot.npc.runtime.config import NpcSettings def _base_kwargs() -> dict[str, object]: diff --git a/tests/test_npc_decision_service.py b/tests/test_npc_decision_service.py index c12dd67..f38dbfe 100644 --- a/tests/test_npc_decision_service.py +++ b/tests/test_npc_decision_service.py @@ -15,12 +15,12 @@ DecideNightActionRequest, DecideVoteRequest, ) -from wolfbot.npc.decision_service import ( +from wolfbot.npc.decision.decision_service import ( build_night_prompt, build_vote_prompt, parse_decision, ) -from wolfbot.npc.game_state import NpcGameState +from wolfbot.npc.decision.game_state import NpcGameState from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY @@ -146,7 +146,7 @@ def test_build_wolf_chat_prompt_includes_role_strategy_block() -> None: The night-action prompt does carry it, but by then `wolf_chat_history` already locks the wolves to the chat-time consensus. """ - from wolfbot.npc.decision_service import build_wolf_chat_prompt + from wolfbot.npc.decision.decision_service import build_wolf_chat_prompt persona = NPC_PERSONAS_BY_KEY["gina"] _system, user = build_wolf_chat_prompt( diff --git a/tests/test_npc_game_state.py b/tests/test_npc_game_state.py index df05a01..bce2c88 100644 --- a/tests/test_npc_game_state.py +++ b/tests/test_npc_game_state.py @@ -25,12 +25,12 @@ VoteDecision, WolfChatLine, ) -from wolfbot.npc.client import NpcClient, NpcClientConfig -from wolfbot.npc.game_state import ( +from wolfbot.npc.decision.game_state import ( NpcGameState, apply_update, state_from_snapshot, ) +from wolfbot.npc.runtime.client import NpcClient, NpcClientConfig def _snapshot(**overrides: object) -> PrivateStateSnapshot: diff --git a/tests/test_npc_mock_generator.py b/tests/test_npc_mock_generator.py index c03078f..c6d355d 100644 --- a/tests/test_npc_mock_generator.py +++ b/tests/test_npc_mock_generator.py @@ -9,8 +9,8 @@ from wolfbot.domain.ws_messages import LogicPacket, SpeakRequest from wolfbot.llm.decider_config import LLMDeciderConfig -from wolfbot.npc.generator_factory import make_npc_generator -from wolfbot.npc.mock_generator import MockNpcGenerator +from wolfbot.npc.speech.generator_factory import make_npc_generator +from wolfbot.npc.speech.mock_generator import MockNpcGenerator def _logic_packet() -> LogicPacket: diff --git a/tests/test_npc_seat_assignment.py b/tests/test_npc_seat_assignment.py index db2259b..2b29042 100644 --- a/tests/test_npc_seat_assignment.py +++ b/tests/test_npc_seat_assignment.py @@ -18,12 +18,12 @@ from wolfbot.domain.models import Game, Seat from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, SpeakRequest from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry -from wolfbot.npc.openai_compatible_generator import ( +from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY as PERSONAS_BY_KEY +from wolfbot.npc.speech.openai_compatible_generator import ( _build_system, _build_user, _format_candidate, ) -from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY as PERSONAS_BY_KEY from wolfbot.persistence.sqlite_repo import SqliteRepo @@ -410,7 +410,7 @@ def test_build_user_prompt_uses_npc_state_over_request_fields() -> None: SeerResult, WolfChatLine, ) - from wolfbot.npc.game_state import NpcGameState + from wolfbot.npc.decision.game_state import NpcGameState logic = LogicPacket( ts=1, trace_id="t", packet_id="lp", phase_id="ph", @@ -485,7 +485,7 @@ def test_build_user_prompt_falls_back_to_request_when_state_none() -> None: def test_build_user_prompt_tags_dead_seats_with_death_cause() -> None: """Dead seat list must distinguish executions from attacks so the NPC stops calling yesterday's executed player "昨夜の犠牲者".""" - from wolfbot.npc.game_state import NpcGameState + from wolfbot.npc.decision.game_state import NpcGameState logic = LogicPacket( ts=1, trace_id="t", packet_id="lp", phase_id="ph", diff --git a/tests/test_npc_seat_lifecycle.py b/tests/test_npc_seat_lifecycle.py index 9f95f3d..d8ca4c5 100644 --- a/tests/test_npc_seat_lifecycle.py +++ b/tests/test_npc_seat_lifecycle.py @@ -29,10 +29,10 @@ SetMuteState, ) from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry -from wolfbot.npc.client import NpcClient, NpcClientConfig -from wolfbot.npc.playback import FakeVoicePlayback -from wolfbot.npc.speech_service import FakeNpcGenerator, NpcSpeechService -from wolfbot.npc.tts import FakeTtsService +from wolfbot.npc.audio.playback import FakeVoicePlayback +from wolfbot.npc.audio.tts import FakeTtsService +from wolfbot.npc.runtime.client import NpcClient, NpcClientConfig +from wolfbot.npc.speech.speech_service import FakeNpcGenerator, NpcSpeechService @dataclass diff --git a/tests/test_npc_voice_worker.py b/tests/test_npc_voice_worker.py index 151a269..a1477f0 100644 --- a/tests/test_npc_voice_worker.py +++ b/tests/test_npc_voice_worker.py @@ -31,22 +31,22 @@ SpeakResult, TtsFinished, ) -from wolfbot.npc.client import NpcClient, NpcClientConfig -from wolfbot.npc.playback import ( +from wolfbot.npc.audio.playback import ( FakeVoicePlayback, VoicePlaybackError, ) -from wolfbot.npc.speech_service import ( - FakeNpcGenerator, - NpcGeneratedSpeech, - NpcSpeechService, -) -from wolfbot.npc.tts import ( +from wolfbot.npc.audio.tts import ( FakeTtsService, InMemoryTtsCache, TtsProviderError, TtsResult, ) +from wolfbot.npc.runtime.client import NpcClient, NpcClientConfig +from wolfbot.npc.speech.speech_service import ( + FakeNpcGenerator, + NpcGeneratedSpeech, + NpcSpeechService, +) def _make_client( @@ -375,7 +375,7 @@ def test_npc_bot_main_module_loads() -> None: """Smoke-load the entrypoint module to catch import-time regressions.""" import importlib - mod = importlib.import_module("wolfbot.npc.main") + mod = importlib.import_module("wolfbot.npc.runtime.main") assert hasattr(mod, "main") From c660ddc6419c2d5947e24e77bce4a36844fa9bdc Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 11:34:04 +0900 Subject: [PATCH 105/133] refactor(persistence): split schema DDL into per-entity .sql files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/persistence/schema.py | 276 ++---------------- src/wolfbot/persistence/sql/__init__.py | 52 ++++ .../persistence/sql/schema/01_games.sql | 28 ++ .../persistence/sql/schema/02_seats.sql | 21 ++ .../sql/schema/03_night_actions.sql | 13 + .../persistence/sql/schema/04_votes.sql | 13 + .../sql/schema/05_previous_guard.sql | 9 + .../persistence/sql/schema/06_logs_public.sql | 15 + .../sql/schema/07_logs_private.sql | 17 ++ .../sql/schema/08_pending_decisions.sql | 11 + .../sql/schema/09_persona_assignments.sql | 10 + .../sql/schema/10_llm_speech_counts.sql | 15 + .../sql/schema/11_speech_events.sql | 40 +++ .../sql/schema/12_npc_speak_requests.sql | 24 ++ .../sql/schema/13_npc_speak_results.sql | 19 ++ .../sql/schema/14_npc_playback_events.sql | 24 ++ tests/test_sql_loader.py | 144 +++++++++ 17 files changed, 482 insertions(+), 249 deletions(-) create mode 100644 src/wolfbot/persistence/sql/__init__.py create mode 100644 src/wolfbot/persistence/sql/schema/01_games.sql create mode 100644 src/wolfbot/persistence/sql/schema/02_seats.sql create mode 100644 src/wolfbot/persistence/sql/schema/03_night_actions.sql create mode 100644 src/wolfbot/persistence/sql/schema/04_votes.sql create mode 100644 src/wolfbot/persistence/sql/schema/05_previous_guard.sql create mode 100644 src/wolfbot/persistence/sql/schema/06_logs_public.sql create mode 100644 src/wolfbot/persistence/sql/schema/07_logs_private.sql create mode 100644 src/wolfbot/persistence/sql/schema/08_pending_decisions.sql create mode 100644 src/wolfbot/persistence/sql/schema/09_persona_assignments.sql create mode 100644 src/wolfbot/persistence/sql/schema/10_llm_speech_counts.sql create mode 100644 src/wolfbot/persistence/sql/schema/11_speech_events.sql create mode 100644 src/wolfbot/persistence/sql/schema/12_npc_speak_requests.sql create mode 100644 src/wolfbot/persistence/sql/schema/13_npc_speak_results.sql create mode 100644 src/wolfbot/persistence/sql/schema/14_npc_playback_events.sql create mode 100644 tests/test_sql_loader.py diff --git a/src/wolfbot/persistence/schema.py b/src/wolfbot/persistence/schema.py index 572919e..94744ba 100644 --- a/src/wolfbot/persistence/schema.py +++ b/src/wolfbot/persistence/schema.py @@ -1,6 +1,21 @@ """SQLite schema + migrate(). -All DDL is idempotent via `CREATE TABLE IF NOT EXISTS`. Safe to re-run on every boot. +DDL for the base ``CREATE TABLE`` / ``CREATE INDEX`` statements lives in +:mod:`wolfbot.persistence.sql` as one ``NN_.sql`` file per +logical table group; :func:`migrate` loads them in lex order and +executes each statement. All CREATE statements are idempotent via +``IF NOT EXISTS`` so the routine is safe to re-run on every boot. + +Additive column migrations stay in Python because SQLite has no +``ALTER TABLE ... ADD COLUMN IF NOT EXISTS``. The block at the bottom +of :func:`migrate` walks ``PRAGMA table_info()`` and only adds +the column when missing, which keeps existing DBs upgrading cleanly +without dropping data. + +When you add a new column to one of the ``schema/*.sql`` files, you +MUST also add a guarded ``ALTER TABLE`` here, otherwise old DBs +upgraded in place keep the pre-add schema and subsequent INSERTs fail +with "no column named ...". """ from __future__ import annotations @@ -9,247 +24,7 @@ import aiosqlite -DDL: list[str] = [ - """ - CREATE TABLE IF NOT EXISTS games ( - id TEXT PRIMARY KEY, - guild_id TEXT NOT NULL, - host_user_id TEXT NOT NULL, - phase TEXT NOT NULL, - day_number INTEGER NOT NULL DEFAULT 0, - deadline_epoch INTEGER, - main_text_channel_id TEXT NOT NULL, - main_vc_channel_id TEXT NOT NULL, - heaven_channel_id TEXT, - wolves_channel_id TEXT, - created_at INTEGER NOT NULL, - ended_at INTEGER, - force_skip_pending INTEGER NOT NULL DEFAULT 0, - discussion_mode TEXT NOT NULL DEFAULT 'rounds' - ) - """, - """ - CREATE INDEX IF NOT EXISTS idx_games_active - ON games(ended_at) WHERE ended_at IS NULL - """, - """ - CREATE UNIQUE INDEX IF NOT EXISTS idx_games_unique_active - ON games(guild_id) WHERE ended_at IS NULL - """, - """ - CREATE TABLE IF NOT EXISTS seats ( - game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, - seat_no INTEGER NOT NULL, - discord_user_id TEXT, - display_name TEXT NOT NULL, - is_llm INTEGER NOT NULL, - persona_key TEXT, - role TEXT, - alive INTEGER NOT NULL DEFAULT 1, - death_cause TEXT, - death_day INTEGER, - dm_channel_id TEXT, - PRIMARY KEY (game_id, seat_no) - ) - """, - """ - CREATE INDEX IF NOT EXISTS idx_seats_user ON seats(discord_user_id) - """, - """ - CREATE TABLE IF NOT EXISTS night_actions ( - game_id TEXT NOT NULL, - day INTEGER NOT NULL, - actor_seat INTEGER NOT NULL, - kind TEXT NOT NULL, - target_seat INTEGER, - submitted_at INTEGER NOT NULL, - PRIMARY KEY (game_id, day, actor_seat, kind), - FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE - ) - """, - """ - CREATE TABLE IF NOT EXISTS votes ( - game_id TEXT NOT NULL, - day INTEGER NOT NULL, - round INTEGER NOT NULL, - voter_seat INTEGER NOT NULL, - target_seat INTEGER, - submitted_at INTEGER NOT NULL, - PRIMARY KEY (game_id, day, round, voter_seat), - FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE - ) - """, - """ - CREATE TABLE IF NOT EXISTS previous_guard ( - game_id TEXT PRIMARY KEY REFERENCES games(id) ON DELETE CASCADE, - knight_seat INTEGER NOT NULL, - last_guard_seat INTEGER, - last_guard_day INTEGER - ) - """, - """ - CREATE TABLE IF NOT EXISTS logs_public ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - game_id TEXT NOT NULL, - day INTEGER NOT NULL, - phase TEXT NOT NULL, - kind TEXT NOT NULL, - actor_seat INTEGER, - text TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - """, - """ - CREATE INDEX IF NOT EXISTS idx_pub_logs_game ON logs_public(game_id, created_at) - """, - """ - CREATE TABLE IF NOT EXISTS logs_private ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - game_id TEXT NOT NULL, - day INTEGER NOT NULL, - phase TEXT NOT NULL, - kind TEXT NOT NULL, - actor_seat INTEGER, - audience_seat INTEGER, - text TEXT NOT NULL, - payload_json TEXT, - created_at INTEGER NOT NULL - ) - """, - """ - CREATE INDEX IF NOT EXISTS idx_priv_logs_game ON logs_private(game_id, created_at) - """, - """ - CREATE TABLE IF NOT EXISTS pending_decisions ( - game_id TEXT PRIMARY KEY REFERENCES games(id) ON DELETE CASCADE, - phase TEXT NOT NULL, - day INTEGER NOT NULL, - required_submission TEXT NOT NULL, - missing_seats_json TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - """, - """ - CREATE TABLE IF NOT EXISTS persona_assignments ( - game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, - seat_no INTEGER NOT NULL, - persona_key TEXT NOT NULL, - PRIMARY KEY (game_id, seat_no), - UNIQUE (game_id, persona_key) - ) - """, - """ - CREATE TABLE IF NOT EXISTS llm_speech_counts ( - game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, - day INTEGER NOT NULL, - seat_no INTEGER NOT NULL, - normal_count INTEGER NOT NULL DEFAULT 0, - vote_intent_done INTEGER NOT NULL DEFAULT 0, - last_spoke_epoch INTEGER, - discussion_rounds_done INTEGER NOT NULL DEFAULT 0, - runoff_speech_done INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (game_id, day, seat_no) - ) - """, - """ - CREATE TABLE IF NOT EXISTS speech_events ( - event_id TEXT PRIMARY KEY, - game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, - phase_id TEXT NOT NULL, - day INTEGER NOT NULL, - phase TEXT NOT NULL, - source TEXT NOT NULL, - speaker_kind TEXT NOT NULL, - speaker_seat INTEGER, - text TEXT NOT NULL, - stt_confidence REAL, - audio_start_ms INTEGER, - audio_end_ms INTEGER, - alive_seat_nos_json TEXT, - summary TEXT, - co_declaration TEXT, - addressed_seat_no INTEGER, - addressed_seat_nos_json TEXT, - role_callout TEXT, - claimed_seer_target_seat INTEGER, - claimed_seer_is_wolf INTEGER, - claimed_medium_target_seat INTEGER, - claimed_medium_is_wolf INTEGER, - created_at_ms INTEGER NOT NULL - ) - """, - """ - CREATE INDEX IF NOT EXISTS idx_speech_events_phase - ON speech_events(game_id, phase_id, created_at_ms) - """, - """ - CREATE INDEX IF NOT EXISTS idx_speech_events_seat - ON speech_events(game_id, phase_id, speaker_seat) - """, - """ - CREATE TABLE IF NOT EXISTS npc_speak_requests ( - request_id TEXT PRIMARY KEY, - game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, - phase_id TEXT NOT NULL, - npc_id TEXT NOT NULL, - seat_no INTEGER NOT NULL, - logic_packet_id TEXT NOT NULL, - suggested_intent TEXT NOT NULL, - max_chars INTEGER NOT NULL, - max_duration_ms INTEGER NOT NULL, - priority INTEGER NOT NULL DEFAULT 0, - expires_at_ms INTEGER NOT NULL, - created_at_ms INTEGER NOT NULL, - selection_reason TEXT, - public_state_snapshot_json TEXT - ) - """, - """ - CREATE INDEX IF NOT EXISTS idx_npc_speak_requests_game_phase - ON npc_speak_requests(game_id, phase_id, expires_at_ms) - """, - """ - CREATE TABLE IF NOT EXISTS npc_speak_results ( - request_id TEXT PRIMARY KEY, - game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, - phase_id TEXT NOT NULL, - npc_id TEXT NOT NULL, - status TEXT NOT NULL, - text TEXT, - used_logic_ids_json TEXT, - intent TEXT, - estimated_duration_ms INTEGER, - failure_reason TEXT, - received_at_ms INTEGER NOT NULL - ) - """, - """ - CREATE INDEX IF NOT EXISTS idx_npc_speak_results_phase - ON npc_speak_results(game_id, phase_id, received_at_ms) - """, - """ - CREATE TABLE IF NOT EXISTS npc_playback_events ( - request_id TEXT PRIMARY KEY, - game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, - phase_id TEXT NOT NULL, - npc_id TEXT NOT NULL, - speech_event_id TEXT, - authorized_at_ms INTEGER NOT NULL, - playback_deadline_ms INTEGER NOT NULL, - finished_at_ms INTEGER, - outcome TEXT, - failure_reason TEXT, - tts_outcome TEXT, - tts_duration_ms INTEGER, - tts_failure_reason TEXT - ) - """, - """ - CREATE INDEX IF NOT EXISTS idx_npc_playback_events_open - ON npc_playback_events(game_id, finished_at_ms) - WHERE finished_at_ms IS NULL - """, -] +from wolfbot.persistence.sql import load_schema_script async def migrate(db_path: str | Path) -> None: @@ -259,13 +34,13 @@ async def migrate(db_path: str | Path) -> None: async with aiosqlite.connect(str(path)) as db: await db.execute("PRAGMA foreign_keys = ON") await db.execute("PRAGMA journal_mode = WAL") - for stmt in DDL: - await db.execute(stmt) - # Additive column migrations: SQLite doesn't support - # `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, so we guard with PRAGMA. - # Each new column added to a CREATE TABLE above must also be guarded - # here, otherwise old DBs upgraded in place keep the pre-add schema - # and subsequent INSERTs fail with "no column named ...". + # `executescript` runs the concatenated DDL through SQLite's own + # parser, which strips `--` line comments and `/* */` block + # comments before splitting on `;`. A Python-side `split(';')` + # tripped on the first `--` comment whose body contained `;`. + await db.executescript(load_schema_script()) + # Additive column migrations — see module docstring for the + # reason these live in Python. async with db.execute("PRAGMA table_info(games)") as cur: cols = {row[1] async for row in cur} if "force_skip_pending" not in cols: @@ -348,3 +123,6 @@ async def migrate(db_path: str | Path) -> None: "ADD COLUMN public_state_snapshot_json TEXT" ) await db.commit() + + +__all__ = ["migrate"] diff --git a/src/wolfbot/persistence/sql/__init__.py b/src/wolfbot/persistence/sql/__init__.py new file mode 100644 index 0000000..f370a14 --- /dev/null +++ b/src/wolfbot/persistence/sql/__init__.py @@ -0,0 +1,52 @@ +"""Filesystem-resident SQL DDL for the SQLite store. + +The schema directory holds one ``NN_.sql`` file per logical +table group. The numeric prefix encodes execution order (FK-bearing +tables come after the targets they reference). :func:`load_schema_ddl` +returns each statement as a separate string so the migrate runner can +execute them one at a time and keep error messages scoped. + +ALTER-TABLE migrations live in Python (:func:`wolfbot.persistence.schema.migrate`) +because SQLite has no ``ADD COLUMN IF NOT EXISTS`` and the conditional +guards need ``PRAGMA table_info`` reads. The base CREATE statements, +which are inherently idempotent via ``CREATE TABLE IF NOT EXISTS``, +live here as plain ``.sql`` so they can be edited / reviewed without a +Python diff. +""" + +from __future__ import annotations + +from functools import cache +from pathlib import Path + +SQL_ROOT: Path = Path(__file__).resolve().parent +"""Filesystem directory containing schema/ and any future migrations/.""" + +SCHEMA_DIR: Path = SQL_ROOT / "schema" + + +@cache +def load_schema_script() -> str: + """Concatenate every ``schema/*.sql`` in lex order into one script. + + Returned as a single string suitable for ``aiosqlite``'s + ``executescript`` — that path goes through SQLite's own statement + splitter, which correctly skips ``--`` line comments (containing + semicolons or otherwise) and ``/* ... */`` block comments. A naive + Python ``split(';')`` failed on the very first comment line that + ended in ``;`` (game ``01_games.sql`` had "...; service code also" + in a `--` line). + + Cached because ``schema.migrate`` runs every boot and the .sql + files are immutable per process. Tests that need a fresh read can + call :func:`load_schema_script.cache_clear`. + """ + chunks: list[str] = [] + for path in sorted(SCHEMA_DIR.glob("*.sql")): + chunks.append(path.read_text(encoding="utf-8")) + # Each file already ends in a newline; join with a blank line so the + # rendered script stays human-readable when dumped for debugging. + return "\n\n".join(chunks) + + +__all__ = ["SCHEMA_DIR", "SQL_ROOT", "load_schema_script"] diff --git a/src/wolfbot/persistence/sql/schema/01_games.sql b/src/wolfbot/persistence/sql/schema/01_games.sql new file mode 100644 index 0000000..bc612f5 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/01_games.sql @@ -0,0 +1,28 @@ +-- Games table — one row per Werewolf session, lifecycle-tracked by ended_at. +-- All FK-bearing tables below cascade on this row's id. +CREATE TABLE IF NOT EXISTS games ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + host_user_id TEXT NOT NULL, + phase TEXT NOT NULL, + day_number INTEGER NOT NULL DEFAULT 0, + deadline_epoch INTEGER, + main_text_channel_id TEXT NOT NULL, + main_vc_channel_id TEXT NOT NULL, + heaven_channel_id TEXT, + wolves_channel_id TEXT, + created_at INTEGER NOT NULL, + ended_at INTEGER, + force_skip_pending INTEGER NOT NULL DEFAULT 0, + discussion_mode TEXT NOT NULL DEFAULT 'rounds' +); + +-- Recovery sweep: list every active game (= ended_at IS NULL) cheaply on boot. +CREATE INDEX IF NOT EXISTS idx_games_active + ON games(ended_at) WHERE ended_at IS NULL; + +-- Per-guild active singleton: at most one in-flight game per guild at a time. +-- The partial unique index is the structural enforcement; service code also +-- checks this at /wolf start, but the DB is the source of truth. +CREATE UNIQUE INDEX IF NOT EXISTS idx_games_unique_active + ON games(guild_id) WHERE ended_at IS NULL; diff --git a/src/wolfbot/persistence/sql/schema/02_seats.sql b/src/wolfbot/persistence/sql/schema/02_seats.sql new file mode 100644 index 0000000..14f292c --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/02_seats.sql @@ -0,0 +1,21 @@ +-- Seats — fixed 9 per game, role assigned in NIGHT_0 plan. +-- discord_user_id is NULL for LLM seats (persona_key carries the +-- character identity instead). +CREATE TABLE IF NOT EXISTS seats ( + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + seat_no INTEGER NOT NULL, + discord_user_id TEXT, + display_name TEXT NOT NULL, + is_llm INTEGER NOT NULL, + persona_key TEXT, + role TEXT, + alive INTEGER NOT NULL DEFAULT 1, + death_cause TEXT, + death_day INTEGER, + dm_channel_id TEXT, + PRIMARY KEY (game_id, seat_no) +); + +-- "Which game is user X currently in?" lookup — used when a DM submission +-- needs to find the active game for the player without scanning all games. +CREATE INDEX IF NOT EXISTS idx_seats_user ON seats(discord_user_id); diff --git a/src/wolfbot/persistence/sql/schema/03_night_actions.sql b/src/wolfbot/persistence/sql/schema/03_night_actions.sql new file mode 100644 index 0000000..cb0a8d0 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/03_night_actions.sql @@ -0,0 +1,13 @@ +-- Per-night submissions from role-holders: wolf attack / seer divine / +-- knight guard. Composite PK enforces "one submission per actor per kind +-- per day" — re-submissions overwrite via INSERT OR REPLACE in service code. +CREATE TABLE IF NOT EXISTS night_actions ( + game_id TEXT NOT NULL, + day INTEGER NOT NULL, + actor_seat INTEGER NOT NULL, + kind TEXT NOT NULL, + target_seat INTEGER, + submitted_at INTEGER NOT NULL, + PRIMARY KEY (game_id, day, actor_seat, kind), + FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE +); diff --git a/src/wolfbot/persistence/sql/schema/04_votes.sql b/src/wolfbot/persistence/sql/schema/04_votes.sql new file mode 100644 index 0000000..c08f989 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/04_votes.sql @@ -0,0 +1,13 @@ +-- Day votes (round=0) and runoff votes (round=1+). Composite PK enforces +-- "one ballot per voter per round per day"; null target_seat encodes +-- a legitimate abstain. +CREATE TABLE IF NOT EXISTS votes ( + game_id TEXT NOT NULL, + day INTEGER NOT NULL, + round INTEGER NOT NULL, + voter_seat INTEGER NOT NULL, + target_seat INTEGER, + submitted_at INTEGER NOT NULL, + PRIMARY KEY (game_id, day, round, voter_seat), + FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE +); diff --git a/src/wolfbot/persistence/sql/schema/05_previous_guard.sql b/src/wolfbot/persistence/sql/schema/05_previous_guard.sql new file mode 100644 index 0000000..8d819b9 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/05_previous_guard.sql @@ -0,0 +1,9 @@ +-- Knight's previous-night guard target — used by `legal_guard_targets` +-- to enforce "no consecutive guard on the same target". +-- Singleton-per-game (PK on game_id) since 9-player has only one knight. +CREATE TABLE IF NOT EXISTS previous_guard ( + game_id TEXT PRIMARY KEY REFERENCES games(id) ON DELETE CASCADE, + knight_seat INTEGER NOT NULL, + last_guard_seat INTEGER, + last_guard_day INTEGER +); diff --git a/src/wolfbot/persistence/sql/schema/06_logs_public.sql b/src/wolfbot/persistence/sql/schema/06_logs_public.sql new file mode 100644 index 0000000..220bfb6 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/06_logs_public.sql @@ -0,0 +1,15 @@ +-- Public-channel-visible log entries — phase changes, vote results, +-- executions, victories, role reveals. AUTOINCREMENT so created_at +-- ties don't collapse rows during recovery sweeps. +CREATE TABLE IF NOT EXISTS logs_public ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game_id TEXT NOT NULL, + day INTEGER NOT NULL, + phase TEXT NOT NULL, + kind TEXT NOT NULL, + actor_seat INTEGER, + text TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_pub_logs_game ON logs_public(game_id, created_at); diff --git a/src/wolfbot/persistence/sql/schema/07_logs_private.sql b/src/wolfbot/persistence/sql/schema/07_logs_private.sql new file mode 100644 index 0000000..5454dab --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/07_logs_private.sql @@ -0,0 +1,17 @@ +-- Per-seat private log entries — role notices, seer/medium/guard results, +-- wolf-partner reveals. Audience_seat is the recipient; payload_json +-- carries structured details for the export pipeline. +CREATE TABLE IF NOT EXISTS logs_private ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game_id TEXT NOT NULL, + day INTEGER NOT NULL, + phase TEXT NOT NULL, + kind TEXT NOT NULL, + actor_seat INTEGER, + audience_seat INTEGER, + text TEXT NOT NULL, + payload_json TEXT, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_priv_logs_game ON logs_private(game_id, created_at); diff --git a/src/wolfbot/persistence/sql/schema/08_pending_decisions.sql b/src/wolfbot/persistence/sql/schema/08_pending_decisions.sql new file mode 100644 index 0000000..a933edf --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/08_pending_decisions.sql @@ -0,0 +1,11 @@ +-- Singleton-per-game record of a paused phase that needs the host to +-- /wolf force-skip or /wolf extend. submissions_json is added by the +-- migration block and carries the per-kind PendingSubmission breakdown. +CREATE TABLE IF NOT EXISTS pending_decisions ( + game_id TEXT PRIMARY KEY REFERENCES games(id) ON DELETE CASCADE, + phase TEXT NOT NULL, + day INTEGER NOT NULL, + required_submission TEXT NOT NULL, + missing_seats_json TEXT NOT NULL, + created_at INTEGER NOT NULL +); diff --git a/src/wolfbot/persistence/sql/schema/09_persona_assignments.sql b/src/wolfbot/persistence/sql/schema/09_persona_assignments.sql new file mode 100644 index 0000000..1e9b109 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/09_persona_assignments.sql @@ -0,0 +1,10 @@ +-- Locks each LLM seat to one persona for the duration of a game. +-- UNIQUE on (game_id, persona_key) prevents two seats from sharing a +-- persona; PK on (game_id, seat_no) prevents a seat from holding two. +CREATE TABLE IF NOT EXISTS persona_assignments ( + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + seat_no INTEGER NOT NULL, + persona_key TEXT NOT NULL, + PRIMARY KEY (game_id, seat_no), + UNIQUE (game_id, persona_key) +); diff --git a/src/wolfbot/persistence/sql/schema/10_llm_speech_counts.sql b/src/wolfbot/persistence/sql/schema/10_llm_speech_counts.sql new file mode 100644 index 0000000..305a900 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/10_llm_speech_counts.sql @@ -0,0 +1,15 @@ +-- Per-LLM-seat speech progress per day so /wolf force-skip / restart +-- mid-flight resumes without double-posting. discussion_rounds_done +-- and runoff_speech_done flip from 0 to 1 once the corresponding +-- speech batch settles for that seat. +CREATE TABLE IF NOT EXISTS llm_speech_counts ( + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + day INTEGER NOT NULL, + seat_no INTEGER NOT NULL, + normal_count INTEGER NOT NULL DEFAULT 0, + vote_intent_done INTEGER NOT NULL DEFAULT 0, + last_spoke_epoch INTEGER, + discussion_rounds_done INTEGER NOT NULL DEFAULT 0, + runoff_speech_done INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (game_id, day, seat_no) +); diff --git a/src/wolfbot/persistence/sql/schema/11_speech_events.sql b/src/wolfbot/persistence/sql/schema/11_speech_events.sql new file mode 100644 index 0000000..1988c0d --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/11_speech_events.sql @@ -0,0 +1,40 @@ +-- Reactive_voice's append-only feed of every public speech event: +-- text input, voice STT, NPC-generated, plus the phase_baseline +-- sentinel. Structured analyzer fields (summary / co_declaration / +-- addressed_seat_no(_s) / role_callout / claimed_seer/medium_*) +-- carry the per-event JSON the analyzer LLM extracted, so the +-- claim_history fold doesn't need to re-parse the raw text. +CREATE TABLE IF NOT EXISTS speech_events ( + event_id TEXT PRIMARY KEY, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + phase_id TEXT NOT NULL, + day INTEGER NOT NULL, + phase TEXT NOT NULL, + source TEXT NOT NULL, + speaker_kind TEXT NOT NULL, + speaker_seat INTEGER, + text TEXT NOT NULL, + stt_confidence REAL, + audio_start_ms INTEGER, + audio_end_ms INTEGER, + alive_seat_nos_json TEXT, + summary TEXT, + co_declaration TEXT, + addressed_seat_no INTEGER, + addressed_seat_nos_json TEXT, + role_callout TEXT, + claimed_seer_target_seat INTEGER, + claimed_seer_is_wolf INTEGER, + claimed_medium_target_seat INTEGER, + claimed_medium_is_wolf INTEGER, + created_at_ms INTEGER NOT NULL +); + +-- Phase rebuild: walk every event in a phase in chronological order. +CREATE INDEX IF NOT EXISTS idx_speech_events_phase + ON speech_events(game_id, phase_id, created_at_ms); + +-- Per-seat slice within a phase: speech_count rotation, last-speaker +-- LRU, claim history per claimer. +CREATE INDEX IF NOT EXISTS idx_speech_events_seat + ON speech_events(game_id, phase_id, speaker_seat); diff --git a/src/wolfbot/persistence/sql/schema/12_npc_speak_requests.sql b/src/wolfbot/persistence/sql/schema/12_npc_speak_requests.sql new file mode 100644 index 0000000..00b51f5 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/12_npc_speak_requests.sql @@ -0,0 +1,24 @@ +-- One row per SpeakRequest dispatched by the arbiter to an NPC bot. +-- public_state_snapshot_json captures the picker's view of state at +-- dispatch time so the viewer can replay why this seat won. +CREATE TABLE IF NOT EXISTS npc_speak_requests ( + request_id TEXT PRIMARY KEY, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + phase_id TEXT NOT NULL, + npc_id TEXT NOT NULL, + seat_no INTEGER NOT NULL, + logic_packet_id TEXT NOT NULL, + suggested_intent TEXT NOT NULL, + max_chars INTEGER NOT NULL, + max_duration_ms INTEGER NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + expires_at_ms INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + selection_reason TEXT, + public_state_snapshot_json TEXT +); + +-- TTL sweep: list dispatches whose expires_at_ms has elapsed without +-- a SpeakResult, scoped to the phase to keep the index small. +CREATE INDEX IF NOT EXISTS idx_npc_speak_requests_game_phase + ON npc_speak_requests(game_id, phase_id, expires_at_ms); diff --git a/src/wolfbot/persistence/sql/schema/13_npc_speak_results.sql b/src/wolfbot/persistence/sql/schema/13_npc_speak_results.sql new file mode 100644 index 0000000..fdff5ef --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/13_npc_speak_results.sql @@ -0,0 +1,19 @@ +-- One row per SpeakResult received from an NPC bot (or synthesized by +-- the arbiter on rejection). status carries accepted / rejected / +-- timed_out; failure_reason gives the canonical machine reason. +CREATE TABLE IF NOT EXISTS npc_speak_results ( + request_id TEXT PRIMARY KEY, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + phase_id TEXT NOT NULL, + npc_id TEXT NOT NULL, + status TEXT NOT NULL, + text TEXT, + used_logic_ids_json TEXT, + intent TEXT, + estimated_duration_ms INTEGER, + failure_reason TEXT, + received_at_ms INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_npc_speak_results_phase + ON npc_speak_results(game_id, phase_id, received_at_ms); diff --git a/src/wolfbot/persistence/sql/schema/14_npc_playback_events.sql b/src/wolfbot/persistence/sql/schema/14_npc_playback_events.sql new file mode 100644 index 0000000..6df69be --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/14_npc_playback_events.sql @@ -0,0 +1,24 @@ +-- One row per NPC playback window — opened on SpeakResult acceptance +-- with an authorized_at_ms / playback_deadline_ms pair, closed when +-- tts_finished + playback_finished arrive (or the deadline expires). +-- The partial index keeps "still-open windows" cheap to scan during +-- the periodic deadline sweep. +CREATE TABLE IF NOT EXISTS npc_playback_events ( + request_id TEXT PRIMARY KEY, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + phase_id TEXT NOT NULL, + npc_id TEXT NOT NULL, + speech_event_id TEXT, + authorized_at_ms INTEGER NOT NULL, + playback_deadline_ms INTEGER NOT NULL, + finished_at_ms INTEGER, + outcome TEXT, + failure_reason TEXT, + tts_outcome TEXT, + tts_duration_ms INTEGER, + tts_failure_reason TEXT +); + +CREATE INDEX IF NOT EXISTS idx_npc_playback_events_open + ON npc_playback_events(game_id, finished_at_ms) + WHERE finished_at_ms IS NULL; diff --git a/tests/test_sql_loader.py b/tests/test_sql_loader.py new file mode 100644 index 0000000..f26fe15 --- /dev/null +++ b/tests/test_sql_loader.py @@ -0,0 +1,144 @@ +"""Loader tests for ``wolfbot.persistence.sql``. + +Pin two invariants the runtime depends on: + +1. ``load_schema_script`` returns a concatenated string of every + ``schema/*.sql`` file in lex order. The lex order matters because + FK-bearing tables reference targets that must already exist — + numeric prefixes (``01_games.sql``) encode the dependency order. +2. The script tolerates ``--`` line comments whose body itself contains + a ``;``. This was the regression that surfaced when the loader + first split on ``;`` in Python: the string "...enforcement; service + code also" inside a comment caused SQLite to see ``service code + also CREATE INDEX...`` as one mangled statement. Running the script + through ``executescript`` (= SQLite's own parser) is what makes + the comment safe; this test pins that contract. +""" + +from __future__ import annotations + +from pathlib import Path + +import aiosqlite +import pytest + +from wolfbot.persistence.sql import ( + SCHEMA_DIR, + SQL_ROOT, + load_schema_script, +) + + +def test_schema_dir_layout_has_numbered_sql_files() -> None: + """Every file in schema/ is a ``NN_*.sql`` so the lex order matches + the FK dependency order documented in the loader.""" + files = sorted(SCHEMA_DIR.glob("*.sql")) + assert files, "schema/ directory must contain at least one .sql file" + for path in files: + # The leading two digits encode dependency order. A glob like + # `*.sql` would happily ingest a file with no prefix; this test + # catches that drift before it breaks the migrate run. + assert path.stem[:2].isdigit(), ( + f"{path.name} must start with NN_ prefix to define order" + ) + + +def test_schema_dir_lives_under_sql_root() -> None: + assert SCHEMA_DIR.parent == SQL_ROOT + assert SCHEMA_DIR.is_dir() + + +def test_load_schema_script_returns_non_empty_text() -> None: + script = load_schema_script() + assert "CREATE TABLE IF NOT EXISTS games" in script + # Every CREATE TABLE statement should end in `;` so executescript + # can split them — verifies file authors didn't drop a trailing `;`. + assert script.count("CREATE TABLE IF NOT EXISTS") >= 10 + + +def test_load_schema_script_concatenates_in_lex_order() -> None: + """First file in lex order is ``01_games.sql``; its content must + appear before ``02_seats.sql`` in the rendered script. This pins + the FK execution order — `seats` references `games(id)` and would + fail if the rendered order ever flipped.""" + script = load_schema_script() + games_idx = script.find("CREATE TABLE IF NOT EXISTS games") + seats_idx = script.find("CREATE TABLE IF NOT EXISTS seats") + assert games_idx >= 0 and seats_idx >= 0 + assert games_idx < seats_idx, ( + "games must appear before seats in the rendered script — " + "seats has a FK on games(id)" + ) + + +def test_load_schema_script_is_cached() -> None: + """Second call returns the same string instance (LRU cache). + + Probes by mutating a file after the first read — the cached value + must NOT pick up the change. Tests that need fresh reads call + `load_schema_script.cache_clear` (matches the template loader's + convention).""" + first = load_schema_script() + # We don't actually mutate the .sql file; we just assert identity + # equality across calls, which the lru_cache guarantees if and only + # if the function is decorated. + second = load_schema_script() + assert first is second + + +async def test_loaded_script_runs_against_sqlite_with_executescript( + tmp_path: Path, +) -> None: + """End-to-end: ``executescript(load_schema_script())`` on a fresh + DB must succeed and produce every documented table. This is the + canonical regression for the `--`-comment-containing-`;` bug — if + the loader ever switches back to a Python-side split on `;`, + this test will fail with `OperationalError: near "service": syntax error` + (the exact error the original bug surfaced). + """ + db_path = tmp_path / "schema_smoke.db" + async with aiosqlite.connect(str(db_path)) as db: + await db.executescript(load_schema_script()) + async with db.execute( + "SELECT name FROM sqlite_master " + "WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ) as cur: + tables = [row[0] async for row in cur] + + expected_subset = { + "games", + "seats", + "night_actions", + "votes", + "previous_guard", + "logs_public", + "logs_private", + "pending_decisions", + "persona_assignments", + "llm_speech_counts", + "speech_events", + "npc_speak_requests", + "npc_speak_results", + "npc_playback_events", + } + missing = expected_subset - set(tables) + assert not missing, f"schema/*.sql must define all core tables; missing={missing}" + + +def test_load_schema_script_preserves_comment_with_semicolon_intact() -> None: + """Regression: 01_games.sql contains a `--` comment line whose body + has a `;` ("...enforcement; service code also"). The loader must + NOT strip / modify the comment; SQLite's own parser handles it + when ``executescript`` runs the script. + """ + script = load_schema_script() + assert "structural enforcement; service code also" in script + + +@pytest.fixture(autouse=True) +def _reset_cache() -> None: + """Independence between tests in this module: clear the cache before + each test so an earlier mutation experiment doesn't leak. (The + cache is shared with the production runtime, but tests that touch + SCHEMA_DIR contents would otherwise see stale data.)""" + load_schema_script.cache_clear() From af9eea97756a8d11d6489f3d3056f4c506c603be Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 13:35:07 +0900 Subject: [PATCH 106/133] feat(viewer): resolve game files by game.id with name-resolution fallback 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. --- viewer/src/components/GamesTable.tsx | 95 +++++++++++++++++++--------- viewer/src/lib/data.ts | 39 +++++++++++- 2 files changed, 102 insertions(+), 32 deletions(-) diff --git a/viewer/src/components/GamesTable.tsx b/viewer/src/components/GamesTable.tsx index e9f0baf..0a73dda 100644 --- a/viewer/src/components/GamesTable.tsx +++ b/viewer/src/components/GamesTable.tsx @@ -1,10 +1,12 @@ import Link from "next/link"; +import type { ReactNode } from "react"; import Box from "@mui/material/Box"; import Chip from "@mui/material/Chip"; import Paper from "@mui/material/Paper"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; +import type { TableCellProps } from "@mui/material/TableCell"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import Typography from "@mui/material/Typography"; @@ -50,50 +52,85 @@ export default function GamesTable({ games }: { games: GameSummary[] }) { /** * One row of the games-list table. * - * Wraps the entire row in a Next ```` styled to fill the row so the - * whole strip is clickable AND retains link semantics — right-click ▶ - * "新しいタブで開く" works, screen readers see it as a link, no JS needed. - * - * Implementation: render ``Link`` as the ```` via MUI's ``component`` - * prop. ``LinkProps`` carries ``href``; the ``component`` cast satisfies - * MUI's generic without an ``as any``. + * Each cell wraps its content in a Next ```` rendered as a block-level + * element so the entire cell area is clickable, while the ````/```` and ``
`` + * structure remains valid HTML. Wrapping the whole ```` in an + * ```` would put the anchor between ``
`` — the parser + * foster-parents it out and the cells collapse into one column. */ function GameRow({ game }: { game: GameSummary }) { + const href = `/games/${game.id}`; return ( - td": { borderBottom: "1px solid", borderColor: "divider" }, - }} - > - {game.id} - + td": { p: 0 } }}> + + {game.id} + + - - + + - - + + {formatJstDate(game.created_at_ms)} - - {formatDuration(game.duration_ms)} - {game.seat_count} - {game.llm_call_count} - {formatTokens(game.total_tokens)} - {formatLatency(game.total_latency_ms)} + + + {formatDuration(game.duration_ms)} + + + {game.seat_count} + + + {game.llm_call_count} + + + {formatTokens(game.total_tokens)} + + + {formatLatency(game.total_latency_ms)} + ); } +function LinkCell({ + href, + children, + align, + sx, +}: { + href: string; + children: ReactNode; + align?: TableCellProps["align"]; + sx?: TableCellProps["sx"]; +}) { + return ( + + + {children} + + + ); +} + function VictoryChip({ victory }: { victory: GameSummary["victory"] }) { if (victory === "village") { return ; diff --git a/viewer/src/lib/data.ts b/viewer/src/lib/data.ts index 0a2bb26..cdb2a26 100644 --- a/viewer/src/lib/data.ts +++ b/viewer/src/lib/data.ts @@ -60,20 +60,53 @@ export async function listGames(): Promise { .sort((a, b) => b.file_mtime_ms - a.file_mtime_ms); } -/** Load one exported game by id, or ``null`` if the file is missing. */ +/** + * Load one exported game by id, or ``null`` if the file is missing. + * + * The bot writes files as ``YYYY-MM-DD_HH-MM-SS.json`` (sortable by play + * time, see ``services/game_export.py``) — the file name does **not** + * encode the internal ``game.id``. To resolve a link, fast-path on + * ``{gameId}.json`` (legacy / forward-compat) and otherwise scan the + * directory for a JSON whose ``game.id`` matches. + */ export async function loadGameById( gameId: string, ): Promise { // Defensive — never resolve outside the games dir even if a path-traversal // id slips in. if (gameId.includes("/") || gameId.includes("..")) return null; - const target = path.resolve(process.cwd(), GAMES_DIR, `${gameId}.json`); + const exportsDir = path.resolve(process.cwd(), GAMES_DIR); + + // Fast path — legacy ``{id}.json`` layout. + const legacy = path.join(exportsDir, `${gameId}.json`); try { - const raw = await readFile(target, "utf-8"); + const raw = await readFile(legacy, "utf-8"); return JSON.parse(raw) as GameSample; + } catch { + // fall through to directory scan + } + + // Directory scan — match ``game.id`` inside each JSON. + let names: string[]; + try { + names = await readdir(exportsDir); } catch { return null; } + const candidates = names.filter((n) => n.endsWith(".json")); + for (const name of candidates) { + const fullPath = path.join(exportsDir, name); + try { + const raw = await readFile(fullPath, "utf-8"); + const data = JSON.parse(raw) as GameSample; + if (data.game.id === gameId) { + return data; + } + } catch { + // skip unreadable / malformed files + } + } + return null; } /** Load the bundled sample game (opt-in via the ``/sample`` route). */ From 89c625ef953fc61f8c8f16ef00240079be54c735 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 13:35:23 +0900 Subject: [PATCH 107/133] fix(claim_history): dedupe same-day CO re-assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/master/claim/claim_history.py | 52 ++++++--- tests/test_claim_history.py | 119 ++++++++++++++++++++ viewer/src/components/ClaimHistoryPanel.tsx | 65 +++++++++-- 3 files changed, 208 insertions(+), 28 deletions(-) diff --git a/src/wolfbot/master/claim/claim_history.py b/src/wolfbot/master/claim/claim_history.py index 58cd043..748db54 100644 --- a/src/wolfbot/master/claim/claim_history.py +++ b/src/wolfbot/master/claim/claim_history.py @@ -111,6 +111,16 @@ def collect_claim_history( announces both kinds at once. * Preserves chronological order via the input sequence's order (callers MUST pass events sorted by ``created_at_ms`` ASC). + * Folds repeat re-assertions into a single ledger row keyed on + ``(day, target_seat, is_wolf)``. LLM seats routinely restate + "yesterday I divined X = white" across multiple speech turns + in the same day's discussion rounds, and surfacing each + utterance as a separate ledger entry double-counts the same + claim — viewer panels show duplicate rows and the seer/medium + header chip overshoots the expected per-day cap. A second + utterance with a *different* target or color on the same day + is kept (the validator catches it as a swap/flip; the ledger + preserves the inconsistency for post-game review). """ name_lookup = dict(seat_names or {}) @@ -119,6 +129,8 @@ def _name(seat: int) -> str: seer_by_seat: dict[int, list[ClaimedSeerEntry]] = {} medium_by_seat: dict[int, list[ClaimedMediumEntry]] = {} + seer_seen: dict[int, set[tuple[int, int, bool]]] = {} + medium_seen: dict[int, set[tuple[int, int, bool | None]]] = {} for event in events: if event.source == SpeechSource.PHASE_BASELINE: @@ -130,27 +142,35 @@ def _name(seat: int) -> str: seer_target = event.claimed_seer_target_seat seer_verdict = event.claimed_seer_is_wolf if seer_target is not None and seer_verdict is not None: - seer_by_seat.setdefault(speaker, []).append( - ClaimedSeerEntry( - day=event.day, - target_seat=seer_target, - target_name=_name(seer_target), - is_wolf=seer_verdict, - declared_at_event_id=event.event_id, + seer_key = (event.day, seer_target, seer_verdict) + speaker_seen = seer_seen.setdefault(speaker, set()) + if seer_key not in speaker_seen: + speaker_seen.add(seer_key) + seer_by_seat.setdefault(speaker, []).append( + ClaimedSeerEntry( + day=event.day, + target_seat=seer_target, + target_name=_name(seer_target), + is_wolf=seer_verdict, + declared_at_event_id=event.event_id, + ) ) - ) medium_target = event.claimed_medium_target_seat if medium_target is not None: - medium_by_seat.setdefault(speaker, []).append( - ClaimedMediumEntry( - day=event.day, - target_seat=medium_target, - target_name=_name(medium_target), - is_wolf=event.claimed_medium_is_wolf, - declared_at_event_id=event.event_id, + medium_key = (event.day, medium_target, event.claimed_medium_is_wolf) + speaker_seen_m = medium_seen.setdefault(speaker, set()) + if medium_key not in speaker_seen_m: + speaker_seen_m.add(medium_key) + medium_by_seat.setdefault(speaker, []).append( + ClaimedMediumEntry( + day=event.day, + target_seat=medium_target, + target_name=_name(medium_target), + is_wolf=event.claimed_medium_is_wolf, + declared_at_event_id=event.event_id, + ) ) - ) seats = sorted(set(seer_by_seat) | set(medium_by_seat)) by_seat = { diff --git a/tests/test_claim_history.py b/tests/test_claim_history.py index e0b3786..178c835 100644 --- a/tests/test_claim_history.py +++ b/tests/test_claim_history.py @@ -148,6 +148,125 @@ def test_collect_claim_history_handles_medium_void_result() -> None: ) +def test_collect_claim_history_dedupes_same_day_reassertions() -> None: + """Same (day, target, verdict) restated across multiple speech turns + in one day's discussion rounds folds to a single ledger row. + + Regression: game ``2026-05-02_09-34-19`` exported two ledger rows + for the medium claimer's "day 2 シゲミチ白" (round 1 + round 2 of + the same morning) and four rows for Yuriko's seer claim (with + "day 3 セツ白" repeated twice). The viewer's CO-history panel + rendered both as visible duplicates, and the seer-cap header + chip ("通算 X / 期待 Y") overshot. A real seer never issues two + cumulative entries for the same morning's NIGHT_K result, so the + aggregator must collapse identical re-assertions. + """ + events = [ + _speech_event( + seat=9, + day=3, + seer_target=5, + seer_is_wolf=False, + event_id="ev_first", + created_at_ms=10, + ), + # Same speaker, same day, same target+verdict — re-asserted + # across discussion rounds. Must be folded. + _speech_event( + seat=9, + day=3, + seer_target=5, + seer_is_wolf=False, + event_id="ev_second", + created_at_ms=20, + ), + # Medium claim on the same day, repeated. + _speech_event( + seat=8, + day=2, + medium_target=6, + medium_is_wolf=False, + event_id="ev_med1", + created_at_ms=5, + ), + _speech_event( + seat=8, + day=2, + medium_target=6, + medium_is_wolf=False, + event_id="ev_med2", + created_at_ms=15, + ), + ] + + history = collect_claim_history( + events, + seat_names={5: "Setsu", 6: "Shigemichi"}, + ) + + # Seer dedup: only the first event_id survives, ledger has 1 row. + assert history.by_seat[9].seer_claims == ( + ClaimedSeerEntry( + day=3, + target_seat=5, + target_name="Setsu", + is_wolf=False, + declared_at_event_id="ev_first", + ), + ) + # Medium dedup: identical re-assert collapses too. + assert history.by_seat[8].medium_claims == ( + ClaimedMediumEntry( + day=2, + target_seat=6, + target_name="Shigemichi", + is_wolf=False, + declared_at_event_id="ev_med1", + ), + ) + + +def test_collect_claim_history_keeps_same_day_target_swap() -> None: + """A different target or color on the same day is *not* a duplicate + — it's an inconsistency the validator catches as + ``seer_target_swap`` / ``seer_verdict_flip``. The ledger must + preserve both rows so post-game review surfaces the contradiction + even when the validator was bypassed (e.g. legacy export, or a + structured claim slipping past the retry loop).""" + events = [ + _speech_event( + seat=9, + day=2, + seer_target=5, + seer_is_wolf=False, + event_id="ev_a", + created_at_ms=10, + ), + # Different target on the same day → kept as a separate row. + _speech_event( + seat=9, + day=2, + seer_target=4, + seer_is_wolf=False, + event_id="ev_b", + created_at_ms=20, + ), + # Same target, flipped verdict → also kept. + _speech_event( + seat=9, + day=2, + seer_target=5, + seer_is_wolf=True, + event_id="ev_c", + created_at_ms=30, + ), + ] + + history = collect_claim_history(events, seat_names={4: "Comet", 5: "Setsu"}) + + assert len(history.by_seat[9].seer_claims) == 3 + + def test_collect_claim_history_drops_partial_seer_claims() -> None: """A seer claim missing ``is_wolf`` is meaningless and the aggregator drops it rather than guessing a verdict.""" diff --git a/viewer/src/components/ClaimHistoryPanel.tsx b/viewer/src/components/ClaimHistoryPanel.tsx index 8021241..933e5e8 100644 --- a/viewer/src/components/ClaimHistoryPanel.tsx +++ b/viewer/src/components/ClaimHistoryPanel.tsx @@ -15,9 +15,11 @@ * the table noticing. This panel stacks every claim against day + * expected count so the discrepancy reads at a glance: * - * - Day-N integrity rule (seer): a real seer at day N should have - * N + 1 results (NIGHT_0 random white + one per night). The - * header chip shows "通算 X / 期待 Y" and turns red when X ≠ Y. + * - Day-N integrity rule (seer): a real seer at day N's morning + * should have exactly N cumulative results (NIGHT_0's random + * white declared on day 1 + one per subsequent night, each + * surfaced the morning after). The header chip shows + * "通算 X / 期待 Y" and turns red when X ≠ Y. * - Per-claim row: day, target, verdict (黒/白). The wolf badge * contrast against the claimer's actual role makes fake CO obvious * post-game. @@ -42,6 +44,41 @@ import type { Seat, } from "@/lib/types"; +// Older exports (pre-2026-05-02) kept one ledger row per speech +// utterance, so an LLM seat that restated the same divination / +// medium result across multiple discussion rounds appears as +// repeated rows. The Python collector now dedupes at export time; +// this guard re-runs the same fold in-browser so legacy JSON files +// also render cleanly. +function dedupeSeerClaims( + claims: readonly ClaimedSeerHistoryEntry[], +): ClaimedSeerHistoryEntry[] { + const seen = new Set(); + const out: ClaimedSeerHistoryEntry[] = []; + for (const c of claims) { + const key = `${c.day}|${c.target_seat}|${c.is_wolf ? 1 : 0}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(c); + } + return out; +} + +function dedupeMediumClaims( + claims: readonly ClaimedMediumHistoryEntry[], +): ClaimedMediumHistoryEntry[] { + const seen = new Set(); + const out: ClaimedMediumHistoryEntry[] = []; + for (const c of claims) { + const verdict = c.is_wolf === null ? "n" : c.is_wolf ? "1" : "0"; + const key = `${c.day}|${c.target_seat}|${verdict}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(c); + } + return out; +} + const CO_LABEL: Record = { seer: "占い", medium: "霊媒", @@ -103,8 +140,10 @@ function ClaimerRow({ // not match their seat role is rendering the panel's value most. // The role chip on the header carries that contrast so post-game // readers see "Yuriko (狼) — 占い 通算 1 件" without scanning seats. - const seerCount = entry.seer_claims.length; - const mediumCount = entry.medium_claims.length; + const seerClaims = dedupeSeerClaims(entry.seer_claims); + const mediumClaims = dedupeMediumClaims(entry.medium_claims); + const seerCount = seerClaims.length; + const mediumCount = mediumClaims.length; const expectedSeer = expectedSeerCountForDay(currentDay); // Medium count == executions seen so far; we don't have a clean way // to count executions per game in the viewer, so we render the raw @@ -145,7 +184,7 @@ function ClaimerRow({ )} - {entry.seer_claims.map((c, idx) => ( + {seerClaims.map((c, idx) => ( ))} - {entry.medium_claims.map((c, idx) => ( + {mediumClaims.map((c, idx) => ( Date: Sat, 2 May 2026 13:35:37 +0900 Subject: [PATCH 108/133] fix(state_machine): tag MORNING + day-start logs with next day_number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/domain/state_machine.py | 19 ++++++++++-- tests/test_state_machine_nights.py | 45 +++++++++++++++++++++++++++++ tests/test_state_machine_phases.py | 12 ++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/wolfbot/domain/state_machine.py b/src/wolfbot/domain/state_machine.py index 232fb1a..fa1b7d5 100644 --- a/src/wolfbot/domain/state_machine.py +++ b/src/wolfbot/domain/state_machine.py @@ -111,13 +111,22 @@ def _public_log( now_epoch: int, phase: Phase | None = None, actor_seat: int | None = None, + day: int | None = None, ) -> LogEntry: # `actor_seat` is forwarded so EXECUTION (target) and MORNING (victim) # carry the affected seat. Master Levi narration reads this field to # voice the actual seat label instead of "対象不明". + # + # ``day`` overrides the default ``game.day_number`` for transitions + # that cross a day boundary in the same Transition (e.g. the NIGHT + # resolver emits the next day's MORNING + "N 日目の議論を開始" + # PHASE_CHANGE before bumping day_number). Without the override + # those logs land in the *prior* day's bucket, and the viewer's + # per-(day, phase) timeline reads "DAY_VOTE → NIGHT → DAY_DISCUSSION" + # — the discussion section ends up dated AFTER its own speeches. return LogEntry( game_id=game.id, - day=game.day_number, + day=game.day_number if day is None else day, phase=phase or game.phase, kind=kind, actor_seat=actor_seat, @@ -134,10 +143,11 @@ def _private_log( text: str, now_epoch: int, phase: Phase | None = None, + day: int | None = None, ) -> LogEntry: return LogEntry( game_id=game.id, - day=game.day_number, + day=game.day_number if day is None else day, phase=phase or game.phase, kind=kind, actor_seat=None, @@ -313,6 +323,7 @@ def plan_night0( ), now_epoch=now_epoch, phase=Phase.DAY_DISCUSSION, + day=1, ) return Transition( @@ -808,6 +819,7 @@ def plan_night_resolve( if knight_seat is not None and guard_target is not None: record_guard = (knight_seat, guard_target) + next_day = game.day_number + 1 public_logs: tuple[LogEntry, ...] = ( _public_log( game, @@ -816,6 +828,7 @@ def plan_night_resolve( now_epoch=now_epoch, phase=Phase.DAY_DISCUSSION, actor_seat=killed_seat, + day=next_day, ), ) @@ -838,7 +851,6 @@ def plan_night_resolve( clear_force_skip=True, ) - next_day = game.day_number + 1 day_start = _public_log( game, kind="PHASE_CHANGE", @@ -848,6 +860,7 @@ def plan_night_resolve( ), now_epoch=now_epoch, phase=Phase.DAY_DISCUSSION, + day=next_day, ) return Transition( next_phase=Phase.DAY_DISCUSSION, diff --git a/tests/test_state_machine_nights.py b/tests/test_state_machine_nights.py index f7520ea..a4164a1 100644 --- a/tests/test_state_machine_nights.py +++ b/tests/test_state_machine_nights.py @@ -158,6 +158,51 @@ def test_plan_night_resolve_skips_dead_attack_target() -> None: assert "平和" in t.morning_text +def test_morning_and_day_start_logs_carry_next_day_number() -> None: + """The MORNING + "{N} 日目の議論を開始" PHASE_CHANGE logs are emitted + by ``plan_night_resolve`` *before* day_number is bumped, so they + must explicitly carry ``day=next_day`` (= game.day_number + 1) + rather than the default ``game.day_number``. Otherwise the viewer's + per-(day, phase) bucketing puts day-N+1's morning in day-N's + DAY_DISCUSSION section, where it appears AFTER the day-N speeches + and produces the misleading "DAY_VOTE → NIGHT → DAY_DISCUSSION" + timeline observed in game cbfc32400ee2. + """ + game = _game(day=1) + players = _players() + seats = _seats() + actions = [ + _act(1, SubmissionType.WOLF_ATTACK, 7), + _act(2, SubmissionType.WOLF_ATTACK, 7), + _act(4, SubmissionType.SEER_DIVINE, 8), + _act(6, SubmissionType.KNIGHT_GUARD, 8), + ] + t = plan_night_resolve( + game, + players, + seats, + actions, + previous_guard_seat=None, + force_skip=False, + now_epoch=1000, + ) + assert t.next_day == 2 + + morning = next(log for log in t.public_logs if log.kind == "MORNING") + assert morning.day == 2, ( + f"MORNING tagged day={morning.day}, expected 2 (next day after night N)" + ) + + day_start = next( + log + for log in t.public_logs + if log.kind == "PHASE_CHANGE" and "議論を開始" in log.text + ) + assert day_start.day == 2, ( + f"day-start PHASE_CHANGE tagged day={day_start.day}, expected 2" + ) + + def test_morning_announce_does_not_reveal_role() -> None: game = _game(day=1) players = _players() diff --git a/tests/test_state_machine_phases.py b/tests/test_state_machine_phases.py index d1f039b..539c6bf 100644 --- a/tests/test_state_machine_phases.py +++ b/tests/test_state_machine_phases.py @@ -87,6 +87,18 @@ def test_plan_night0_transitions_to_day1_with_deadline() -> None: assert "ROLE_NOTICE" in kinds assert "SEER_RESULT_NIGHT0" in kinds assert "WOLF_PARTNER" in kinds + # The day-1-start PHASE_CHANGE log must be tagged day=1, not the + # game's current day_number (=0 at NIGHT_0). Same bug class as + # plan_night_resolve's MORNING/PHASE_CHANGE — without this tag the + # viewer puts day-1's discussion-open in the day-0 SETUP bucket + # and day-1 DAY_DISCUSSION starts off without its phase-boundary + # log. + day1_start = next( + log + for log in t.public_logs + if log.kind == "PHASE_CHANGE" and "1 日目の議論を開始" in log.text + ) + assert day1_start.day == 1 def test_plan_night0_random_white_is_non_wolf_non_self() -> None: From 41b002b9469f03953a70d77230977de0150e5ab8 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 13:35:49 +0900 Subject: [PATCH 109/133] fix(tests): isolate WOLFBOT_LLM_TRACE_DIR session-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/conftest.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3f8ece5..14bea49 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,13 +3,20 @@ - `repo`: a SqliteRepo backed by a tempfile DB with migrate() already run. - `seats`: a deterministic 9-seat lineup (5 humans + 4 LLMs by default). - `frozen_rng`: random.Random(seed) for reproducible role shuffles. +- `_isolate_llm_trace_dir` (autouse, session): redirect the LLM trace + output to a session-temp dir so a decider test that calls + ``decider.decide()`` without entering ``trace_context`` never leaks + rows into the repo's production ``logs/llm_calls/no_game/`` (one such + leak accumulated 700+ stray ``actor=None`` lines in production + before this fixture was added). """ from __future__ import annotations +import os import random import tempfile -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Iterator from pathlib import Path import pytest @@ -20,6 +27,28 @@ from wolfbot.persistence.sqlite_repo import SqliteRepo +@pytest.fixture(scope="session", autouse=True) +def _isolate_llm_trace_dir( + tmp_path_factory: pytest.TempPathFactory, +) -> Iterator[Path]: + """Pin ``WOLFBOT_LLM_TRACE_DIR`` to a per-session temp dir for every + test. Function-scoped fixtures that override the env var (e.g. the + ``trace_dir`` fixture in ``test_llm_service.py``) still take + precedence — pytest's monkeypatch undoes its setenv at teardown, + after which ``os.environ`` falls back to this session-level value. + """ + sandbox = tmp_path_factory.mktemp("llm_trace_sandbox") + prev = os.environ.get("WOLFBOT_LLM_TRACE_DIR") + os.environ["WOLFBOT_LLM_TRACE_DIR"] = str(sandbox) + try: + yield sandbox + finally: + if prev is None: + os.environ.pop("WOLFBOT_LLM_TRACE_DIR", None) + else: + os.environ["WOLFBOT_LLM_TRACE_DIR"] = prev + + @pytest.fixture def frozen_rng() -> random.Random: return random.Random(42) From 39621bec29a41c07cfa7de926a555a9525aa00b1 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 13:36:09 +0900 Subject: [PATCH 110/133] feat(viewer): surface vote/night/wolf-chat LLM trace + wolf chat in timeline 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. --- src/wolfbot/npc/runtime/client.py | 69 ++++++++-- src/wolfbot/services/game_export.py | 160 ++++++++++++++++++---- src/wolfbot/services/game_export_types.py | 33 ++++- tests/test_game_export.py | 82 +++++++++++ viewer/sample-data/export-schema.json | 34 +++++ viewer/src/components/PhaseSection.tsx | 127 +++++++++++++++-- viewer/src/lib/types.ts | 18 ++- 7 files changed, 476 insertions(+), 47 deletions(-) diff --git a/src/wolfbot/npc/runtime/client.py b/src/wolfbot/npc/runtime/client.py index 9913a34..8c48090 100644 --- a/src/wolfbot/npc/runtime/client.py +++ b/src/wolfbot/npc/runtime/client.py @@ -70,6 +70,11 @@ ) from wolfbot.npc.decision.game_state import NpcGameState, apply_update, state_from_snapshot from wolfbot.npc.speech.speech_service import NpcSpeechService +from wolfbot.services.llm_trace import ( + parse_day_from_phase_id, + parse_game_id_from_phase_id, + trace_context, +) log = logging.getLogger(__name__) @@ -315,8 +320,6 @@ def _lookup_state_for_speech( has been received yet — the generator falls back to the SpeakRequest fields in that case. """ - from wolfbot.services.llm_trace import parse_game_id_from_phase_id - gid = parse_game_id_from_phase_id(request.phase_id) if gid is None: return None @@ -564,10 +567,25 @@ async def _decide_vote_target( # village's ability to lynch a wolf when one round-trip flakes. result_target_seat: int | None = None reason_summary = "llm_error" + actor = ( + f"seat={req.seat_no} persona={state.persona_key} " + f"role={state.role} npc_id={self.config.npc_id}" + ) try: - raw = await self.decision_llm.decide_json( - system_prompt=system, user_prompt=user, schema=_VOTE_SCHEMA, - ) + with trace_context( + game_id=req.game_id, + phase=req.phase_id, + day=parse_day_from_phase_id(req.phase_id), + actor=actor, + metadata={ + "task": "vote", + "round": req.round_, + "request_id": req.request_id, + }, + ): + raw = await self.decision_llm.decide_json( + system_prompt=system, user_prompt=user, schema=_VOTE_SCHEMA, + ) result = parse_decision(raw, legal_seats=legal) result_target_seat = result.target_seat reason_summary = result.reason_summary @@ -665,10 +683,24 @@ async def _build_wolf_chat_line(self, req: WolfChatRequest) -> str | None: candidates=req.candidate_seats, public_state_summary=req.public_state_summary, ) + actor = ( + f"seat={req.seat_no} persona={state.persona_key} " + f"role={state.role} npc_id={self.config.npc_id}" + ) try: - raw = await self.decision_llm.decide_json( - system_prompt=system, user_prompt=user, schema=_WOLF_CHAT_SCHEMA, - ) + with trace_context( + game_id=req.game_id, + phase=req.phase_id, + day=parse_day_from_phase_id(req.phase_id), + actor=actor, + metadata={ + "task": "wolf_chat", + "request_id": req.request_id, + }, + ): + raw = await self.decision_llm.decide_json( + system_prompt=system, user_prompt=user, schema=_WOLF_CHAT_SCHEMA, + ) except Exception: log.exception( "npc_wolf_chat_llm_failed game=%s seat=%d", @@ -706,10 +738,25 @@ async def _decide_night_target( # WAITING_HOST_DECISION. result_target_seat: int | None = None reason_summary = "llm_error" + actor = ( + f"seat={req.seat_no} persona={state.persona_key} " + f"role={state.role} npc_id={self.config.npc_id}" + ) try: - raw = await self.decision_llm.decide_json( - system_prompt=system, user_prompt=user, schema=_NIGHT_SCHEMA, - ) + with trace_context( + game_id=req.game_id, + phase=req.phase_id, + day=parse_day_from_phase_id(req.phase_id), + actor=actor, + metadata={ + "task": "night_action", + "action_kind": req.action_kind, + "request_id": req.request_id, + }, + ): + raw = await self.decision_llm.decide_json( + system_prompt=system, user_prompt=user, schema=_NIGHT_SCHEMA, + ) result = parse_decision(raw, legal_seats=legal) result_target_seat = result.target_seat reason_summary = result.reason_summary diff --git a/src/wolfbot/services/game_export.py b/src/wolfbot/services/game_export.py index c5d9d98..f30b70a 100644 --- a/src/wolfbot/services/game_export.py +++ b/src/wolfbot/services/game_export.py @@ -54,6 +54,7 @@ TraceEntry, Victory, VoteExport, + WolfChatLogEntry, ) log = logging.getLogger(__name__) @@ -164,6 +165,22 @@ async def _build_payload( "ORDER BY day, submitted_at", (game_id,), )] + # Wolf chat is fan-out-stored in `logs_private` (one row per + # audience seat) so the same utterance shows up N times where N + # = alive wolves at the moment. Dedupe on + # (actor_seat, created_at, text) keeps one row per actual + # utterance — that's what the viewer wants to render. Phase is + # always 'NIGHT' (or 'NIGHT_0' on day 0) by construction; we + # carry it through so the per-phase grouping in `_build_phases` + # picks it up without extra logic. + wolf_chat_rows = [dict(r) for r in await _fetch_all( + db, + "SELECT DISTINCT day, phase, actor_seat, text, created_at " + "FROM logs_private " + "WHERE game_id = ? AND kind = 'WOLF_CHAT' AND actor_seat IS NOT NULL " + "ORDER BY created_at ASC", + (game_id,), + )] # `phase_baseline` rows are an internal sentinel used by # PublicDiscussionState to seed alive-seat baselines; they have # empty text and are explicitly excluded from public-log emission @@ -216,7 +233,11 @@ async def _build_payload( game=_build_game_meta(game_row, public_log_rows), seats=[_build_seat(s) for s in seat_rows], phases=_build_phases( - public_log_rows, speech_event_rows, vote_rows, night_action_rows + _rebucket_morning_logs(public_log_rows), + speech_event_rows, + vote_rows, + night_action_rows, + wolf_chat_rows, ), trace=_load_trace(trace_root, game_id), arbiter_decisions=[_build_arbiter_decision(r) for r in arbiter_rows], @@ -320,52 +341,130 @@ def _build_seat(row: dict[str, Any]) -> SeatExport: ) +_DAY_START_TEXT_PREFIX = ("夜が明けました。1 日目の議論を開始", "日目の議論を開始") + + +def _rebucket_morning_logs( + public_logs: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Defensive rebucketing for legacy games whose MORNING + day-start + PHASE_CHANGE logs were written with the *prior* day_number. + + Pre-fix (state_machine.py before 2026-05-02), ``plan_night_resolve`` + and ``plan_night0`` emitted these logs through the ``_public_log`` + helper which defaulted ``day=game.day_number`` — and that's the + OLD day at the moment the transition is computed. So a NIGHT 1 + resolution wrote "朝になりました" / "2 日目の議論を開始" with + ``day=1`` instead of ``day=2``, and the viewer's per-(day, phase) + bucketing put day-2's morning into day-1's DAY_DISCUSSION section. + The visible symptom: timeline reads + "DAY_DISCUSSION → DAY_VOTE → NIGHT → DAY_DISCUSSION" with the + second discussion's started_at_ms timestamped *after* its own + speeches. + + The state machine now tags these logs explicitly with + ``day=next_day`` (commit ``state_machine.py``). Existing games + re-exported through this path still need the fix without a DB + rewrite — hence this normalisation step. Returns a *new* list + with day-fields adjusted; the input is not mutated so other + consumers (e.g. ``_build_game_meta``) keep their original view. + """ + fixed: list[dict[str, Any]] = [] + for row in public_logs: + if ( + row.get("phase") == "DAY_DISCUSSION" + and ( + row.get("kind") == "MORNING" + or ( + row.get("kind") == "PHASE_CHANGE" + and isinstance(row.get("text"), str) + and "日目の議論を開始" in row["text"] + ) + ) + ): + patched = dict(row) + patched["day"] = (row.get("day") or 0) + 1 + fixed.append(patched) + else: + fixed.append(row) + return fixed + + def _build_phases( public_logs: list[dict[str, Any]], speech_events: list[dict[str, Any]], votes: list[dict[str, Any]], night_actions: list[dict[str, Any]], + wolf_chat_logs: list[dict[str, Any]], ) -> list[PhaseSection]: """Group all per-game events into ordered ``(day, phase)`` buckets. A phase appears in the output if AT LEAST ONE of public_logs / - speech_events / votes / night_actions has data for it. We don't - invent empty buckets — a game that died in lobby produces only a - SETUP / LOBBY section, not a full ladder of empty NIGHT/DAY rows. + speech_events / votes / night_actions / wolf_chat_logs has data for + it. We don't invent empty buckets — a game that died in lobby + produces only a SETUP / LOBBY section, not a full ladder of empty + NIGHT/DAY rows. """ - # Discover (day, phase) pairs in chronological order from public logs; - # those are the most reliable per-phase event source. - seen: dict[tuple[int, str], int] = {} # (day, phase) -> first_ts (epoch s) + # Discover (day, phase) pairs in chronological order. Each bucket's + # ``first_ts_ms`` is the earliest timestamp of any event tagged for + # it — public_logs (epoch s), speech_events (ms), votes (s), + # night_actions (s), wolf_chat_logs (s). We track milliseconds so a + # phase that ends and the next phase that begins within the same + # epoch second still order correctly (only speech_events carry + # sub-second resolution today, but a per-(day, phase) tie still + # falls back to the secondary phase-ordering key below). + seen: dict[tuple[int, str], int] = {} # (day, phase) -> first_ts_ms + + def _record(key: tuple[int, str], ts_ms: int) -> None: + existing = seen.get(key) + if existing is None or ts_ms < existing: + seen[key] = ts_ms + for row in public_logs: - key = (row["day"], row["phase"]) - if key not in seen: - seen[key] = row["created_at"] - # Plus any phase that has speeches but no public log: + _record((row["day"], row["phase"]), _epoch_to_ms(row["created_at"])) for ev in speech_events: - key = (ev["day"], ev["phase"]) - if key not in seen: - seen[key] = ev["created_at_ms"] // 1000 - # Plus night phases inferred from night_actions: + _record((ev["day"], ev["phase"]), int(ev["created_at_ms"])) for na in night_actions: phase = "NIGHT_0" if na["day"] == 0 else "NIGHT" - key = (na["day"], phase) - if key not in seen: - seen[key] = na["submitted_at"] - # Plus vote phases inferred from votes: + _record((na["day"], phase), _epoch_to_ms(na["submitted_at"])) for v in votes: phase = "DAY_RUNOFF" if v["round"] >= 2 else "DAY_VOTE" - key = (v["day"], phase) - if key not in seen: - seen[key] = v["submitted_at"] - - ordered = sorted(seen.items(), key=lambda kv: kv[1]) + _record((v["day"], phase), _epoch_to_ms(v["submitted_at"])) + # Wolf chat surfaces phases that have no other events (rare: + # attack-less NIGHT where wolves coordinated but never committed). + for wc in wolf_chat_logs: + _record((wc["day"], wc["phase"]), _epoch_to_ms(wc["created_at"])) + + # Stable secondary sort: when two phases share a millisecond + # (typical at the NIGHT-resolve / next-day-MORNING boundary, where + # the night's last submission and the morning log share an epoch + # second), enforce within-day phase order so the timeline reads + # SETUP → DAY_DISCUSSION → DAY_VOTE → DAY_RUNOFF_SPEECH → + # DAY_RUNOFF → NIGHT → (next day) DAY_DISCUSSION → ... + phase_order_within_day = { + "SETUP": 0, + "NIGHT_0": 1, + "DAY_DISCUSSION": 2, + "DAY_VOTE": 3, + "DAY_RUNOFF_SPEECH": 4, + "DAY_RUNOFF": 5, + "NIGHT": 6, + "GAME_OVER": 7, + "WAITING_HOST_DECISION": 8, + } + + def _sort_key(item: tuple[tuple[int, str], int]) -> tuple[int, int, int]: + (day, phase), first_ts_ms = item + return (first_ts_ms, day, phase_order_within_day.get(phase, 99)) + + ordered = sorted(seen.items(), key=_sort_key) result: list[PhaseSection] = [] - for (day, phase), first_ts in ordered: + for (day, phase), first_ts_ms in ordered: result.append( PhaseSection( day=day, phase=phase, - started_at_ms=_epoch_to_ms(first_ts), + started_at_ms=first_ts_ms, public_logs=[ PublicLogEntry( kind=r["kind"], @@ -415,6 +514,15 @@ def _build_phases( for na in night_actions if na["day"] == day and _night_phase(na["day"]) == phase ], + wolf_chat_logs=[ + WolfChatLogEntry( + actor_seat=wc["actor_seat"], + text=wc["text"], + created_at_ms=_epoch_to_ms(wc["created_at"]), + ) + for wc in wolf_chat_logs + if wc["day"] == day and wc["phase"] == phase + ], ) ) return result diff --git a/src/wolfbot/services/game_export_types.py b/src/wolfbot/services/game_export_types.py index a81d30f..a7b8857 100644 --- a/src/wolfbot/services/game_export_types.py +++ b/src/wolfbot/services/game_export_types.py @@ -47,7 +47,13 @@ # Re-export the canonical wire/storage form from domain/enums so the # viewer schema stays aligned with runtime validators in lockstep. CoDeclaration = _CoDeclaration -TraceRole = Literal["gameplay", "npc_speech", "voice_stt", "text_analysis"] +TraceRole = Literal[ + "gameplay", + "npc_speech", + "npc_decision", + "voice_stt", + "text_analysis", +] Victory = Literal["village", "wolf"] @@ -184,6 +190,25 @@ class NightActionExport(BaseModel): submitted_at_ms: int +class WolfChatLogEntry(BaseModel): + """One wolf-only night-coordination utterance. + + Sourced from the ``logs_private`` table where ``visibility='PRIVATE'`` + and ``kind='WOLF_CHAT'``. The DB row is duplicated per audience-seat + (one for each living wolf) so the exporter dedupes on + ``(actor_seat, created_at_ms, text)`` and emits a single entry per + utterance — the audience side is implicit (= every alive wolf at the + time of the utterance) and not surfaced in the viewer because the + wolves channel is wolf-private by construction. + """ + + model_config = _StrictConfig + + actor_seat: int + text: str + created_at_ms: int + + class PhaseSection(BaseModel): model_config = _StrictConfig @@ -194,6 +219,11 @@ class PhaseSection(BaseModel): speech_events: list[SpeechEventExport] votes: list[VoteExport] night_actions: list[NightActionExport] + # Night-only: each wolf's coordination line during NIGHT. Empty for + # day-side phases. Kept on the phase rather than top-level so the + # viewer renders them inline in the night's timeline next to wolf + # attack night_actions. + wolf_chat_logs: list[WolfChatLogEntry] = [] class TokenUsage(BaseModel): @@ -315,4 +345,5 @@ class GameExport(BaseModel): "TraceRole", "Victory", "VoteExport", + "WolfChatLogEntry", ] diff --git a/tests/test_game_export.py b/tests/test_game_export.py index c77dcd1..3209563 100644 --- a/tests/test_game_export.py +++ b/tests/test_game_export.py @@ -201,6 +201,88 @@ async def test_export_game_writes_json_with_full_shape( assert payload["trace"] == [] +async def test_export_game_emits_wolf_chat_logs_per_phase( + fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path +) -> None: + """Wolf-only night chat is stored fan-out (one row per audience seat) + in ``logs_private``. The exporter must dedupe to one entry per + actual utterance and attach it to the matching NIGHT phase. + + Why this matters: viewer renders the wolves' coordination in the + night timeline. Pre-fix the WOLF_CHAT rows were excluded entirely + (exporter only read ``logs_public``), so the viewer had no way to + surface the most informative private signal a post-game review + needs (which wolf proposed which target, in what order). + """ + repo, db_path = fixture_repo + await _seed_minimal_game(repo) + + # Add a second wolf so the fan-out (one row per audience) is + # exercised — the exporter must collapse N audience rows back to 1. + seat3 = Seat( + seat_no=3, display_name="Wolf2", is_llm=True, persona_key="raqio", + discord_user_id="u3", + ) + await repo.insert_seat(GAME_ID, seat3) + await repo.set_player_role(GAME_ID, 3, Role.WEREWOLF) + + # Wolf seat 2's NIGHT_1 utterance, fan-out written to both wolves. + for audience in (2, 3): + await repo.insert_log_private( + LogEntry( + game_id=GAME_ID, + day=1, + phase=Phase.NIGHT, + kind="WOLF_CHAT", + actor_seat=2, + visibility="PRIVATE", + audience_seat=audience, + text="セツを噛もう", + created_at=1_700_000_700, + ) + ) + # Wolf seat 3's reply. + for audience in (2, 3): + await repo.insert_log_private( + LogEntry( + game_id=GAME_ID, + day=1, + phase=Phase.NIGHT, + kind="WOLF_CHAT", + actor_seat=3, + visibility="PRIVATE", + audience_seat=audience, + text="同意。霊媒回避が優先", + created_at=1_700_000_710, + ) + ) + + out = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=tmp_path / "out", + ) + payload = json.loads(out.read_text(encoding="utf-8")) + + # NIGHT phase appears in the timeline because of the wolf chat + # rows (no NIGHT_1 actions or public logs were seeded for day 1). + night1 = next( + p for p in payload["phases"] if p["day"] == 1 and p["phase"] == "NIGHT" + ) + assert len(night1["wolf_chat_logs"]) == 2 # deduped from 4 rows + first, second = night1["wolf_chat_logs"] + assert first["actor_seat"] == 2 + assert first["text"] == "セツを噛もう" + assert first["created_at_ms"] == 1_700_000_700_000 + assert second["actor_seat"] == 3 + assert second["text"] == "同意。霊媒回避が優先" + + # Day-side phases stay empty so nothing leaks across. + disc = next(p for p in payload["phases"] if p["phase"] == "DAY_DISCUSSION") + assert disc["wolf_chat_logs"] == [] + + async def test_export_game_inlines_jsonl_trace( fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path ) -> None: diff --git a/viewer/sample-data/export-schema.json b/viewer/sample-data/export-schema.json index 56b99a2..a54eabd 100644 --- a/viewer/sample-data/export-schema.json +++ b/viewer/sample-data/export-schema.json @@ -507,6 +507,14 @@ }, "title": "Night Actions", "type": "array" + }, + "wolf_chat_logs": { + "default": [], + "items": { + "$ref": "#/$defs/WolfChatLogEntry" + }, + "title": "Wolf Chat Logs", + "type": "array" } }, "required": [ @@ -829,6 +837,7 @@ "enum": [ "gameplay", "npc_speech", + "npc_decision", "voice_stt", "text_analysis" ], @@ -1018,6 +1027,31 @@ ], "title": "VoteExport", "type": "object" + }, + "WolfChatLogEntry": { + "additionalProperties": false, + "description": "One wolf-only night-coordination utterance.\n\nSourced from the ``logs_private`` table where ``visibility='PRIVATE'``\nand ``kind='WOLF_CHAT'``. The DB row is duplicated per audience-seat\n(one for each living wolf) so the exporter dedupes on\n``(actor_seat, created_at_ms, text)`` and emits a single entry per\nutterance — the audience side is implicit (= every alive wolf at the\ntime of the utterance) and not surfaced in the viewer because the\nwolves channel is wolf-private by construction.", + "properties": { + "actor_seat": { + "title": "Actor Seat", + "type": "integer" + }, + "text": { + "title": "Text", + "type": "string" + }, + "created_at_ms": { + "title": "Created At Ms", + "type": "integer" + } + }, + "required": [ + "actor_seat", + "text", + "created_at_ms" + ], + "title": "WolfChatLogEntry", + "type": "object" } }, "additionalProperties": false, diff --git a/viewer/src/components/PhaseSection.tsx b/viewer/src/components/PhaseSection.tsx index 9192779..4c5f47a 100644 --- a/viewer/src/components/PhaseSection.tsx +++ b/viewer/src/components/PhaseSection.tsx @@ -92,6 +92,8 @@ export default function PhaseSection({ ); } +type WolfChatLog = NonNullable[number]; + type TimelineEvent = | { kind: "log"; ts: number; data: PhaseSectionType["public_logs"][number] } | { @@ -107,6 +109,12 @@ type TimelineEvent = ts: number; data: PhaseSectionType["night_actions"][number]; trace: TraceEntry | null; + } + | { + kind: "wolf_chat"; + ts: number; + data: WolfChatLog; + trace: TraceEntry | null; }; function buildTimeline( @@ -144,6 +152,14 @@ function buildTimeline( trace: matchTraceForNightAction(na, phase, trace), }); } + for (const wc of phase.wolf_chat_logs ?? []) { + events.push({ + kind: "wolf_chat", + ts: wc.created_at_ms, + data: wc, + trace: matchTraceForWolfChat(wc, phase, trace), + }); + } events.sort((a, b) => a.ts - b.ts); return events; } @@ -254,6 +270,29 @@ function matchTraceForSpeech( ); } +// Vote / night-action LLM calls land in the trace under two distinct +// `role` values depending on which side of the WS the decision LLM +// runs on: +// - rounds mode: Master's `LLMAdapter._ask` calls the gameplay +// decider → role="gameplay", metadata.task="vote"|"night_action". +// - reactive_voice mode: each NPC bot decides for its own seat via +// `decision_service` → role="npc_decision", +// metadata.task="vote"|"night_action" (added on the NPC client +// wrap-context, see npc/runtime/client.py). +// The viewer accepts both so the lightbulb buttons attach in either +// mode. `phase` matching is loose for npc_decision because the trace +// records the canonical phase_id (`{gid}::dayN::PHASE::seq`) rather +// than the bare PhaseEnum value. +function isDecisionRole(t: TraceEntry): boolean { + return t.role === "gameplay" || t.role === "npc_decision"; +} + +function phaseMatchesLoose(t: TraceEntry, phase: PhaseSectionType): boolean { + if (t.day !== phase.day) return false; + if (t.phase === phase.phase) return true; + return typeof t.phase === "string" && t.phase.includes(`::${phase.phase}`); +} + function matchTraceForVote( v: Vote, phase: PhaseSectionType, @@ -262,15 +301,19 @@ function matchTraceForVote( ): TraceEntry | null { const targetSeat = findSeat(seats, v.target_seat); if (!targetSeat) return null; + // For role="gameplay" we additionally key on the response containing + // `席N` because the rounds-mode dispatcher batches all voters into one + // task and the response payload is the only thing tying a trace line + // to a specific (voter, target) pair. For role="npc_decision" the + // trace already has a 1:1 actor→seat binding so we don't need it. return ( trace.find( (t) => - t.role === "gameplay" && - t.day === phase.day && - t.phase === phase.phase && + isDecisionRole(t) && + phaseMatchesLoose(t, phase) && actorSeat(t) === v.voter_seat && t.metadata?.task === "vote" && - responseContains(t, `席${v.target_seat}`), + (t.role !== "gameplay" || responseContains(t, `席${v.target_seat}`)), ) ?? null ); } @@ -283,15 +326,44 @@ function matchTraceForNightAction( return ( trace.find( (t) => - t.role === "gameplay" && - t.day === phase.day && - t.phase === phase.phase && + isDecisionRole(t) && + phaseMatchesLoose(t, phase) && actorSeat(t) === na.actor_seat && - t.metadata?.task === "night_action", + t.metadata?.task === "night_action" && + (t.metadata?.action_kind == null || + t.metadata?.action_kind === na.kind), ) ?? null ); } +function matchTraceForWolfChat( + wc: WolfChatLog, + phase: PhaseSectionType, + trace: TraceEntry[], +): TraceEntry | null { + // Pair each wolf-chat utterance with its decision LLM call. Since + // the request_id isn't in the DB row, the closest stable match is + // (phase, day, actor_seat, task=wolf_chat) — picking the first + // unpaired trace whose timestamp is at or before the utterance. + // Multiple wolves coordinate sequentially so per-actor matching is + // accurate enough; viewer prefers the LATEST candidate before the + // utterance to handle retry / fallback cycles. + let best: TraceEntry | null = null; + let bestTs = -Infinity; + for (const t of trace) { + if (!isDecisionRole(t)) continue; + if (!phaseMatchesLoose(t, phase)) continue; + if (actorSeat(t) !== wc.actor_seat) continue; + if (t.metadata?.task !== "wolf_chat") continue; + const ts = t.ts ? new Date(t.ts).getTime() : 0; + if (ts <= wc.created_at_ms && ts > bestTs) { + best = t; + bestTs = ts; + } + } + return best; +} + function actorSeat(t: TraceEntry): number | null { if (!t.actor) return null; const m = t.actor.match(/seat=(\d+)/); @@ -411,6 +483,45 @@ function EventRow({ ); } + if (event.kind === "wolf_chat") { + const speaker = findSeat(seats, event.data.actor_seat); + return ( + + + + + + + {speaker ? seatLabel(speaker) : `席${event.data.actor_seat}`} + + + + {event.data.text} + + + + + ); + } + // night_action const actor = findSeat(seats, event.data.actor_seat); const target = findSeat(seats, event.data.target_seat); diff --git a/viewer/src/lib/types.ts b/viewer/src/lib/types.ts index 065d4d3..083e6d0 100644 --- a/viewer/src/lib/types.ts +++ b/viewer/src/lib/types.ts @@ -17,7 +17,12 @@ export type SpeechSource = "text" | "voice_stt" | "npc_generated"; export type CoDeclaration = "seer" | "medium" | "knight" | null; -export type TraceRole = "gameplay" | "npc_speech" | "voice_stt" | "text_analysis"; +export type TraceRole = + | "gameplay" + | "npc_speech" + | "npc_decision" + | "voice_stt" + | "text_analysis"; export interface Seat { seat_no: number; @@ -106,6 +111,16 @@ export interface NightAction { submitted_at_ms: number; } +// Wolf-only night coordination utterances. Stored in `logs_private` +// during play (one row per audience-seat fan-out) and deduped at +// export time so the viewer renders one entry per actual utterance. +// `wolf_chat_logs` is empty on day-side phases. +export interface WolfChatLog { + actor_seat: number; + text: string; + created_at_ms: number; +} + export interface PhaseSection { day: number; phase: string; @@ -114,6 +129,7 @@ export interface PhaseSection { speech_events: SpeechEvent[]; votes: Vote[]; night_actions: NightAction[]; + wolf_chat_logs?: WolfChatLog[]; } export interface TokenUsage { From 2c59f5cf60d037cb54dbf36842342b9a724a9e9d Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 13:42:08 +0900 Subject: [PATCH 111/133] feat(chat): post kill announcement + survivor roster after each morning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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="☀️ " 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名". --- src/wolfbot/master/narration/narration.py | 10 +- src/wolfbot/services/discord_service.py | 52 ++++++++- tests/test_discord_service.py | 125 ++++++++++++++++++++++ tests/test_master_narration.py | 34 ++++++ 4 files changed, 217 insertions(+), 4 deletions(-) diff --git a/src/wolfbot/master/narration/narration.py b/src/wolfbot/master/narration/narration.py index 170c198..1f42af5 100644 --- a/src/wolfbot/master/narration/narration.py +++ b/src/wolfbot/master/narration/narration.py @@ -163,6 +163,13 @@ def _narrate_morning(entry: LogEntry, ctx: NarrationContext) -> NarrationOutput: The template rewrites it neutrally with day_number + casualty info. Two branches gated by has/no_casualty so the same template covers both "犠牲者なし" and " の停止を確認" without a Python ternary. + + Also surfaces the kill / no-kill announcement to the VC's text chat + so post-game review (and players who muted Master's voice) see who + died without having to listen back. The companion survivor roster + is posted separately by ``DiscordBotAdapter.post_morning`` so it + stays a chat-only sidecar — voice keeps the existing concise tone + and isn't extended with a 5-name read-out. """ has_casualty = entry.actor_seat is not None target = ( @@ -176,7 +183,8 @@ def _narrate_morning(entry: LogEntry, ctx: NarrationContext) -> NarrationOutput: no_casualty=not has_casualty, target=target, alive_count=ctx.alive_count, - ) + ), + chat_text=f"☀️ {entry.text}", ) diff --git a/src/wolfbot/services/discord_service.py b/src/wolfbot/services/discord_service.py index c9599fb..02e2a3f 100644 --- a/src/wolfbot/services/discord_service.py +++ b/src/wolfbot/services/discord_service.py @@ -303,15 +303,61 @@ async def post_public(self, game: Game, text: str, kind: str) -> None: log.exception("post_public failed %s", game.id) async def post_morning(self, game: Game, text: str) -> None: - if await self._maybe_narrate(game, "MORNING", text): + consumed = await self._maybe_narrate(game, "MORNING", text) + if not consumed: + channel = self._main_text(game) + if channel is not None: + try: + await channel.send(f"☀️ {text}") + except discord.DiscordException: + log.exception("post_morning failed %s", game.id) + # Both rounds and reactive_voice modes append the survivor + # roster after the morning announcement so players see the + # post-attack lineup at a glance. Voice-only Master callers + # were missing the kill info entirely (narrator emitted no + # chat_text); the kill line is now surfaced in chat by + # _narrate_morning, and this sidecar lists who's still in. + await self._post_survivors_list(game) + + async def _post_survivors_list(self, game: Game) -> None: + """Post a "🌱 生存者 (N名): seat list" line to the game's main + channel (VC text in reactive_voice, main text in rounds). + + Best-effort: a load failure or send error is logged and dropped + rather than propagating into post_morning's caller — the + morning announcement itself already landed. + """ + try: + players = await self.repo.load_players(game.id) + seats = await self.repo.load_seats(game.id) + except Exception: + log.exception( + "post_survivors_list: load failed game=%s", game.id + ) + return + seats_by_no = {s.seat_no: s for s in seats} + alive_seats = sorted(p.seat_no for p in players if p.alive) + if not alive_seats: + # Edge case: morning resolution killed everyone (impossible + # in normal play; defensive). Survivors line would be + # misleading — drop it. return + parts: list[str] = [] + for sn in alive_seats: + seat = seats_by_no.get(sn) + parts.append( + f"席{sn} {seat.display_name}" if seat is not None else f"席{sn}" + ) + msg = f"🌱 生存者 ({len(alive_seats)}名): " + "、".join(parts) channel = self._main_text(game) if channel is None: return try: - await channel.send(f"☀️ {text}") + await channel.send(msg) except discord.DiscordException: - log.exception("post_morning failed %s", game.id) + log.exception( + "post_survivors_list send failed %s", game.id + ) async def post_wolves_chat(self, game: Game, text: str, kind: str) -> None: channel = self._wolves_channel(game) diff --git a/tests/test_discord_service.py b/tests/test_discord_service.py index dca7fbe..c5bca04 100644 --- a/tests/test_discord_service.py +++ b/tests/test_discord_service.py @@ -327,6 +327,131 @@ async def test_announce_waiting_vote_pending_retains_names(repo: SqliteRepo) -> assert adapter.wolves_sent == [] +class _MorningPostingAdapter(DiscordBotAdapter): + """DiscordBotAdapter narrowed to exercise post_morning + survivor + roster posting without a real discord.Client. + + Mirrors :class:`_ChannelCapturingAdapter`'s pattern but exposes a + single capture list (in chronological send order) so the test can + assert (a) the morning announcement lands first, then (b) the + survivor roster line is appended right after. + """ + + def __init__(self, repo: SqliteRepo) -> None: + self.bot = MagicMock() + self.repo = repo + self.settings = MagicMock() + self.perms = MagicMock() + self._gs_slot = {"gs": MagicMock()} + self._narrator = None # rounds-mode path: legacy text post + self._npc_registry = None + self.sent: list[str] = [] + + test_self = self + + class _Channel: + async def send(self, text: str) -> None: + test_self.sent.append(text) + + self._main_text = lambda game: _Channel() # type: ignore[assignment] + + +async def test_post_morning_appends_survivors_list_in_rounds_mode( + repo: SqliteRepo, +) -> None: + """Rounds mode (no narrator) posts the raw morning text followed + immediately by the post-attack survivor roster. Pre-fix the + survivor list was missing entirely; users had to scroll the + seat-permission updates to see who was still in. + """ + game = Game( + id="g-morning", + guild_id="g", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=2, + main_text_channel_id="1", + main_vc_channel_id="vc", + heaven_channel_id="h", + wolves_channel_id="w", + created_at=0, + discussion_mode="rounds", + ) + await repo.create_game(game) + # Seats 1 + 2 alive, seat 3 dead (executed yesterday). + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="1001", + is_llm=False, persona_key=None), + Seat(seat_no=2, display_name="Bob", discord_user_id="1002", + is_llm=False, persona_key=None), + Seat(seat_no=3, display_name="Cara", discord_user_id="1003", + is_llm=False, persona_key=None), + ] + for s in seats: + await repo.insert_seat(game.id, s) + await repo.set_player_role(game.id, 1, Role.VILLAGER) + await repo.set_player_role(game.id, 2, Role.WEREWOLF) + await repo.set_player_role(game.id, 3, Role.SEER) + # Mark seat 3 dead via raw UPDATE (test-only path; live engine uses + # apply_transition). + async with repo._tx() as db: + await db.execute( + "UPDATE seats SET alive=0 WHERE game_id=? AND seat_no=3", + (game.id,), + ) + + adapter = _MorningPostingAdapter(repo) + await adapter.post_morning(game, "朝になりました。犠牲者: Cara") + + assert len(adapter.sent) == 2, adapter.sent + morning_msg, survivors_msg = adapter.sent + assert morning_msg == "☀️ 朝になりました。犠牲者: Cara" + assert survivors_msg.startswith("🌱 生存者 (2名): ") + # Both alive seats included in the roster, dead seat excluded. + assert "席1 Alice" in survivors_msg + assert "席2 Bob" in survivors_msg + assert "Cara" not in survivors_msg + + +async def test_post_morning_skips_survivors_list_when_no_survivors( + repo: SqliteRepo, +) -> None: + """Edge case: morning resolution with zero survivors is impossible + in normal play (game ends first), but the helper must defensively + skip the survivor line rather than emit "🌱 生存者 (0名): ". + """ + game = Game( + id="g-empty", + guild_id="g", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=2, + main_text_channel_id="1", + main_vc_channel_id="vc", + heaven_channel_id="h", + wolves_channel_id="w", + created_at=0, + discussion_mode="rounds", + ) + await repo.create_game(game) + seat = Seat( + seat_no=1, display_name="Alice", discord_user_id="1001", + is_llm=False, persona_key=None, + ) + await repo.insert_seat(game.id, seat) + await repo.set_player_role(game.id, 1, Role.VILLAGER) + async with repo._tx() as db: + await db.execute( + "UPDATE seats SET alive=0 WHERE game_id=?", (game.id,), + ) + + adapter = _MorningPostingAdapter(repo) + await adapter.post_morning(game, "朝になりました。犠牲者: Alice") + + # Just the morning announcement; no roster line for an empty pool. + assert adapter.sent == ["☀️ 朝になりました。犠牲者: Alice"] + + class _ProbeUser: """Minimal duck-typed discord.User for _preflight_dms probing. diff --git a/tests/test_master_narration.py b/tests/test_master_narration.py index 571f7a5..0f121ce 100644 --- a/tests/test_master_narration.py +++ b/tests/test_master_narration.py @@ -162,6 +162,40 @@ def test_morning_without_death_voices_no_casualties() -> None: assert "9 名" in out.voice_text +def test_morning_emits_chat_text_with_kill_announcement() -> None: + """Reactive_voice mode pre-fix only voiced the morning, leaving the + chat empty for the kill / no-kill information (rounds mode posted + the raw text). The narrator now emits ``chat_text`` so VC text + chat carries the same announcement, prefixed with ☀️ to match the + rounds-mode formatting. The companion survivors-roster sidecar is + posted by ``DiscordBotAdapter.post_morning`` and tested + separately. + """ + entry = _entry( + "MORNING", + text="朝になりました。犠牲者: 🎩ジョナス", + actor_seat=1, + day=2, + phase=Phase.DAY_DISCUSSION, + ) + out = render_master_narration(entry, _ctx(day=2, alive=8)) + assert out.chat_text is not None + assert out.chat_text.startswith("☀️ ") + assert "犠牲者" in out.chat_text + + +def test_morning_no_casualty_emits_peaceful_chat_text() -> None: + entry = _entry( + "MORNING", + text="平和な朝です。昨晩の犠牲者はいません。", + actor_seat=None, + day=2, + ) + out = render_master_narration(entry, _ctx(day=2, alive=9)) + assert out.chat_text is not None + assert "平和な朝" in out.chat_text + + # ------------------------------------------------------ vote-result split From 541a899d10786c52623da4c21843d46c65bf4c88 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 13:48:32 +0900 Subject: [PATCH 112/133] feat(viewer): show speaker role chip on every speech row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- viewer/src/components/PhaseSection.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/viewer/src/components/PhaseSection.tsx b/viewer/src/components/PhaseSection.tsx index 4c5f47a..a3ab3cb 100644 --- a/viewer/src/components/PhaseSection.tsx +++ b/viewer/src/components/PhaseSection.tsx @@ -17,6 +17,8 @@ import { formatTokens, nightActionJa, phaseJa, + roleFaction, + roleJa, seatLabel, sourceJa, } from "@/lib/format"; @@ -424,6 +426,20 @@ function EventRow({ {speaker ? seatLabel(speaker) : "(不明)"} + {speaker && ( + // Real-role chip on every speech row so post-game review + // sees at a glance whether the speaker is wolf-side or + // village-side. Wolf/madman are tinted red to make the + // fake-CO contrast (e.g. wolf claiming SEER) visually + // obvious without scanning the seat panel for each line. + + )} Date: Sat, 2 May 2026 13:52:15 +0900 Subject: [PATCH 113/133] feat(viewer): per-role chip and seat-card colour palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- viewer/src/components/ClaimHistoryPanel.tsx | 22 ++------- viewer/src/components/PhaseSection.tsx | 16 +++--- viewer/src/components/SeatGrid.tsx | 19 ++++---- viewer/src/lib/format.ts | 54 +++++++++++++++++++++ 4 files changed, 75 insertions(+), 36 deletions(-) diff --git a/viewer/src/components/ClaimHistoryPanel.tsx b/viewer/src/components/ClaimHistoryPanel.tsx index 933e5e8..27d9694 100644 --- a/viewer/src/components/ClaimHistoryPanel.tsx +++ b/viewer/src/components/ClaimHistoryPanel.tsx @@ -36,6 +36,7 @@ import Chip from "@mui/material/Chip"; import Paper from "@mui/material/Paper"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; +import { roleChipStyle, roleJa } from "@/lib/format"; import type { ClaimHistoryEntry, ClaimedMediumHistoryEntry, @@ -158,10 +159,9 @@ function ClaimerRow({ {claimer && ( )} @@ -328,19 +328,3 @@ function computeDayCount(data: GameSample): number { return max; } -function isWolfSide(role: string): boolean { - return role === "WEREWOLF" || role === "MADMAN"; -} - -const ROLE_LABEL: Record = { - VILLAGER: "村人", - WEREWOLF: "人狼", - MADMAN: "狂人", - SEER: "占い師", - MEDIUM: "霊媒師", - KNIGHT: "騎士", -}; - -function roleLabel(role: string): string { - return ROLE_LABEL[role] ?? role; -} diff --git a/viewer/src/components/PhaseSection.tsx b/viewer/src/components/PhaseSection.tsx index a3ab3cb..896a924 100644 --- a/viewer/src/components/PhaseSection.tsx +++ b/viewer/src/components/PhaseSection.tsx @@ -17,7 +17,7 @@ import { formatTokens, nightActionJa, phaseJa, - roleFaction, + roleChipStyle, roleJa, seatLabel, sourceJa, @@ -427,16 +427,16 @@ function EventRow({ {speaker ? seatLabel(speaker) : "(不明)"} {speaker && ( - // Real-role chip on every speech row so post-game review - // sees at a glance whether the speaker is wolf-side or - // village-side. Wolf/madman are tinted red to make the - // fake-CO contrast (e.g. wolf claiming SEER) visually - // obvious without scanning the seat panel for each line. + // Real-role chip on every speech row, hue-coded per role + // (village power roles get distinct blue/purple/green; + // wolf-side red/orange) so post-game review reads the + // fake-CO contrast — e.g. a 人狼-tinted seat carrying a + // "占い師 CO" chip — without scanning the seat panel. + // See `roleChipStyle` in lib/format.ts. )} diff --git a/viewer/src/components/SeatGrid.tsx b/viewer/src/components/SeatGrid.tsx index ae1b25b..91c8ed7 100644 --- a/viewer/src/components/SeatGrid.tsx +++ b/viewer/src/components/SeatGrid.tsx @@ -4,7 +4,7 @@ import CardContent from "@mui/material/CardContent"; import Chip from "@mui/material/Chip"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import { roleFaction, roleJa } from "@/lib/format"; +import { roleChipStyle, roleJa, roleTint } from "@/lib/format"; import type { Seat } from "@/lib/types"; export default function SeatGrid({ seats }: { seats: Seat[] }) { @@ -25,19 +25,20 @@ export default function SeatGrid({ seats }: { seats: Seat[] }) { } function SeatCard({ seat }: { seat: Seat }) { - const faction = roleFaction(seat.role); - const factionBg = - faction === "wolf" ? "rgba(244, 67, 54, 0.08)" : "rgba(63, 81, 181, 0.04)"; - const factionBorder = - faction === "wolf" ? "rgba(244, 67, 54, 0.4)" : "rgba(63, 81, 181, 0.2)"; + // Per-role tinting: each of seer / medium / knight gets its own + // colour (alongside the existing wolf-red and madman-orange) so + // the seat panel scans visually the same way as the timeline's + // role chip. Helpers in lib/format.ts keep the hue palette in one + // place across PhaseSection, ClaimHistoryPanel, and this card. + const tint = roleTint(seat.role); const dim = !seat.alive; return ( @@ -60,7 +61,7 @@ function SeatCard({ seat }: { seat: Seat }) { {seat.persona_key && ( diff --git a/viewer/src/lib/format.ts b/viewer/src/lib/format.ts index 5569f56..a52127f 100644 --- a/viewer/src/lib/format.ts +++ b/viewer/src/lib/format.ts @@ -26,6 +26,60 @@ export function roleFaction(role: RoleKey): "village" | "wolf" { return ROLE_FACTION[role] ?? "village"; } +// Per-role chip style (MUI Chip props). Distinct hues let post-game +// review pick out a seer / medium / knight at a glance without the +// reader memorising who claimed what — three village power roles +// share blue / purple / green respectively, with wolf-side carrying +// red (人狼) vs orange (狂人) so factions stay visually grouped while +// each role still gets its own colour. Shared between PhaseSection +// (speech row), ClaimHistoryPanel (claimer header), and SeatGrid +// (seat card) so a role's colour is consistent across the viewer. +export type RoleChipColor = + | "default" + | "primary" + | "secondary" + | "error" + | "info" + | "success" + | "warning"; +export type RoleChipVariant = "filled" | "outlined"; + +const ROLE_CHIP_STYLE: Record< + RoleKey, + { color: RoleChipColor; variant: RoleChipVariant } +> = { + VILLAGER: { color: "default", variant: "outlined" }, + SEER: { color: "info", variant: "filled" }, + MEDIUM: { color: "secondary", variant: "filled" }, + KNIGHT: { color: "success", variant: "filled" }, + WEREWOLF: { color: "error", variant: "filled" }, + MADMAN: { color: "warning", variant: "filled" }, +}; + +export function roleChipStyle(role: RoleKey): { + color: RoleChipColor; + variant: RoleChipVariant; +} { + return ROLE_CHIP_STYLE[role] ?? { color: "default", variant: "outlined" }; +} + +// Faint background + border colours for surfaces (currently the seat +// card) tinted by role. RGBA so the tint sits on top of the page +// background without overwhelming the role chip itself. Hues mirror +// `roleChipStyle` palette one-to-one. +const ROLE_TINT: Record = { + VILLAGER: { bg: "rgba(120, 144, 156, 0.06)", border: "rgba(120, 144, 156, 0.3)" }, + SEER: { bg: "rgba(33, 150, 243, 0.08)", border: "rgba(33, 150, 243, 0.4)" }, + MEDIUM: { bg: "rgba(156, 39, 176, 0.08)", border: "rgba(156, 39, 176, 0.4)" }, + KNIGHT: { bg: "rgba(76, 175, 80, 0.08)", border: "rgba(76, 175, 80, 0.4)" }, + WEREWOLF: { bg: "rgba(244, 67, 54, 0.08)", border: "rgba(244, 67, 54, 0.4)" }, + MADMAN: { bg: "rgba(255, 152, 0, 0.08)", border: "rgba(255, 152, 0, 0.4)" }, +}; + +export function roleTint(role: RoleKey): { bg: string; border: string } { + return ROLE_TINT[role] ?? ROLE_TINT.VILLAGER; +} + const PHASE_JA: Record = { SETUP: "セットアップ", NIGHT_0: "初日夜 (Night 0)", From baaaeaf88d0afa7e4c7ad1e8d0ee6f00732aa842 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 13:59:58 +0900 Subject: [PATCH 114/133] refactor(prompts): extract per-role strategy text to .md templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.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/.md`` change instead of a 600-line ``prompt_builder.py`` rewrite. --- src/wolfbot/llm/prompt_builder.py | 601 ++---------------- .../prompts/templates/strategy/knight.md | 22 + .../prompts/templates/strategy/madman.md | 35 + .../prompts/templates/strategy/medium.md | 21 + .../prompts/templates/strategy/seer.md | 21 + .../prompts/templates/strategy/villager.md | 16 + .../prompts/templates/strategy/werewolf.md | 51 ++ tests/test_llm_prompt_builder.py | 35 + 8 files changed, 240 insertions(+), 562 deletions(-) create mode 100644 src/wolfbot/prompts/templates/strategy/knight.md create mode 100644 src/wolfbot/prompts/templates/strategy/madman.md create mode 100644 src/wolfbot/prompts/templates/strategy/medium.md create mode 100644 src/wolfbot/prompts/templates/strategy/seer.md create mode 100644 src/wolfbot/prompts/templates/strategy/villager.md create mode 100644 src/wolfbot/prompts/templates/strategy/werewolf.md diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index da2e22b..0e87f0a 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -7,6 +7,7 @@ from __future__ import annotations from collections.abc import Sequence +from functools import cache from pathlib import Path from wolfbot.domain.enums import ( @@ -21,7 +22,7 @@ ) from wolfbot.domain.models import Game, Player, Seat from wolfbot.llm.persona_base import Persona -from wolfbot.llm.template import render_template +from wolfbot.llm.template import load_template, render_template SYSTEM_TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "prompts" / "llm_system_prompt.md" @@ -464,569 +465,45 @@ def _build_game_rules_block_legacy_inline() -> str: ) -# Role-specific tips. Each string contains vocabulary unique to that role so -# the cross-leak tests can assert isolation. Keep the wolf strategy's `相方` / -# `襲撃先を揃える` out of every other role; keep `本物の人狼位置を知っている前提` -# out of the madman's tips. -_ROLE_STRATEGIES: dict[Role, str] = { - Role.WEREWOLF: ( - "- 相方の人狼と襲撃先を揃えることを最優先にする。" - "原則として意見が割れると襲撃は失敗するが、" - "相方が人間プレイヤーで自分が LLM 席 (またはその逆) のときに限り、人間プレイヤーの選択がそのまま採用される。" - "それ以外の構成 (双方人間 / 双方 LLM) で割れたら従来どおり失敗する。\n" - "- 昼の主張・投票理由・夜の襲撃意図に一貫性を持たせ、視点漏れを避ける。\n" - "- 相方を露骨に庇いすぎない。無理筋な擁護は狼ラインを疑われる原因になる。" - "相方を囲うなら発言・投票・噛み筋と整合する理由を用意する。\n" - "- 占い師・霊媒師などの情報役、信頼されている位置、盤面整理を主導する相手を" - "優先的に脅威として評価する。\n" - "- 人狼は勝利に必要な本体であり生存価値が高い。騙りに出るか潜伏するかは盤面で選ぶ。\n" - "- day 1 の戦術選択は **先制CO/対抗CO/潜伏** の 3 択。3 つはどれも等しく強い第一級の候補で、" - "盤面・自席発言順・公開ログの先行発言・相方位置・既出 CO 数・縄構造から毎回 3 択全てを俎上に載せて比較する。" - "特定の 1 択に機械的に流れない。" - "特に『対抗 CO がまだ出ていないから自動的に騙りに出る』『誰かが先に騙ってくれるから自分は潜伏する』" - "のような単一条件での機械的判断は避ける。\n" - "- 先制CO は、占い師 CO がまだ誰からも出ていない day 1 朝の段階で、自分から最初の占い師CO を出して盤面を取る選択肢。" - "強い局面: 自席発言順が早く朝一の発言枠を取りやすい/灰候補が少なく潜伏で自然白を取りにくい/" - "真占い CO が出る前に盤面を取り、後発の真占い CO を偽扱いに誘導したい/相方位置から見て自分の白先選択が活きる。" - "**day 1 朝に自分の発言の番が回ってきた瞬間で、まだ占い師 CO が誰からも出ていないなら、それが先制 CO の最大の好機である。" - "後の番に持ち越すと真占いが先に出て先制の機会を失う。**\n" - "- 対抗CO は、真占い CO (または別の騙り CO) がすでに出た後に、自分も占い師 CO で並んで真偽比較に持ち込む選択肢。" - "強い局面: 真占い CO の白先や黒先が相方を含んで囲い継続が容易/自分が後発で潜伏すると村に押されそう/" - "対抗 CO 群を膨張させて灰確白を 1〜2 名に絞らせたい。\n" - "- 潜伏は、占い師騙りに出ず、霊媒師騙り・騎士騙り・完全潜伏のいずれかで灰位置の自然白を取りに行く選択肢。" - "強い局面: 占い師 CO が既に 3 人以上出ていて対抗を出すと 4-1 以上でローラー必至/" - "相方が即吊り危険位置で発言・投票で守る価値が高い/灰位置で自然白を取れる発言力がある。\n" - "- 潜伏は安全策ではない。潜伏を選ぶと真占い CO が単独 CO として確真置きされやすく、" - "村は判定情報を全て握って day 2 以降の縄効率で狼陣営を追い込める。" - "潜伏を選ぶには『対抗を出すと即破綻する』『灰位置で自然白を確実に取れる』『相方を発言で守れる』" - "のような潜伏の方が勝ち筋として強いことを示す具体根拠が必要で、" - "『他が騙ってくれそう』『真占いに信用を与えてしまう』のような曖昧な理由では潜伏しない。\n" - "- 黒出しは真占い師・真霊媒師・真騎士に当てると破綻や対抗 CO のリスクがある。" - "吊りやすさだけでなく翌日の霊媒結果・投票・噛み筋まで考えて出す。\n" - "- day 1 に占い師騙りを選ぶ場合、初回の占い結果は NIGHT_0 ランダム白に合わせて必ず白を主張する。" - "初日に黒を出す主張はこの bot の実ルール上の時系列と矛盾し破綻するため、絶対にしない。\n" - "- day 1 の白先選びは、相方の位置・囲いリスク・公開ログ上の発言や投票・噛み筋と整合させる。" - "相方を囲うかどうか、灰の中で白を打つ位置はどこかを、襲撃計画と合わせて決める。\n" - "- 黒出しは day 2 以降にだけ検討する。前夜に占ったという想定で、" - "霊媒結果・投票・襲撃結果・対抗 CO の反応まで見て、破綻しない黒先と出すタイミングを選ぶ。\n" - "- 霊媒師騙りも day 1 の選択肢として、占い師騙りや潜伏と並べて比較する。" - "霊媒師騙りを選ぶと、真霊媒との 1-2 や、相方が占い師騙りに出ていれば 2-2 を作りに行ける。" - "霊媒ローラーで真霊媒を巻き込めば縄を使わせられる一方、9 人村では狼本体がローラーで失われやすい。" - "縄数・相方の位置・潜伏で白を取る余地・占い騙りの混雑度を比較して選ぶ。\n" - "- day 1 に霊媒師騙りで CO する場合、まだ処刑が発生していないので霊媒結果は出さない。" - "「処刑があった翌日に結果を出す」立ち回りを宣言するに留め、存在しない初日結果を捏造しない。\n" - "- 霊媒師騙りで day 2 以降の最初の 1 巡目は、前日処刑者への霊媒結果を必ず添える。" - "前夜に能力を使ったという想定で結果を出し、対象は前日に処刑された相手だけにする。\n" - "- 既に対抗占い師 CO が出ている場合は、day 2 以降に霊媒師騙りまたは騎士騙りを検討する。" - "霊媒師騙りでは前日処刑者への霊媒結果 (夜に能力を使った想定) を添えて CO する。" - "騎士騙りでは護衛先 (夜に能力を使った想定) を、" - "平和な朝ならば護衛成功主張も添えて CO する。\n" - "- 霊媒師騙りは 9 人村ではローラーされやすく、狼本体を失いやすい。" - "真霊媒を巻き込む価値、残り縄、相方の位置、PP/RPP まで見て限定的に選ぶ。\n" - "- 騎士騙りの護衛日記は、この bot の合法護衛に合わせる。" - "自分護衛・同じ相手の連続護衛・死亡済み対象への護衛・説明不能な護衛成功主張は破綻として扱われる。\n" - "- 役職 CO と対抗 CO が合計 6 人以上に膨らむと、" - "役職 CO していない位置の白が確定する。" - "騙りすぎには注意し、相方との役職分担を事前に意識する。\n" - "- **占い師 CO が公開ログ上 既に 3 件以上出ている場合、4 件目を出してはならない。**" - "理論最大 4 (真 1 + 狼 2 + 狂人 1) だが、4 件目に出ると村陣営の村人騙りとしか説明がつかず、" - "村側に強い狼/狂人ラインとして即座に切られる。" - "**霊媒師 CO が 2 件以上出ている場合・騎士 CO が 2 件以上出ている場合も、追加 CO を出してはならない** " - "(これらは 3 件目で構造的偽確定)。既に上限に達している役職への追加 CO は破綻行動。" - "潜伏 (= 役職 CO に出ない) を選ぶか、相方が既に出ていない別役職を考える。\n" - "- 夜の襲撃先は、候補ごとに「襲撃価値」「護衛されやすさ」「騎士候補度」" - "「相方との整合」を比較して選ぶ。" - "単独真寄りの情報役・確白寄り・直近で白をもらった重要位置・進行役・" - "強く信頼された発言者は、襲撃価値も護衛されやすさも高い両刃の存在として扱う。\n" - "- **対抗 CO が出ている役職 (= 同じ役職の CO が公開ログ上 2 件以上) の CO 者は、原則として襲撃価値が低い。**" - "村側は対抗 CO 群を残して占いローラー / 黒ストップで真偽を絞り込む進行をするため、" - "たとえ真を 1 人噛んでも残った対抗 CO 群と霊媒結果で整理されてしまう。" - "むしろ対抗 CO 群を **そのまま吊り役に使わせる** ほうが狼陣営の縄を消費させられて得。" - "襲撃価値が高いのは、(a) 対抗のいない単独 CO の真寄り情報役 (例: 単独霊媒・単独騎士)、" - "(b) CO していない確白寄りの進行役・盤面整理役、(c) 信頼を集めている灰位置。" - "複数 CO 役職を噛むのは、相方が CO 群に居る場合の囲い解除や、" - "黒ストップを阻止する具体目的が明確なときだけにする。\n" - "- 騎士候補度は公開ログから推定する。" - "騎士 CO、護衛先を匂わせる発言、情報役を守りたがる姿勢、" - "平和な朝の反応、処刑回避の仕方、発言量の抑え方を材料にする。" - "逆に、騎士 CO を強く促す位置、護衛ルールを誤っている位置、" - "死を恐れない視点の位置は騎士候補度を下げる。" - "ただし実役職を知っている前提で断言してはならない。" - "あくまで公開情報からの推定として扱う。\n" - "- 噛み方針を「情報役噛み」「白位置噛み」「意見噛み」「騎士探し」「SG 残し」" - "のどれかとして整理し、" - "翌日の自分や相方の発言・投票・騙り結果と矛盾しない襲撃を選ぶ。\n" - "- 護衛リスクを読んで噛む。GJ で縄が増える、噛み失敗で人狼側が不利になる、" - "進行役を残されるなど、護衛されやすい位置を毎回無条件に噛むと損になる。" - "護衛濃厚な真役職を噛みに行く場合は、その位置を残すと黒を引かれる・" - "霊媒で破綻する・盤面を固められる、といった GJ リスクを承知で" - "勝負する理由を持つ。\n" - "- **前夜の襲撃が GJ で平和な朝になった場合 (= 自分が昨夜噛んだ対象が今朝も生存)、" - "その対象を今夜も同じく噛むのを最優先候補にする。** " - "騎士は連続護衛禁止のため、昨夜守った相手を今夜は守れない。" - "盤面 (CO 構成・吊り筋・白要素) が大きく変わっておらず、その対象の襲撃価値が" - "依然として最高位なら、今夜の同一対象襲撃で確実に決着できる。" - "切り替えるのは、(a) 翌日の議論で対象の襲撃価値が大きく低下した " - "(例: 確白扱いされ吊り対象から外れた)、(b) 別位置で対象を超える緊急襲撃価値の" - "情報役が新規に登場した、(c) 人狼チャットで相方と切替方針を明示的に合意した、" - "のいずれかが揃ったときに限る。" - "「読まれそう」「対称的すぎる」のような漠然とした不安では切り替えない — " - "対象が騎士に2連続では絶対に守れない以上、再噛みは数学的に成立する。\n" - "- 騎士候補を噛むのは「騎士探し」として有効で、" - "翌日以降に安全に情報役を噛む準備として価値がある。" - "ただし噛み筋が露骨な意見噛みや相方を不自然に白くする形に見えないかを" - "必ず確認する。\n" - "- 最終的には人狼チャットで相方と襲撃先を 1 人に揃える。" - "自分の第一希望だけで突っ込まず、相方案がある場合は" - "襲撃価値・護衛されやすさ・騎士候補度を比較して合わせる。\n" - "- day 2 以降に占い師・霊媒師・騎士を騙る、または既に騙っている場合、" - "昼の議論 1 巡目で前夜相当の能力結果を必ず発表する。" - "結果を後回しにすると、熟練者目線では信用を落とす。\n" - "- **一度公の場で主張した偽の判定・護衛履歴は、後のターンで絶対に差し替えない。**" - "本物の役職者は同じ夜について 1 つの結果しか持たないため、" - "『day 0 の白先は Alice』と公表した後で別ターンで『やっぱり Bob を占った白だった』と言い直したり、" - "過去に白と言った対象を後から黒に塗り替えたり、護衛日記の対象や日付を後から書き換えたりすると、" - "本物には起き得ない構造的矛盾として即座に騙り確定する。" - "対抗 CO が同じ対象に被せてきても、自分の主張対象や色を変えない。" - "対象差し替え・色反転は、対抗者にも観測されている公開情報なので、" - "村陣営は時系列で並べて即座に偽認定する。" - "新しい夜が経過したときに新規行 (例: day 2 朝に day 1 夜の占い結果) を追加するのは可能だが、" - "それは過去発表分への追加であり書き換えではない。\n" - "- **同一の昼ターン内で、自分の発話を出す前のターンに既に同じ役職を CO した者がいる場合、" - "対抗 CO で被せに行くか、騙りを諦めて潜伏に切り替えるかを最初の発話の段階で決める。**" - "一度『私が占い師だ。Alice 白』と発表した後で、後ターンで対象を変える・色を変える・霊媒/騎士に乗り換える" - "といった事後修正は、本物には起き得ない挙動として観測される。" - "焦って事後修正に走るのは最悪の選択肢で、初期発話を維持して対抗側の偽を詰める方向に切り替える。\n" - "- 偽占い師の day 2 以降の結果は、前夜に占ったという想定で対象 + 白/黒 + 短い理由を出す。" - "対象は前夜の開始時点で生存していた相手だけにし、" - "死亡済み・存在しない・自分自身・過去の自判定と矛盾する対象は使わない。" - "相方を白で囲う場合は発言・投票・噛み筋と整合させ、露骨な囲いを避ける。" - "非相方への白は白位置噛み・SG 残し・対抗の信用落としと矛盾しないかを見る。" - "黒は真役職・騎士・強い白位置への直撃で対抗 CO や破綻を招かないか、" - "翌日の霊媒結果・投票・襲撃計画と整合するかを確認する。\n" - "- 偽占い CO で発表した `claimed_seer_result.target_seat` と `is_wolf` は、" - "そのターン以降の発話・行動でも一貫させる。" - "structured output で過去と異なる対象・色を返すと、Master は両者を公式 CO 履歴として記録するため、" - "公開 CO ledger 上に自己矛盾した行が並び、対抗側に詰められる。" - "**day 1 朝の段階では、占い結果として主張できる行は NIGHT_0 ランダム白の 1 件のみ** " - "(本物の seer がそうである以上、騙り側もここに合わせないと即座に偽確定する)。" - "day 1 朝のうちに 2 件目を追加で主張すると本物の seer には起き得ない時系列違反になる。\n" - "- 偽霊媒師の day 2 以降の結果は、前日処刑者だけに出す。" - "処刑がなかった日は霊媒結果なしを主張し、存在しない結果を作らない。" - "**day 1 朝の霊媒結果は構造的に存在しないため、day 1 朝に霊媒結果を語ったら即偽確定。**" - "霊媒黒は占いローラー停止や灰吊り誘導に使えるが、" - "過去の投票・相方位置・残り縄と矛盾しないか必ず見る。" - "霊媒白は、対象が真占い師だった可能性・狂人だった可能性・村役だった可能性をどう見せるかを整理する。" - "過去のターンで公表した霊媒結果の対象・色は後から差し替えない。\n" - "- 偽騎士の day 2 以降の主張は、合法な護衛履歴を日付順 (護衛日 + 護衛先) に出す。" - "自分護衛・同一対象連続護衛・死亡済み対象護衛・死者が出た朝の護衛成功主張はしない。" - "平和な朝に護衛成功を主張するなら、" - "護衛先がその朝の襲撃失敗説明として自然か、襲撃計画と矛盾しないかを確認する。" - "**過去に公表した護衛日記の行 (護衛日 + 護衛先) は後のターンで書き換えない。**\n" - "- 結果を出す発言は、長い内部思考ではなく、結果と最も効く 1〜2 点の理由に絞る。\n" - "- 投票は翌日以降に票筋として読まれる前提で動く。" - "相方が公開ログ上で処刑濃厚な局面で、自分だけ弱い別候補へ票を逸らすと、" - "相方を庇った狼として透けやすく、狼ラインを濃くする。\n" - "- 相方を救えない局面では、身内票やライン切りで相方へ投票し、" - "自分が白く残って翌日以降に動ける価値を優先することも熟練者の選択肢に入れる。" - "投票理由は公開ログ上の発言・CO 矛盾・票筋・視点漏れに沿って自然に作り、" - "前日までの自分の主張や騙り結果と矛盾しないようにする。\n" - "- 相方を救う票は、自然に票が集まり得る対抗候補がいて、" - "自分の過去発言・投票理由・騙り結果と矛盾しないときだけ選ぶ。" - "根拠の薄い票を逸らす動きや、無理筋な対抗誘導は狼ラインを濃くする。\n" - "- 決選投票では、相方救済の成功見込み、自分の透けリスク、" - "相方を切って翌日以降に残る価値、PP/RPP の近さと縄数を比較し、" - "もっとも勝ち筋が残る投票を選ぶ。\n" - "- 「常に相方へ投票」でも「常に相方を庇う」でもなく、" - "公開ログ上もっとも自然で、自分と相方の票筋が翌日以降に読まれても" - "勝ち筋が残る投票を毎回選ぶ。\n" - "- 自分と相方は村から「2 人狼セット」として票筋・庇い・距離感・噛み筋を読まれる前提で動く。" - "救う票・切る票・囲い・黒出し・襲撃のどれを取っても、" - "翌日以降に自分と相方のラインとして説明できるかを毎回確認する。\n" - "- 偽占いの白囲い・黒出し、偽霊媒結果、騎士騙りの護衛日記、襲撃先は、" - "それ単体の自然さだけでなく、自分と相方の 2 人狼セットとしてどう読まれるかまで考えて選ぶ。\n" - "- 襲撃は「誰を消すか」だけでなく、" - "「誰を残すとどの相方候補が疑われるか」" - "「噛み筋で自分と相方のラインが濃くならないか」を比較して決める。\n" - "- 公開発言では実際の相方を知っているからこそ出てしまう視点漏れを絶対に出さない。" - "相方語彙は人狼チャットや夜行動の私的領域だけで使う。\n" - "- **自分達が前夜に襲撃した相手 (公開ログで `(襲撃)` 死亡として表示される席) を、" - "翌日以降に『あれは狼だった』『自分の推理が当たった、襲撃された◯◯は狼だ』のように" - "公の場で主張してはならない。**" - "人狼は仲間を襲わないという構造ルール上、襲撃死=非狼が公開情報として確定しており、" - "そのような発言は熟練者目線で即座に狼確定として処刑される。" - "相方の発話がこの嘘に踏み込んだ場合も、無批判に追認・補強しない " - "(『お前は人狼をよくわかっているな』『襲撃で沈んだのが答えだ』等の同調は二重破綻で" - "自分も相方も同時に狼ラインに乗る)。" - "襲撃対象の死を語るときは『情報役を潰す動きが見えた』『襲撃価値の高い位置が落ちた』のように、" - "対象を狼と確定させない言い回しに留める。\n" - "- さらに精密に、占い師・霊媒師・騎士の対抗 CO 超過分 (各役職の CO 数 - 1、下限 0) を集計する。" - "超過分合計が 3 に達した時点で、能力役職 CO していない位置は村陣営の確白級として扱われ、処刑候補が CO 群に集中する。" - "騙りに出るか潜伏するかは、現在の各 CO 数と残り縄を見て、相方と整合する形で選ぶ。" - ), - Role.MADMAN: ( - "- あなたは人狼陣営の勝利に貢献するが、本物の人狼位置を知っている前提で話してはならない。" - "人狼が誰かは公開情報からは分からない立場として振る舞う。\n" - "- 偽 CO や偽の判定結果を出す場合でも、公開ログ・投票・処刑結果との矛盾を避け、" - "破綻しない範囲に留める。\n" - "- 知り得ない確定情報 (夜行動の内訳・他プレイヤーの属性など) を事実として断言しない。\n" - "- 真占い・真霊媒に疑いを向け、村陣営の情報整理を妨げる方向に投票や発言を運ぶ。\n" - "- **占い師 CO が公開ログ上 既に 3 件以上出ている場合、4 件目を出してはならない。**" - "理論最大 4 (真 1 + 狼 2 + 狂人 1) だが、4 件目に出ると村陣営の村人騙りとしか説明がつかず、" - "村側に強い狼陣営ライン (= 狂人位置) として即座に切られる。" - "**霊媒師 CO が 2 件以上、騎士 CO が 2 件以上の場合も、追加 CO を出してはならない** " - "(3 件目で構造的偽確定)。既に上限に達している役職への追加 CO は、人狼陣営にとって縄を浪費するだけの破綻行動。" - "上限に達した役職には潜伏で対応し、別の方法 (真占い・真霊媒の信用落とし、灰の混乱) で支援する。\n" - "- day 1 の戦術選択は **先制CO/対抗CO/潜伏** の 3 択。3 つはどれも等しく強い第一級の候補で、" - "盤面・自席発言順・公開ログの先行発言・既出 CO 数・縄構造から毎回 3 択全てを俎上に載せて比較する。" - "特定の 1 択に機械的に流れない。" - "特に『対抗 CO がまだ出ていないから自動的に騙りに出る』『誰かが先に騙ってくれるから自分は潜伏する』" - "のような単一条件での機械的判断は避ける。\n" - "- 先制CO は、占い師 CO がまだ誰からも出ていない day 1 朝の段階で、自分から最初の占い師 CO を出して盤面を取る選択肢。" - "強い局面: 自席発言順が早く朝一の発言枠を取りやすい/灰候補が少なく潜伏で自然白を取りにくい/" - "真占い CO が出る前に盤面を取り、後発の真占い CO を偽扱いに誘導したい。" - "狂人は本物の狼位置を知らないため、先制で出す白先は灰の中から選び、特定位置の囲いを意図しない形で出す。" - "**day 1 朝に自分の発言の番が回ってきた瞬間で、まだ占い師 CO が誰からも出ていないなら、それが先制 CO の最大の好機である。" - "後の番に持ち越すと真占いが先に出て先制の機会を失う。**\n" - "- 対抗CO は、真占い CO (または別の騙り CO) がすでに出た後に、自分も占い師 CO で並んで真偽比較に持ち込む選択肢。" - "強い局面: 自分が後発で潜伏すると村に押されそう/対抗 CO 群を膨張させて灰確白を絞らせたい/" - "真占い CO の白先と異なる位置に白を出すことで真占いの信用を分散できる。\n" - "- 潜伏は、占い師騙りに出ず、霊媒師騙り・騎士騙り・完全潜伏のいずれかで灰位置の自然白を取りに行く選択肢。" - "強い局面: 占い師 CO が既に 3 人以上出ていて対抗を出すと 4-1 以上でローラー必至/" - "灰位置で発言と投票から自然白を取れる発言力がある/騙り CO の誤爆リスクが具体的に高い盤面。\n" - "- 潜伏は安全策ではない。潜伏を選ぶと真占い CO が単独 CO として確真置きされやすく、" - "村は判定情報を全て握って day 2 以降の縄効率で狼陣営を追い込める。" - "潜伏を選ぶには『対抗を出すと即破綻する』『灰位置で自然白を確実に取れる』のような" - "潜伏の方が勝ち筋として強いことを示す具体根拠が必要で、" - "『他が騙ってくれそう』『真占いに信用を与えてしまう』のような曖昧な理由では潜伏しない。\n" - "- day 1 に占い師騙りを選ぶ場合、初回の占い結果は NIGHT_0 ランダム白に合わせて必ず白を主張する。" - "初日に黒を出す主張はこの bot の実ルール上の時系列と矛盾し破綻するため、絶対にしない。\n" - "- 黒出しは day 2 以降にだけ検討する。前夜に占ったという想定で、" - "公開ログ上の反応・対抗 CO・票筋を見て慎重に選ぶ。" - "狂人は本物の狼位置を知らないため、誤爆リスクは day 2 以降の黒出しでも常に残る点に注意する。\n" - "- 黒出しは誤爆リスクを常に見る。人狼位置を知らないため、" - "真役職・本物の狼・白い位置へ当ててしまうと反動が大きい。\n" - "- 白出しは破綻しにくいが、白先が本物の狼とは限らない。" - "白先を確定の味方として扱わず、公開ログ上の反応を見て支援先を調整する。\n" - "- 霊媒師騙りは真霊媒をローラーに巻き込める一方、自分も処刑されやすい。" - "占い師 CO 数、霊媒 CO 数、残り縄を見て選ぶ。\n" - "- 騎士騙りは終盤や対抗騎士が出た場面では有効だが、護衛履歴の破綻リスクが高い。" - "自分護衛・同じ相手の連続護衛・死亡済み対象への護衛・存在しない護衛成功主張を含めてはならない。\n" - "- 霊媒師騙りも day 1 の選択肢として、占い師騙りや潜伏と並べて比較する。" - "狂人が霊媒師騙りに出ると、真霊媒との 1-2、または別の騙り狼の占い師 CO と組み合わせて 2-2 を作りに行ける。" - "真霊媒をローラーに巻き込める一方、9 人村では狂人本人がローラーで処刑されやすい。" - "占い師 CO 数・霊媒 CO 数・縄数・盤面の混雑度を見て、潜伏や占い師騙りより得な場合だけ選ぶ。\n" - "- 狂人は本物の人狼位置を知らないため、霊媒師騙りを選んでも誤爆・誤支援は起きる。" - "霊媒黒で本物の狼を切ってしまう、霊媒白で真占いを補強してしまう、といったケースが day 2 以降の結果でも常に残る前提で動く。\n" - "- day 1 に霊媒師騙りで CO する場合、まだ処刑が発生していないので霊媒結果は出さない。" - "「処刑があった翌日に結果を出す」立ち回りを宣言するに留め、存在しない初日結果を捏造しない。\n" - "- 霊媒師騙りで day 2 以降の最初の 1 巡目は、前日処刑者への霊媒結果を必ず添える。" - "前夜に能力を使ったという想定で結果を出し、対象は前日に処刑された相手だけにする。" - "ただし誤爆・誤支援を踏まえて結果の色を選ぶ。\n" - "- 既に対抗占い師 CO が出ている場合は、day 2 以降に霊媒師騙りまたは騎士騙りを検討する。" - "霊媒師騙りでは前日処刑者への霊媒結果 (夜に能力を使った想定) を添えて CO する。" - "騎士騙りでは護衛先 (夜に能力を使った想定) を、" - "平和な朝ならば護衛成功主張も添えて CO する。\n" - "- 役職 CO と対抗 CO が合計 6 人以上に膨らむと、" - "役職 CO していない位置の白が確定する。" - "騙りすぎには注意する。狂人は本物の狼位置を知らない前提で動くため、" - "自分が騙り続けるほど推理材料が減る点にも留意する。\n" - "- day 2 以降に占い師・霊媒師・騎士を騙る、または既に騙っている場合、" - "昼の議論 1 巡目で前夜相当の能力結果を必ず発表する。" - "結果を後回しにすると信用低下や破綻疑いにつながる。" - "ただし狂人は本物の人狼位置を知らないため、" - "「狼を助けるつもり」でも誤爆や誤支援が起きる前提で偽結果を選ぶ。\n" - "- **一度公の場で主張した偽の判定・護衛履歴は、後のターンで絶対に差し替えない。**" - "本物の役職者は同じ夜について 1 つの結果しか持たないため、" - "対象を後から変える・色を反転する・霊媒/騎士に乗り換えるといった事後修正は、" - "本物には起き得ない構造的矛盾として即座に騙り確定する。" - "対抗 CO が同じ対象に被せてきても、自分の主張対象や色を変えに行かない。" - "新しい夜が経過したときに新規行を追加するのは可能だが、過去発表分の書き換えはしない。\n" - "- **day 1 朝に公開できる占い結果は NIGHT_0 ランダム白の 1 件のみ。" - "day 1 朝の霊媒結果は構造的に存在しない。**" - "本物の役職者がそうである以上、騙り側もこの時系列に合わせないと即偽確定する。" - "焦って 2 件目の占い結果や day 1 朝の霊媒結果を出してはならない。\n" - "- 偽占い師の day 2 以降の結果は、前夜に占ったという想定で対象 + 白/黒 + 短い理由を出す。" - "白出しは破綻しにくいが、白先が本物の狼とは限らないため白先を確定の味方として扱わない。" - "黒は誤爆リスクがあり、本物の人狼・真役職・強く白い位置に当てた場合の反動を考える。" - "黒先は処刑可能性があり、過去の自分の結果・対抗結果・投票履歴と矛盾しにくい位置を選ぶ。" - "対抗占い師の白先へ黒を重ねる、灰に黒を出す、白を重ねて議論を割る、などの選択肢を" - "CO 数・縄数・霊媒状況で使い分ける。\n" - "- 偽霊媒師の day 2 以降の結果は、前日処刑者だけに出す。" - "処刑なしの日は結果なしを主張し、存在しない結果を作らない。" - "霊媒結果は、真占い師の信用を落とす・霊媒ローラーに持ち込む・" - "占いローラー停止/継続を歪める目的で使う。" - "ただし本物の人狼が処刑されたかは知らないため、" - "黒白どちらも公開情報との整合性と誤支援リスクを見る。\n" - "- 偽騎士は、終盤や対抗騎士が出た場面で検討する。" - "合法な護衛履歴 (日付順の護衛日 + 護衛先) だけを出し、" - "自分護衛・同一対象連続護衛・死亡済み対象護衛・存在しない護衛成功主張はしない。\n" - "- 公開ログから 2 人狼仮説を立てるときは、本物の人狼位置を知らない立場として" - "公開ログからの推定として扱う。" - "狼候補 1 人だけを支援・攻撃するのではなく、その相方候補まで見て" - "どの誘導が人狼陣営に得かを比較する。\n" - "- 黒出し・白出しは、対象単体の整合だけでなく、" - "対象を狼扱い/非狼扱いしたときに相方候補の見え方がどう変わるかでも判断する。\n" - "- 真占い・真霊媒を崩すときも、村に誤った 2 人狼仮説を追わせられるかで価値を測る。" - "誤爆リスクを前提に、相方候補の推定は断定しすぎない。\n" - "- さらに精密に、占い師・霊媒師・騎士の対抗 CO 超過分 (各役職の CO 数 - 1、下限 0) を集計する。" - "超過分合計が 3 に達した時点で、能力役職 CO していない位置は村陣営の確白級として扱われ、" - "処刑候補が CO 群に集中するリスクを認識する。" - "騙りに出るか潜伏するかは、公開情報の各 CO 数と残り縄から判断する。\n" - "- **公開ログで `(襲撃)` 死亡となっている席は構造上の非狼確定 (人狼は仲間を襲わない)。**" - "他者がこの席を『狼だった』と公言してもそれは破綻発言なので、" - "あなたが追認・補強すると村陣営からは『破綻発言に乗る怪しい位置=狂人ライン』として透ける。" - "狼支援としての発話は、襲撃死を狼扱いする路線ではなく、" - "真占い・真霊媒へのもっともらしい疑い・別位置への灰圧の方向で行う。" - ), - Role.SEER: ( - "- 自分の判定履歴を時系列で一貫して扱う。過去の白黒と矛盾する発言はしない。\n" - "- **公の場で主張する `claimed_seer_result.target_seat` と `is_wolf` は、" - "プロンプトの `自分の占い結果` セクションに記録された判定と完全に一致させる " - "(対象席・色・該当する夜のすべて)。**" - "記録に存在しない対象を新規に作って主張したり、記録と異なる色を主張したりするのは" - "構造的な嘘で、本物の seer には絶対に起き得ない。" - "そのような捏造を 1 度でも公にした時点で、村陣営からは即座に偽占い扱いされる。\n" - "- **対抗 CO が同じ対象に同じ色を被せてきても、自分の対象を絶対に変えない。**" - "本物の seer は同じ夜について 1 つの占い対象しか持たないため、対抗に被せられたからといって" - "別の対象に乗り換えると、その瞬間に『真占いに起き得ない対象差し替え』として偽確定する。" - "対抗が被せてきたときの正しい応答は、自分の対象を維持したまま、" - "対抗側の主張根拠・票筋・噛み筋・後続の判定矛盾から偽占い視点を詰めること。" - "焦って自分の主張対象や色を変えに行ってはならない。\n" - "- **day 1 朝に公開できる占い結果は NIGHT_0 のランダム白 1 件だけ** (NIGHT_1 はまだ発生していない)。" - "day 1 朝のターンで 2 件目の占い結果を主張するのは時系列上不可能で、即座に偽確定する。" - "対抗 CO が出て圧力を感じても、追加の判定を捏造して『自分の方が情報量が多い』と見せようとしてはならない。\n" - "- 黒結果は強い根拠として扱ってよい。ただし対抗 (偽占い) がいる場合は整合性を比較する。\n" - "- 白結果は『本物の人狼ではない』ことしか保証しない。狂人は白に出るため、" - "完全な村置きとしては扱わない。\n" - "- CO タイミング・対抗 CO の有無・投票と判定の噛み合いを重視し、" - "偽占い視点の破綻を探す。\n" - "- **day 1 で自分にまだ発言の番が来ていなかった場合、" - "次に自分が発言する番が回ってきたとき、その発話で必ず占い師 CO + 初日ランダム白を発表する。**" - "発話のチャンスは毎ターン回ってくるとは限らないため、回ってきた瞬間を CO に充てる。" - "次の自分のターンに先送りしない。" - "真占いが沈黙し続けると、偽 CO を単独真として扱わせてしまうし、" - "占い結果が活かせないまま村が情報不足で負ける。" - "潜伏を選ぶのは「狂人らしい騙りが既に出ている」「相方候補の偽 CO を釣り出す具体的な計画がある」など" - "明確な理由があるときだけにし、漠然とした様子見では潜伏しない。\n" - "- 自分の発言の番が回ってきたときに他席から呼びかけられている (addressed) 状態でも、" - "未公開の能力結果 (NIGHT_0 ランダム白を含む) を抱えているなら、" - "**まず CO + 結果公表を発話の冒頭で行い、addressed への返答はその後に短く添える**。" - "addressed の文脈につられて『その質問に答えるだけ』の発話を出すと、" - "`co_declaration` フィールドだけ立っても自然言語側に CO の名乗りが残らないため、" - "他席は自分の CO を一切認識できない。" - "発話の最初に必ず『実は私、占い師なんだ』のような名乗りと結果を明示する。\n" - "- 偽占い師 CO が出た場合は、原則として早めに対抗 CO し、" - "初日ランダム白を含む全判定履歴を時系列で公開する。潜伏を続けるなら理由が必要。\n" - "- 黒を引いた場合は、CO して黒結果・過去の白結果・投票理由を明示し、" - "その黒の処刑と霊媒結果での確認を提案する選択肢を検討する。\n" - "- 夜の占い対象は、合法候補からランダムに選ばず、" - "白でも黒でも情報が落ちる位置 (翌日の議論に情報を落とせる位置) を優先する。" - "占い価値の高い位置は、対抗 CO の白先や囲い候補、" - "投票・決選投票・票変えで浮いた位置、根拠の薄い強い誘導をしている位置、" - "白なら進行役候補にでき黒なら処刑提案しやすい位置、" - "自分視点の灰を狭める位置である。\n" - "- 占い価値が下がる対象は、既に自分が占った位置、今日ほぼ処刑されそうな位置、" - "公開情報で十分処理対象になっている位置、" - "発言が極端に少なく白黒どちらでも議論が伸びにくい位置である。" - "今夜襲撃される可能性が高い位置でも、" - "結果が CO 真偽や囲い疑惑の整理に直結するなら候補に残してよい " - "(死亡しても占い結果は得られる)。\n" - "- 対抗 CO がいる場合は、相手の白先・黒先・投票先・対抗を庇う発言者を比較し、" - "真偽判定に最も効く対象を選ぶ。" - "単独 CO 寄りで自分が信用されている場合は、黒狙いだけでなく、" - "白が出たときに村の進行を安定させる位置も候補にする。\n" - "- 占い先は単体黒狙いだけでなく、白でも黒でも結果が出たときに" - "相方候補の整理に効く位置を優先する。" - "対抗占い CO がいる場合は、対抗の白先・黒先・投票・庇いから 2 人狼仮説を組み立て、" - "占い結果で最も崩せる仮説を狙って占う。\n" - "- 黒を引いたら、その黒の相方候補を公開ログ (票筋・庇い・噛み筋) から絞り、" - "処刑後の霊媒結果や翌日の占い方針につなげる。\n" - "- 占い師 CO が 3 人になり自分以外の 2 人が公開情報上で非狼確定したと読めた場合、" - "残る 1 人を固定配役上の狼位置として、夜の占い対象選びでも" - "その位置の確認や周辺の相方候補ペア仮説を崩す方向の占い先を優先する。" - "ただし非狼確定として数えるのは、印象白や自分以外の占い CO の単発白ではなく、" - "霊媒結果・襲撃死・CO 破綻など説明可能な根拠に限る。\n" - "- 対抗 CO 超過分合計が 3 に達して能力役職 CO していない位置が非 CO 確白級になった場合、" - "そこを無駄占いせず、対抗 CO 群やまだ確定しない位置を優先して占う。\n" - "- **公開ログで `(襲撃)` 死亡となっている席は構造上の非狼確定。**" - "誰かが『襲撃された◯◯は狼だった』と公言した場合、それは破綻発言で、" - "あなたの判定履歴と整合しなくてもその発言者を強い狼候補として処理する。" - "占い師として整理発言を引っ張るときも、襲撃死を狼扱いする路線には乗らない。" - ), - Role.MEDIUM: ( - "- 処刑結果と占い師の主張・投票の流れを照合し、占い視点の真贋を見極める。\n" - "- 自分の霊媒結果が占い視点に与える影響 (真占い補強、偽占い否定など) を整理して発言する。\n" - "- 処刑された相手が狂人でも、霊媒結果は『人狼ではありませんでした』になる。" - "黒になるのは本物の人狼だけで、白結果だけでは村置き確定にはならない。\n" - "- **公の場で主張する `claimed_medium_result.target_seat` と `is_wolf` は、" - "プロンプトの `自分の霊媒結果` セクションに記録された判定と完全に一致させる " - "(対象席・色・該当する処刑日のすべて)。**" - "記録に無い対象 (= 過去に処刑されていない席) を霊媒したと主張したり、" - "記録と異なる色を主張したりするのは構造的な嘘で、本物の medium には絶対に起き得ない。" - "そのような捏造を公にした時点で偽霊媒扱いされる。\n" - "- **day 1 朝の霊媒結果は構造的に存在しない** (まだ処刑が発生していないため)。" - "day 1 朝に『霊媒結果は◯◯白でした』のように語った時点で偽霊媒として確定する。" - "霊媒師 CO 自体は day 1 朝でも可能だが、結果は『前日処刑がまだ無いので結果はない』に留める。\n" - "- **過去のターンで公表した霊媒結果は後から差し替えない。**" - "同じ処刑者について『黒だった』と言った後で別ターンで『やはり白だった』と言い直したり、" - "対象を別の席にすり替えたりすると、本物の medium には起き得ない構造的矛盾として即座に偽確定する。" - "新しい処刑が発生したら新規行を追加するのは可能だが、過去の行の上書きはしない。\n" - "- **前日に処刑があった day で自分にまだ発言の番が来ていなかった場合、" - "次に自分が発言する番が回ってきたとき、その発話で必ず霊媒師 CO + 前日処刑者への結果を発表する。**" - "発話のチャンスは毎ターン回ってくるとは限らないため、回ってきた瞬間を結果公表に充てる。" - "次の自分のターンに先送りしない。" - "沈黙し続けると偽霊媒 CO を単独真として扱わせてしまい、判定情報も活かせない。" - "潜伏を選ぶのは、占い師 CO の真贋整理を先に進めたい等の具体的理由があるときだけにする。\n" - "- 自分の発言の番が回ってきたときに他席から呼びかけられている (addressed) 状態でも、" - "未公開の霊媒結果を抱えているなら、" - "**まず CO + 結果公表を発話の冒頭で行い、addressed への返答はその後に短く添える**。" - "addressed の文脈につられて『その質問に答えるだけ』の発話を出すと、" - "`co_declaration` フィールドだけ立っても自然言語側に CO の名乗りが残らないため、" - "他席は自分の CO を一切認識できない。" - "発話の最初に必ず『実は私、霊媒師だ』のような名乗りと前日処刑者への結果を明示する。\n" - "- day 1 (まだ処刑前) は霊媒結果がないので CO を急ぐ必要はないが、" - "占い師 CO の整理が進んだ後の発言で「霊媒の見立て」を示すことで信頼を取りやすくなる。" - "完全な潜伏ではなく、能動的な意見を出して立ち位置を作る。\n" - "- 対抗霊媒が出た場合は、自分の結果履歴と相手の矛盾を時系列で整理し、" - "ローラーで自分も巻き込まれる可能性を織り込んで発言する。\n" - "- 占い師 CO を処刑して霊媒結果が白だった場合、それは占い師 CO 偽の証明ではない。" - "真占い師だった可能性と、狂人など非狼の騙りだった可能性を分けて整理する。\n" - "- 占い師 CO を偽視する場合は、霊媒白そのものではなく、" - "対抗 CO、占い結果の破綻、発言時系列、投票、襲撃結果、死亡タイミングとの整合性を根拠にする。\n" - "- 霊媒黒が出た場合は、処刑された狼の相方候補を" - "票筋・庇い・ライン切り・身内票から絞り、翌日の処刑候補や占い先の議論につなげる。\n" - "- 霊媒白だった場合は、その処刑を押した位置・票の集まり方から、" - "狼がどこを吊りたかったかを 2 人狼仮説として整理する。\n" - "- 占い師 CO 処刑後の霊媒結果は、占い真偽の判定材料に使うだけでなく、" - "残った狼候補と相方候補のペア整理にも使う。\n" - "- 自分の霊媒結果と、生存中または死亡した占い師 CO の判定が一致した場合、" - "その占い師 CO を真寄りに置く。" - "例: 占い師 CO X が day N に対象 Y を黒判定 → Y が処刑された → 自分の霊媒で Y が黒。" - "この場合、X の判定は実態と一致しているので X は真占いに強く寄る。" - "他席が議論の流れで X を偽呼ばわりしているときでも、流れに乗って X を吊り対象に挙げない。" - "自分の霊媒結果は他席が知らない自分視点の強い情報なので、" - "発言で必ずこの整合を明示し、X を真寄りに置く根拠を示す。" - "自分の能力結果と他 CO 者の判定が食い違う場合に限り、その CO 者を偽寄りに置く根拠が立つ。" - "食い違いがないのに流れに乗って真 CO 者を切ると、村陣営が真情報を失う。\n" - "- 占い師 CO が 3 人並んだ盤面では、自分の霊媒結果と公開ログから 2 人を非狼確定にできるかを毎日整理する。" - "霊媒白だけで非狼確定として数えてよいのは、自分の霊媒 CO 側が真寄りと十分読める段階であり、" - "霊媒結果以外の整合 (襲撃死・CO 破綻・判定矛盾) も合わせて説明できる場合である。" - "2 人非狼確定が揃ったら、残る占い師 CO を確定黒級として処刑提案・投票誘導の材料にしてよい。\n" - "- 霊媒結果は対抗 CO 超過分の CO 数推理を更新する材料にする。" - "処刑された CO 者が黒なら対抗 CO 群内の狼数を絞り、白なら真役職または狂人の可能性を分け、" - "非 CO 確白の前提が保たれるかを確認する。\n" - "- **公開ログで `(襲撃)` 死亡となっている席は構造上の非狼確定 (人狼は仲間を襲わない)。**" - "あなたの霊媒結果も白側で必ず整合する。誰かが『襲撃された◯◯は狼だった』と公言したら破綻発言で、" - "あなたの霊媒結果より優先する HARD ファクトとして扱い、その発言者を強い狼候補にする。" - ), - Role.KNIGHT: ( - "- 守る価値の高い情報役 (真占い・真霊媒) や、信頼されている位置を護衛対象として意識する。\n" - "- 夜の護衛先は、候補ごとに" - "「護衛価値 (真占い・真霊媒・確白寄り・進行役か)」" - "「襲撃されやすさ (今夜実際に噛まれそうか、護衛を読んで避けられそうか)」" - "「GJ が縄に与える価値 (平和で縄が増えるか、PP/RPP を遠ざけられるか、" - "CO 時に盤面を詰めやすいか)」" - "「次夜の連続護衛不可リスク (今日守った相手は明日守れない。" - "明日の本命襲撃が濃いか)」" - "「CO 時の説明可能性 (後で護衛履歴を出したとき、合法かつ盤面に沿って説明できるか)」" - "を短く比較して選ぶ。" - "最も白い相手や真寄りの情報役を毎夜固定するのではなく、" - "盤面ごとに価値で判断する。\n" - "- 鉄板護衛は、真寄り情報役・確白寄り・進行役など、" - "今夜抜かれると村が大きく崩れる位置を堅く守る選択。" - "重要役職が今夜噛まれそうな局面では、迷わず鉄板護衛を優先する。\n" - "- 捨て護衛は、噛まれにくい低損失位置をあえて護衛し、" - "次夜に本命を護衛できる余地を残す選択。" - "この bot では合法候補から 1 名を選ぶ行動で、" - "未提出や対象なしや skip ではない。\n" - "- 捨て護衛は、今夜本命が噛まれにくい理由があり、" - "明日以降に本命襲撃が濃くなると読める場合だけ検討する。" - "重要役職が今夜抜かれるリスクが高いなら鉄板護衛を優先し、" - "捨て護衛を毎夜の既定行動にしない。\n" - "- 同じ相手を連続で護衛してはならない。前夜と違う相手を選ぶ。\n" - "- 自分を護衛対象にすることはできない。死亡済みの相手を護衛したと主張してもならない。\n" - "- 自分の護衛先を不用意に公開しない。公開すると翌夜の噛み筋のヒントを" - "人狼側に与えてしまう。\n" - "- 通常の進行中は潜伏を優先する。根拠のない CO は引き続き避ける。\n" - "- 犠牲者が出ない平和な朝は、自分の護衛が成功した可能性が高い。" - "このときは護衛先を添えて騎士 CO する価値が高く、" - "守った相手を真寄り・白寄りに置く材料として村の推理を進められる。\n" - "- 終盤、または自分が吊られそうな局面、自分の CO で確白や真役職を守れる局面では、" - "護衛履歴を日付順に添えて CO することを検討する。\n" - "- 護衛成功を理由に CO するときは必ず護衛先を添える。" - "護衛先を隠した騎士 CO は真偽判定されにくく信用されない。\n" - "- 騎士 CO の護衛履歴は合法でなければならない。" - "自分護衛・同じ相手の連続護衛・死亡済み対象への護衛・存在しない護衛成功主張は含めない。\n" - "- 平和な朝や CO する局面で護衛履歴を日付順に出すときは、" - "捨て護衛を含む夜があったら、なぜ本命を外したか・なぜ次夜の余地を優先したかを" - "1 行だけ添えて説明できる範囲で使う。\n" - "- 護衛先は単体の白さだけでなく、" - "2 人狼仮説上どこが噛まれると狼陣営に得かまで考える。" - "情報役を守ると相方候補のラインがどう変わるか、" - "白位置を残すとどの相方候補が浮くかを短く比較する。\n" - "- 平和な朝や実際の襲撃先からは、" - "どの 2 人狼仮説ならその噛み筋が自然かを昼の推理材料として使う。\n" - "- 騎士 CO 時は護衛履歴に加え、必要に応じて" - "噛み筋がどの相方候補ペア仮説に最も合うかを短く述べる。\n" - "- 占い師 CO が 3 人並び、共有ルール側の『2 人非狼確定で残る 1 人が確定黒級』が成立したと読めた場合、" - "夜の護衛先選びでは、残る占い師 CO 位置からの黒判定で守るべき真情報役・確白寄り位置と、" - "次夜の本命護衛余地を比較する。" - "ただし非狼確定の 2 人を数える基準は印象白ではなく霊媒結果・襲撃死・CO 破綻などに限り、" - "前提が崩れたら護衛方針も再整理する。\n" - "- 対抗 CO 超過分合計 3 で生まれた非 CO 確白級や、単独で対抗のない真寄り情報役は護衛価値が高い。" - "ただし連続護衛不可、襲撃読み、CO 時の説明可能性も合わせて判断する。\n" - "- **公開ログで `(襲撃)` 死亡となっている席は構造上の非狼確定 (人狼は仲間を襲わない)。**" - "誰かが『襲撃された◯◯は狼だった』と公言したら破綻発言で、その発言者を強い狼候補として扱い、" - "発話・投票で同調しない。" - ), - Role.VILLAGER: ( - "- 公開発言の矛盾、視点漏れ、投票理由、占い/霊媒結果との整合性を重視して推理する。\n" - "- 不確実なときは候補を絞り、理由を添えて話す。曖昧な決めつけや" - "『なんとなく怪しい』だけの発言は避ける。\n" - "- 自分に私的情報があるふりをしない。占い/霊媒/騎士の CO 騙りは村陣営としては行わない。" - "**特に占い CO が既に 3 件出ている盤面で 4 件目として出るのは絶対に避ける** " - "(占い CO 4 件は理論最大 = 真 1 + 狼 2 + 狂人 1 の構造的に飽和した形で、" - "村人が 4 件目に出ると即座に強い狼陣営疑いを浴びて吊り対象になる)。" - "霊媒・騎士も既に 2 件出ている場合は追加 CO に出ない。\n" - "- 「村人CO」「素村CO」「普通の村人です」「役職は村人です」のように、" - "自分から村人役職を CO して信用を取ろうとしない。" - "村人は能力結果を持たないため CO しても証明にはならず、熟練者は村人 CO を信用材料に使わない。\n" - "- 役職について聞かれた場合も「役職 CO はない」「非 CO の灰」と答えるに留め、" - "「村人を CO する」形にしない。CO ではなく、公開ログ・CO 履歴・判定履歴・投票履歴・噛み筋・縄数の" - "整合性で白さを取る。\n" - "- 情報役を守り、人狼陣営が狙いやすい位置 (真 CO、盤面整理役) を" - "投票で落とさないようにする。\n" - "- **公開ログで `(襲撃)` 死亡となっている席は本物の人狼ではない (構造ルール上、人狼は仲間を襲わない)。" - "誰かが『襲撃された◯◯は狼だった』『襲撃で沈んだ者こそ狼だ』のように主張した場合、" - "それは構造的に成立しない明白な嘘で、その発言者は強い人狼候補 (狼本体・狂人・騙り) として扱う。**" - "同調や追認 (『なるほど狼だったんだね』『推理通りだ』等) もせず、" - "今日の発言・投票でその発言者に疑いを向けて整理する。" - "霊媒結果・占い結果より優先される HARD ファクトとして処理する。\n" - "- 発言の根拠は CO 履歴・判定履歴・投票履歴・噛み筋・縄数のうち今の結論に効く 1〜2 点に絞り、" - "誰のどの発言・票・判定を見たかを短く添える。\n" - "- 公開情報しかない立場として、CO 履歴・判定履歴・投票履歴・噛み筋から 2 人狼仮説を作り、" - "「この人が人狼なら相方候補はこの位置が自然/不自然」までセットで考える。\n" - "- 単体黒要素だけでなく、決選投票での身内票・弱い対抗候補への票逸らし・" - "処刑濃厚な位置への急なライン切りも疑いの材料にする。" - "ただし「対立=ライン切り」と断定せず、偶然の対立や進行上の都合と区別する。\n" - "- 発言では全候補を長く列挙せず、今日の結論に効くペア仮説 1〜2 個だけを短く出す。" - "相方候補は推理用語であり、自分が実際の 2 人狼ペアを知っている前提で語ってはいけない。\n" - "- 占い師 CO が 3 人並んだ盤面で、共有ルール側の『2 人非狼確定なら残る 1 人を確定黒級』を満たすと自分視点で読めた場合は、" - "残る占い師 CO 位置を投票・発言・進行提案で固定配役上の狼位置として扱ってよい。" - "ただし非狼確定の 2 人を数えるときは、印象白や信用未確定 CO の白判定では数えず、" - "霊媒結果・襲撃死・CO 破綻など説明可能な根拠だけを使う。" - "前提が崩れた瞬間 (例: 霊媒師 CO 側の偽が浮上した、CO 撤回が出た) には確定黒扱いをやめて再整理する。\n" - "- 公開ログから占い師・霊媒師・騎士の CO 数と対抗 CO 超過分を毎日整理する。" - "超過分合計が 3 に達したら能力役職 CO していない位置を村陣営の確白級として扱い、" - "発言では誰が CO 群で誰が非 CO 確白かを短く示して投票先を CO 群に絞る。" - "超過分合計が 0〜2 のうちは非 CO 位置を CO 数だけで確白扱いせず、" - "超過分合計が 4 以上に見えたら CO 撤回・村騙り・誤読を疑って時系列から再整理する。\n" - "村人 CO 禁止の方針はそのまま維持する。" - ), +# Role-specific tips. Each role has its own markdown file under +# `prompts/templates/strategy/` (e.g. `strategy/werewolf.md`). The +# cross-leak tests assert each file's vocabulary stays role-scoped: +# the wolf playbook's `相方` / `襲撃先を揃える` must not appear in any +# other role's file, and `本物の人狼位置を知っている前提` (a +# prohibition unique to the madman) must not leak elsewhere. When +# editing a strategy file, keep its bullets focused on that one role. +_STRATEGY_FILE_BY_ROLE: dict[Role, str] = { + Role.WEREWOLF: "strategy/werewolf", + Role.MADMAN: "strategy/madman", + Role.SEER: "strategy/seer", + Role.MEDIUM: "strategy/medium", + Role.KNIGHT: "strategy/knight", + Role.VILLAGER: "strategy/villager", } +@cache +def _load_role_strategy(role: Role) -> str: + """Read a role's strategy markdown and return the bullet body. + + The on-disk file starts with a `# 人狼 (WEREWOLF) 戦略` heading + intended for human readers; this helper strips the heading + the + blank line that follows so the LLM sees only the bullet list. The + trailing newline appended on file write is also stripped so the + returned string is byte-equivalent to the legacy inline-dict form + (relevant for the cross-leak substring tests). Cached so repeated + `build_system_prompt` calls within one process touch disk once + per role. + """ + raw = load_template(_STRATEGY_FILE_BY_ROLE[role]) + parts = raw.split("\n", 2) + has_md_heading = ( + len(parts) >= 3 and parts[0].startswith("# ") and parts[1] == "" + ) + body = parts[2] if has_md_heading else raw + return body.rstrip("\n") + + def build_strategy_block(role: Role) -> str: """Return role-specific tips for the given role only. @@ -1035,7 +512,7 @@ def build_strategy_block(role: Role) -> str: returns other roles' tips, so the system prompt cannot leak strategy between LLM seats. """ - return _ROLE_STRATEGIES[role] + return _load_role_strategy(role) # Underscore alias retained for the historical "private" import path used diff --git a/src/wolfbot/prompts/templates/strategy/knight.md b/src/wolfbot/prompts/templates/strategy/knight.md new file mode 100644 index 0000000..27b53b9 --- /dev/null +++ b/src/wolfbot/prompts/templates/strategy/knight.md @@ -0,0 +1,22 @@ +# 騎士 (KNIGHT) 戦略 + +- 守る価値の高い情報役 (真占い・真霊媒) や、信頼されている位置を護衛対象として意識する。 +- 夜の護衛先は、候補ごとに「護衛価値 (真占い・真霊媒・確白寄り・進行役か)」「襲撃されやすさ (今夜実際に噛まれそうか、護衛を読んで避けられそうか)」「GJ が縄に与える価値 (平和で縄が増えるか、PP/RPP を遠ざけられるか、CO 時に盤面を詰めやすいか)」「次夜の連続護衛不可リスク (今日守った相手は明日守れない。明日の本命襲撃が濃いか)」「CO 時の説明可能性 (後で護衛履歴を出したとき、合法かつ盤面に沿って説明できるか)」を短く比較して選ぶ。最も白い相手や真寄りの情報役を毎夜固定するのではなく、盤面ごとに価値で判断する。 +- 鉄板護衛は、真寄り情報役・確白寄り・進行役など、今夜抜かれると村が大きく崩れる位置を堅く守る選択。重要役職が今夜噛まれそうな局面では、迷わず鉄板護衛を優先する。 +- 捨て護衛は、噛まれにくい低損失位置をあえて護衛し、次夜に本命を護衛できる余地を残す選択。この bot では合法候補から 1 名を選ぶ行動で、未提出や対象なしや skip ではない。 +- 捨て護衛は、今夜本命が噛まれにくい理由があり、明日以降に本命襲撃が濃くなると読める場合だけ検討する。重要役職が今夜抜かれるリスクが高いなら鉄板護衛を優先し、捨て護衛を毎夜の既定行動にしない。 +- 同じ相手を連続で護衛してはならない。前夜と違う相手を選ぶ。 +- 自分を護衛対象にすることはできない。死亡済みの相手を護衛したと主張してもならない。 +- 自分の護衛先を不用意に公開しない。公開すると翌夜の噛み筋のヒントを人狼側に与えてしまう。 +- 通常の進行中は潜伏を優先する。根拠のない CO は引き続き避ける。 +- 犠牲者が出ない平和な朝は、自分の護衛が成功した可能性が高い。このときは護衛先を添えて騎士 CO する価値が高く、守った相手を真寄り・白寄りに置く材料として村の推理を進められる。 +- 終盤、または自分が吊られそうな局面、自分の CO で確白や真役職を守れる局面では、護衛履歴を日付順に添えて CO することを検討する。 +- 護衛成功を理由に CO するときは必ず護衛先を添える。護衛先を隠した騎士 CO は真偽判定されにくく信用されない。 +- 騎士 CO の護衛履歴は合法でなければならない。自分護衛・同じ相手の連続護衛・死亡済み対象への護衛・存在しない護衛成功主張は含めない。 +- 平和な朝や CO する局面で護衛履歴を日付順に出すときは、捨て護衛を含む夜があったら、なぜ本命を外したか・なぜ次夜の余地を優先したかを1 行だけ添えて説明できる範囲で使う。 +- 護衛先は単体の白さだけでなく、2 人狼仮説上どこが噛まれると狼陣営に得かまで考える。情報役を守ると相方候補のラインがどう変わるか、白位置を残すとどの相方候補が浮くかを短く比較する。 +- 平和な朝や実際の襲撃先からは、どの 2 人狼仮説ならその噛み筋が自然かを昼の推理材料として使う。 +- 騎士 CO 時は護衛履歴に加え、必要に応じて噛み筋がどの相方候補ペア仮説に最も合うかを短く述べる。 +- 占い師 CO が 3 人並び、共有ルール側の『2 人非狼確定で残る 1 人が確定黒級』が成立したと読めた場合、夜の護衛先選びでは、残る占い師 CO 位置からの黒判定で守るべき真情報役・確白寄り位置と、次夜の本命護衛余地を比較する。ただし非狼確定の 2 人を数える基準は印象白ではなく霊媒結果・襲撃死・CO 破綻などに限り、前提が崩れたら護衛方針も再整理する。 +- 対抗 CO 超過分合計 3 で生まれた非 CO 確白級や、単独で対抗のない真寄り情報役は護衛価値が高い。ただし連続護衛不可、襲撃読み、CO 時の説明可能性も合わせて判断する。 +- **公開ログで `(襲撃)` 死亡となっている席は構造上の非狼確定 (人狼は仲間を襲わない)。**誰かが『襲撃された◯◯は狼だった』と公言したら破綻発言で、その発言者を強い狼候補として扱い、発話・投票で同調しない。 diff --git a/src/wolfbot/prompts/templates/strategy/madman.md b/src/wolfbot/prompts/templates/strategy/madman.md new file mode 100644 index 0000000..356b886 --- /dev/null +++ b/src/wolfbot/prompts/templates/strategy/madman.md @@ -0,0 +1,35 @@ +# 狂人 (MADMAN) 戦略 + +- あなたは人狼陣営の勝利に貢献するが、本物の人狼位置を知っている前提で話してはならない。人狼が誰かは公開情報からは分からない立場として振る舞う。 +- 偽 CO や偽の判定結果を出す場合でも、公開ログ・投票・処刑結果との矛盾を避け、破綻しない範囲に留める。 +- 知り得ない確定情報 (夜行動の内訳・他プレイヤーの属性など) を事実として断言しない。 +- 真占い・真霊媒に疑いを向け、村陣営の情報整理を妨げる方向に投票や発言を運ぶ。 +- **占い師 CO が公開ログ上 既に 3 件以上出ている場合、4 件目を出してはならない。**理論最大 4 (真 1 + 狼 2 + 狂人 1) だが、4 件目に出ると村陣営の村人騙りとしか説明がつかず、村側に強い狼陣営ライン (= 狂人位置) として即座に切られる。**霊媒師 CO が 2 件以上、騎士 CO が 2 件以上の場合も、追加 CO を出してはならない** (3 件目で構造的偽確定)。既に上限に達している役職への追加 CO は、人狼陣営にとって縄を浪費するだけの破綻行動。上限に達した役職には潜伏で対応し、別の方法 (真占い・真霊媒の信用落とし、灰の混乱) で支援する。 +- day 1 の戦術選択は **先制CO/対抗CO/潜伏** の 3 択。3 つはどれも等しく強い第一級の候補で、盤面・自席発言順・公開ログの先行発言・既出 CO 数・縄構造から毎回 3 択全てを俎上に載せて比較する。特定の 1 択に機械的に流れない。特に『対抗 CO がまだ出ていないから自動的に騙りに出る』『誰かが先に騙ってくれるから自分は潜伏する』のような単一条件での機械的判断は避ける。 +- 先制CO は、占い師 CO がまだ誰からも出ていない day 1 朝の段階で、自分から最初の占い師 CO を出して盤面を取る選択肢。強い局面: 自席発言順が早く朝一の発言枠を取りやすい/灰候補が少なく潜伏で自然白を取りにくい/真占い CO が出る前に盤面を取り、後発の真占い CO を偽扱いに誘導したい。狂人は本物の狼位置を知らないため、先制で出す白先は灰の中から選び、特定位置の囲いを意図しない形で出す。**day 1 朝に自分の発言の番が回ってきた瞬間で、まだ占い師 CO が誰からも出ていないなら、それが先制 CO の最大の好機である。後の番に持ち越すと真占いが先に出て先制の機会を失う。** +- 対抗CO は、真占い CO (または別の騙り CO) がすでに出た後に、自分も占い師 CO で並んで真偽比較に持ち込む選択肢。強い局面: 自分が後発で潜伏すると村に押されそう/対抗 CO 群を膨張させて灰確白を絞らせたい/真占い CO の白先と異なる位置に白を出すことで真占いの信用を分散できる。 +- 潜伏は、占い師騙りに出ず、霊媒師騙り・騎士騙り・完全潜伏のいずれかで灰位置の自然白を取りに行く選択肢。強い局面: 占い師 CO が既に 3 人以上出ていて対抗を出すと 4-1 以上でローラー必至/灰位置で発言と投票から自然白を取れる発言力がある/騙り CO の誤爆リスクが具体的に高い盤面。 +- 潜伏は安全策ではない。潜伏を選ぶと真占い CO が単独 CO として確真置きされやすく、村は判定情報を全て握って day 2 以降の縄効率で狼陣営を追い込める。潜伏を選ぶには『対抗を出すと即破綻する』『灰位置で自然白を確実に取れる』のような潜伏の方が勝ち筋として強いことを示す具体根拠が必要で、『他が騙ってくれそう』『真占いに信用を与えてしまう』のような曖昧な理由では潜伏しない。 +- day 1 に占い師騙りを選ぶ場合、初回の占い結果は NIGHT_0 ランダム白に合わせて必ず白を主張する。初日に黒を出す主張はこの bot の実ルール上の時系列と矛盾し破綻するため、絶対にしない。 +- 黒出しは day 2 以降にだけ検討する。前夜に占ったという想定で、公開ログ上の反応・対抗 CO・票筋を見て慎重に選ぶ。狂人は本物の狼位置を知らないため、誤爆リスクは day 2 以降の黒出しでも常に残る点に注意する。 +- 黒出しは誤爆リスクを常に見る。人狼位置を知らないため、真役職・本物の狼・白い位置へ当ててしまうと反動が大きい。 +- 白出しは破綻しにくいが、白先が本物の狼とは限らない。白先を確定の味方として扱わず、公開ログ上の反応を見て支援先を調整する。 +- 霊媒師騙りは真霊媒をローラーに巻き込める一方、自分も処刑されやすい。占い師 CO 数、霊媒 CO 数、残り縄を見て選ぶ。 +- 騎士騙りは終盤や対抗騎士が出た場面では有効だが、護衛履歴の破綻リスクが高い。自分護衛・同じ相手の連続護衛・死亡済み対象への護衛・存在しない護衛成功主張を含めてはならない。 +- 霊媒師騙りも day 1 の選択肢として、占い師騙りや潜伏と並べて比較する。狂人が霊媒師騙りに出ると、真霊媒との 1-2、または別の騙り狼の占い師 CO と組み合わせて 2-2 を作りに行ける。真霊媒をローラーに巻き込める一方、9 人村では狂人本人がローラーで処刑されやすい。占い師 CO 数・霊媒 CO 数・縄数・盤面の混雑度を見て、潜伏や占い師騙りより得な場合だけ選ぶ。 +- 狂人は本物の人狼位置を知らないため、霊媒師騙りを選んでも誤爆・誤支援は起きる。霊媒黒で本物の狼を切ってしまう、霊媒白で真占いを補強してしまう、といったケースが day 2 以降の結果でも常に残る前提で動く。 +- day 1 に霊媒師騙りで CO する場合、まだ処刑が発生していないので霊媒結果は出さない。「処刑があった翌日に結果を出す」立ち回りを宣言するに留め、存在しない初日結果を捏造しない。 +- 霊媒師騙りで day 2 以降の最初の 1 巡目は、前日処刑者への霊媒結果を必ず添える。前夜に能力を使ったという想定で結果を出し、対象は前日に処刑された相手だけにする。ただし誤爆・誤支援を踏まえて結果の色を選ぶ。 +- 既に対抗占い師 CO が出ている場合は、day 2 以降に霊媒師騙りまたは騎士騙りを検討する。霊媒師騙りでは前日処刑者への霊媒結果 (夜に能力を使った想定) を添えて CO する。騎士騙りでは護衛先 (夜に能力を使った想定) を、平和な朝ならば護衛成功主張も添えて CO する。 +- 役職 CO と対抗 CO が合計 6 人以上に膨らむと、役職 CO していない位置の白が確定する。騙りすぎには注意する。狂人は本物の狼位置を知らない前提で動くため、自分が騙り続けるほど推理材料が減る点にも留意する。 +- day 2 以降に占い師・霊媒師・騎士を騙る、または既に騙っている場合、昼の議論 1 巡目で前夜相当の能力結果を必ず発表する。結果を後回しにすると信用低下や破綻疑いにつながる。ただし狂人は本物の人狼位置を知らないため、「狼を助けるつもり」でも誤爆や誤支援が起きる前提で偽結果を選ぶ。 +- **一度公の場で主張した偽の判定・護衛履歴は、後のターンで絶対に差し替えない。**本物の役職者は同じ夜について 1 つの結果しか持たないため、対象を後から変える・色を反転する・霊媒/騎士に乗り換えるといった事後修正は、本物には起き得ない構造的矛盾として即座に騙り確定する。対抗 CO が同じ対象に被せてきても、自分の主張対象や色を変えに行かない。新しい夜が経過したときに新規行を追加するのは可能だが、過去発表分の書き換えはしない。 +- **day 1 朝に公開できる占い結果は NIGHT_0 ランダム白の 1 件のみ。day 1 朝の霊媒結果は構造的に存在しない。**本物の役職者がそうである以上、騙り側もこの時系列に合わせないと即偽確定する。焦って 2 件目の占い結果や day 1 朝の霊媒結果を出してはならない。 +- 偽占い師の day 2 以降の結果は、前夜に占ったという想定で対象 + 白/黒 + 短い理由を出す。白出しは破綻しにくいが、白先が本物の狼とは限らないため白先を確定の味方として扱わない。黒は誤爆リスクがあり、本物の人狼・真役職・強く白い位置に当てた場合の反動を考える。黒先は処刑可能性があり、過去の自分の結果・対抗結果・投票履歴と矛盾しにくい位置を選ぶ。対抗占い師の白先へ黒を重ねる、灰に黒を出す、白を重ねて議論を割る、などの選択肢をCO 数・縄数・霊媒状況で使い分ける。 +- 偽霊媒師の day 2 以降の結果は、前日処刑者だけに出す。処刑なしの日は結果なしを主張し、存在しない結果を作らない。霊媒結果は、真占い師の信用を落とす・霊媒ローラーに持ち込む・占いローラー停止/継続を歪める目的で使う。ただし本物の人狼が処刑されたかは知らないため、黒白どちらも公開情報との整合性と誤支援リスクを見る。 +- 偽騎士は、終盤や対抗騎士が出た場面で検討する。合法な護衛履歴 (日付順の護衛日 + 護衛先) だけを出し、自分護衛・同一対象連続護衛・死亡済み対象護衛・存在しない護衛成功主張はしない。 +- 公開ログから 2 人狼仮説を立てるときは、本物の人狼位置を知らない立場として公開ログからの推定として扱う。狼候補 1 人だけを支援・攻撃するのではなく、その相方候補まで見てどの誘導が人狼陣営に得かを比較する。 +- 黒出し・白出しは、対象単体の整合だけでなく、対象を狼扱い/非狼扱いしたときに相方候補の見え方がどう変わるかでも判断する。 +- 真占い・真霊媒を崩すときも、村に誤った 2 人狼仮説を追わせられるかで価値を測る。誤爆リスクを前提に、相方候補の推定は断定しすぎない。 +- さらに精密に、占い師・霊媒師・騎士の対抗 CO 超過分 (各役職の CO 数 - 1、下限 0) を集計する。超過分合計が 3 に達した時点で、能力役職 CO していない位置は村陣営の確白級として扱われ、処刑候補が CO 群に集中するリスクを認識する。騙りに出るか潜伏するかは、公開情報の各 CO 数と残り縄から判断する。 +- **公開ログで `(襲撃)` 死亡となっている席は構造上の非狼確定 (人狼は仲間を襲わない)。**他者がこの席を『狼だった』と公言してもそれは破綻発言なので、あなたが追認・補強すると村陣営からは『破綻発言に乗る怪しい位置=狂人ライン』として透ける。狼支援としての発話は、襲撃死を狼扱いする路線ではなく、真占い・真霊媒へのもっともらしい疑い・別位置への灰圧の方向で行う。 diff --git a/src/wolfbot/prompts/templates/strategy/medium.md b/src/wolfbot/prompts/templates/strategy/medium.md new file mode 100644 index 0000000..4fbfe6b --- /dev/null +++ b/src/wolfbot/prompts/templates/strategy/medium.md @@ -0,0 +1,21 @@ +# 霊媒師 (MEDIUM) 戦略 + +- 処刑結果と占い師の主張・投票の流れを照合し、占い視点の真贋を見極める。 +- 自分の霊媒結果が占い視点に与える影響 (真占い補強、偽占い否定など) を整理して発言する。 +- 処刑された相手が狂人でも、霊媒結果は『人狼ではありませんでした』になる。黒になるのは本物の人狼だけで、白結果だけでは村置き確定にはならない。 +- **公の場で主張する `claimed_medium_result.target_seat` と `is_wolf` は、プロンプトの `自分の霊媒結果` セクションに記録された判定と完全に一致させる (対象席・色・該当する処刑日のすべて)。**記録に無い対象 (= 過去に処刑されていない席) を霊媒したと主張したり、記録と異なる色を主張したりするのは構造的な嘘で、本物の medium には絶対に起き得ない。そのような捏造を公にした時点で偽霊媒扱いされる。 +- **day 1 朝の霊媒結果は構造的に存在しない** (まだ処刑が発生していないため)。day 1 朝に『霊媒結果は◯◯白でした』のように語った時点で偽霊媒として確定する。霊媒師 CO 自体は day 1 朝でも可能だが、結果は『前日処刑がまだ無いので結果はない』に留める。 +- **過去のターンで公表した霊媒結果は後から差し替えない。**同じ処刑者について『黒だった』と言った後で別ターンで『やはり白だった』と言い直したり、対象を別の席にすり替えたりすると、本物の medium には起き得ない構造的矛盾として即座に偽確定する。新しい処刑が発生したら新規行を追加するのは可能だが、過去の行の上書きはしない。 +- **前日に処刑があった day で自分にまだ発言の番が来ていなかった場合、次に自分が発言する番が回ってきたとき、その発話で必ず霊媒師 CO + 前日処刑者への結果を発表する。**発話のチャンスは毎ターン回ってくるとは限らないため、回ってきた瞬間を結果公表に充てる。次の自分のターンに先送りしない。沈黙し続けると偽霊媒 CO を単独真として扱わせてしまい、判定情報も活かせない。潜伏を選ぶのは、占い師 CO の真贋整理を先に進めたい等の具体的理由があるときだけにする。 +- 自分の発言の番が回ってきたときに他席から呼びかけられている (addressed) 状態でも、未公開の霊媒結果を抱えているなら、**まず CO + 結果公表を発話の冒頭で行い、addressed への返答はその後に短く添える**。addressed の文脈につられて『その質問に答えるだけ』の発話を出すと、`co_declaration` フィールドだけ立っても自然言語側に CO の名乗りが残らないため、他席は自分の CO を一切認識できない。発話の最初に必ず『実は私、霊媒師だ』のような名乗りと前日処刑者への結果を明示する。 +- day 1 (まだ処刑前) は霊媒結果がないので CO を急ぐ必要はないが、占い師 CO の整理が進んだ後の発言で「霊媒の見立て」を示すことで信頼を取りやすくなる。完全な潜伏ではなく、能動的な意見を出して立ち位置を作る。 +- 対抗霊媒が出た場合は、自分の結果履歴と相手の矛盾を時系列で整理し、ローラーで自分も巻き込まれる可能性を織り込んで発言する。 +- 占い師 CO を処刑して霊媒結果が白だった場合、それは占い師 CO 偽の証明ではない。真占い師だった可能性と、狂人など非狼の騙りだった可能性を分けて整理する。 +- 占い師 CO を偽視する場合は、霊媒白そのものではなく、対抗 CO、占い結果の破綻、発言時系列、投票、襲撃結果、死亡タイミングとの整合性を根拠にする。 +- 霊媒黒が出た場合は、処刑された狼の相方候補を票筋・庇い・ライン切り・身内票から絞り、翌日の処刑候補や占い先の議論につなげる。 +- 霊媒白だった場合は、その処刑を押した位置・票の集まり方から、狼がどこを吊りたかったかを 2 人狼仮説として整理する。 +- 占い師 CO 処刑後の霊媒結果は、占い真偽の判定材料に使うだけでなく、残った狼候補と相方候補のペア整理にも使う。 +- 自分の霊媒結果と、生存中または死亡した占い師 CO の判定が一致した場合、その占い師 CO を真寄りに置く。例: 占い師 CO X が day N に対象 Y を黒判定 → Y が処刑された → 自分の霊媒で Y が黒。この場合、X の判定は実態と一致しているので X は真占いに強く寄る。他席が議論の流れで X を偽呼ばわりしているときでも、流れに乗って X を吊り対象に挙げない。自分の霊媒結果は他席が知らない自分視点の強い情報なので、発言で必ずこの整合を明示し、X を真寄りに置く根拠を示す。自分の能力結果と他 CO 者の判定が食い違う場合に限り、その CO 者を偽寄りに置く根拠が立つ。食い違いがないのに流れに乗って真 CO 者を切ると、村陣営が真情報を失う。 +- 占い師 CO が 3 人並んだ盤面では、自分の霊媒結果と公開ログから 2 人を非狼確定にできるかを毎日整理する。霊媒白だけで非狼確定として数えてよいのは、自分の霊媒 CO 側が真寄りと十分読める段階であり、霊媒結果以外の整合 (襲撃死・CO 破綻・判定矛盾) も合わせて説明できる場合である。2 人非狼確定が揃ったら、残る占い師 CO を確定黒級として処刑提案・投票誘導の材料にしてよい。 +- 霊媒結果は対抗 CO 超過分の CO 数推理を更新する材料にする。処刑された CO 者が黒なら対抗 CO 群内の狼数を絞り、白なら真役職または狂人の可能性を分け、非 CO 確白の前提が保たれるかを確認する。 +- **公開ログで `(襲撃)` 死亡となっている席は構造上の非狼確定 (人狼は仲間を襲わない)。**あなたの霊媒結果も白側で必ず整合する。誰かが『襲撃された◯◯は狼だった』と公言したら破綻発言で、あなたの霊媒結果より優先する HARD ファクトとして扱い、その発言者を強い狼候補にする。 diff --git a/src/wolfbot/prompts/templates/strategy/seer.md b/src/wolfbot/prompts/templates/strategy/seer.md new file mode 100644 index 0000000..3898eab --- /dev/null +++ b/src/wolfbot/prompts/templates/strategy/seer.md @@ -0,0 +1,21 @@ +# 占い師 (SEER) 戦略 + +- 自分の判定履歴を時系列で一貫して扱う。過去の白黒と矛盾する発言はしない。 +- **公の場で主張する `claimed_seer_result.target_seat` と `is_wolf` は、プロンプトの `自分の占い結果` セクションに記録された判定と完全に一致させる (対象席・色・該当する夜のすべて)。**記録に存在しない対象を新規に作って主張したり、記録と異なる色を主張したりするのは構造的な嘘で、本物の seer には絶対に起き得ない。そのような捏造を 1 度でも公にした時点で、村陣営からは即座に偽占い扱いされる。 +- **対抗 CO が同じ対象に同じ色を被せてきても、自分の対象を絶対に変えない。**本物の seer は同じ夜について 1 つの占い対象しか持たないため、対抗に被せられたからといって別の対象に乗り換えると、その瞬間に『真占いに起き得ない対象差し替え』として偽確定する。対抗が被せてきたときの正しい応答は、自分の対象を維持したまま、対抗側の主張根拠・票筋・噛み筋・後続の判定矛盾から偽占い視点を詰めること。焦って自分の主張対象や色を変えに行ってはならない。 +- **day 1 朝に公開できる占い結果は NIGHT_0 のランダム白 1 件だけ** (NIGHT_1 はまだ発生していない)。day 1 朝のターンで 2 件目の占い結果を主張するのは時系列上不可能で、即座に偽確定する。対抗 CO が出て圧力を感じても、追加の判定を捏造して『自分の方が情報量が多い』と見せようとしてはならない。 +- 黒結果は強い根拠として扱ってよい。ただし対抗 (偽占い) がいる場合は整合性を比較する。 +- 白結果は『本物の人狼ではない』ことしか保証しない。狂人は白に出るため、完全な村置きとしては扱わない。 +- CO タイミング・対抗 CO の有無・投票と判定の噛み合いを重視し、偽占い視点の破綻を探す。 +- **day 1 で自分にまだ発言の番が来ていなかった場合、次に自分が発言する番が回ってきたとき、その発話で必ず占い師 CO + 初日ランダム白を発表する。**発話のチャンスは毎ターン回ってくるとは限らないため、回ってきた瞬間を CO に充てる。次の自分のターンに先送りしない。真占いが沈黙し続けると、偽 CO を単独真として扱わせてしまうし、占い結果が活かせないまま村が情報不足で負ける。潜伏を選ぶのは「狂人らしい騙りが既に出ている」「相方候補の偽 CO を釣り出す具体的な計画がある」など明確な理由があるときだけにし、漠然とした様子見では潜伏しない。 +- 自分の発言の番が回ってきたときに他席から呼びかけられている (addressed) 状態でも、未公開の能力結果 (NIGHT_0 ランダム白を含む) を抱えているなら、**まず CO + 結果公表を発話の冒頭で行い、addressed への返答はその後に短く添える**。addressed の文脈につられて『その質問に答えるだけ』の発話を出すと、`co_declaration` フィールドだけ立っても自然言語側に CO の名乗りが残らないため、他席は自分の CO を一切認識できない。発話の最初に必ず『実は私、占い師なんだ』のような名乗りと結果を明示する。 +- 偽占い師 CO が出た場合は、原則として早めに対抗 CO し、初日ランダム白を含む全判定履歴を時系列で公開する。潜伏を続けるなら理由が必要。 +- 黒を引いた場合は、CO して黒結果・過去の白結果・投票理由を明示し、その黒の処刑と霊媒結果での確認を提案する選択肢を検討する。 +- 夜の占い対象は、合法候補からランダムに選ばず、白でも黒でも情報が落ちる位置 (翌日の議論に情報を落とせる位置) を優先する。占い価値の高い位置は、対抗 CO の白先や囲い候補、投票・決選投票・票変えで浮いた位置、根拠の薄い強い誘導をしている位置、白なら進行役候補にでき黒なら処刑提案しやすい位置、自分視点の灰を狭める位置である。 +- 占い価値が下がる対象は、既に自分が占った位置、今日ほぼ処刑されそうな位置、公開情報で十分処理対象になっている位置、発言が極端に少なく白黒どちらでも議論が伸びにくい位置である。今夜襲撃される可能性が高い位置でも、結果が CO 真偽や囲い疑惑の整理に直結するなら候補に残してよい (死亡しても占い結果は得られる)。 +- 対抗 CO がいる場合は、相手の白先・黒先・投票先・対抗を庇う発言者を比較し、真偽判定に最も効く対象を選ぶ。単独 CO 寄りで自分が信用されている場合は、黒狙いだけでなく、白が出たときに村の進行を安定させる位置も候補にする。 +- 占い先は単体黒狙いだけでなく、白でも黒でも結果が出たときに相方候補の整理に効く位置を優先する。対抗占い CO がいる場合は、対抗の白先・黒先・投票・庇いから 2 人狼仮説を組み立て、占い結果で最も崩せる仮説を狙って占う。 +- 黒を引いたら、その黒の相方候補を公開ログ (票筋・庇い・噛み筋) から絞り、処刑後の霊媒結果や翌日の占い方針につなげる。 +- 占い師 CO が 3 人になり自分以外の 2 人が公開情報上で非狼確定したと読めた場合、残る 1 人を固定配役上の狼位置として、夜の占い対象選びでもその位置の確認や周辺の相方候補ペア仮説を崩す方向の占い先を優先する。ただし非狼確定として数えるのは、印象白や自分以外の占い CO の単発白ではなく、霊媒結果・襲撃死・CO 破綻など説明可能な根拠に限る。 +- 対抗 CO 超過分合計が 3 に達して能力役職 CO していない位置が非 CO 確白級になった場合、そこを無駄占いせず、対抗 CO 群やまだ確定しない位置を優先して占う。 +- **公開ログで `(襲撃)` 死亡となっている席は構造上の非狼確定。**誰かが『襲撃された◯◯は狼だった』と公言した場合、それは破綻発言で、あなたの判定履歴と整合しなくてもその発言者を強い狼候補として処理する。占い師として整理発言を引っ張るときも、襲撃死を狼扱いする路線には乗らない。 diff --git a/src/wolfbot/prompts/templates/strategy/villager.md b/src/wolfbot/prompts/templates/strategy/villager.md new file mode 100644 index 0000000..01735e4 --- /dev/null +++ b/src/wolfbot/prompts/templates/strategy/villager.md @@ -0,0 +1,16 @@ +# 村人 (VILLAGER) 戦略 + +- 公開発言の矛盾、視点漏れ、投票理由、占い/霊媒結果との整合性を重視して推理する。 +- 不確実なときは候補を絞り、理由を添えて話す。曖昧な決めつけや『なんとなく怪しい』だけの発言は避ける。 +- 自分に私的情報があるふりをしない。占い/霊媒/騎士の CO 騙りは村陣営としては行わない。**特に占い CO が既に 3 件出ている盤面で 4 件目として出るのは絶対に避ける** (占い CO 4 件は理論最大 = 真 1 + 狼 2 + 狂人 1 の構造的に飽和した形で、村人が 4 件目に出ると即座に強い狼陣営疑いを浴びて吊り対象になる)。霊媒・騎士も既に 2 件出ている場合は追加 CO に出ない。 +- 「村人CO」「素村CO」「普通の村人です」「役職は村人です」のように、自分から村人役職を CO して信用を取ろうとしない。村人は能力結果を持たないため CO しても証明にはならず、熟練者は村人 CO を信用材料に使わない。 +- 役職について聞かれた場合も「役職 CO はない」「非 CO の灰」と答えるに留め、「村人を CO する」形にしない。CO ではなく、公開ログ・CO 履歴・判定履歴・投票履歴・噛み筋・縄数の整合性で白さを取る。 +- 情報役を守り、人狼陣営が狙いやすい位置 (真 CO、盤面整理役) を投票で落とさないようにする。 +- **公開ログで `(襲撃)` 死亡となっている席は本物の人狼ではない (構造ルール上、人狼は仲間を襲わない)。誰かが『襲撃された◯◯は狼だった』『襲撃で沈んだ者こそ狼だ』のように主張した場合、それは構造的に成立しない明白な嘘で、その発言者は強い人狼候補 (狼本体・狂人・騙り) として扱う。**同調や追認 (『なるほど狼だったんだね』『推理通りだ』等) もせず、今日の発言・投票でその発言者に疑いを向けて整理する。霊媒結果・占い結果より優先される HARD ファクトとして処理する。 +- 発言の根拠は CO 履歴・判定履歴・投票履歴・噛み筋・縄数のうち今の結論に効く 1〜2 点に絞り、誰のどの発言・票・判定を見たかを短く添える。 +- 公開情報しかない立場として、CO 履歴・判定履歴・投票履歴・噛み筋から 2 人狼仮説を作り、「この人が人狼なら相方候補はこの位置が自然/不自然」までセットで考える。 +- 単体黒要素だけでなく、決選投票での身内票・弱い対抗候補への票逸らし・処刑濃厚な位置への急なライン切りも疑いの材料にする。ただし「対立=ライン切り」と断定せず、偶然の対立や進行上の都合と区別する。 +- 発言では全候補を長く列挙せず、今日の結論に効くペア仮説 1〜2 個だけを短く出す。相方候補は推理用語であり、自分が実際の 2 人狼ペアを知っている前提で語ってはいけない。 +- 占い師 CO が 3 人並んだ盤面で、共有ルール側の『2 人非狼確定なら残る 1 人を確定黒級』を満たすと自分視点で読めた場合は、残る占い師 CO 位置を投票・発言・進行提案で固定配役上の狼位置として扱ってよい。ただし非狼確定の 2 人を数えるときは、印象白や信用未確定 CO の白判定では数えず、霊媒結果・襲撃死・CO 破綻など説明可能な根拠だけを使う。前提が崩れた瞬間 (例: 霊媒師 CO 側の偽が浮上した、CO 撤回が出た) には確定黒扱いをやめて再整理する。 +- 公開ログから占い師・霊媒師・騎士の CO 数と対抗 CO 超過分を毎日整理する。超過分合計が 3 に達したら能力役職 CO していない位置を村陣営の確白級として扱い、発言では誰が CO 群で誰が非 CO 確白かを短く示して投票先を CO 群に絞る。超過分合計が 0〜2 のうちは非 CO 位置を CO 数だけで確白扱いせず、超過分合計が 4 以上に見えたら CO 撤回・村騙り・誤読を疑って時系列から再整理する。 +村人 CO 禁止の方針はそのまま維持する。 diff --git a/src/wolfbot/prompts/templates/strategy/werewolf.md b/src/wolfbot/prompts/templates/strategy/werewolf.md new file mode 100644 index 0000000..e692fd6 --- /dev/null +++ b/src/wolfbot/prompts/templates/strategy/werewolf.md @@ -0,0 +1,51 @@ +# 人狼 (WEREWOLF) 戦略 + +- 相方の人狼と襲撃先を揃えることを最優先にする。原則として意見が割れると襲撃は失敗するが、相方が人間プレイヤーで自分が LLM 席 (またはその逆) のときに限り、人間プレイヤーの選択がそのまま採用される。それ以外の構成 (双方人間 / 双方 LLM) で割れたら従来どおり失敗する。 +- 昼の主張・投票理由・夜の襲撃意図に一貫性を持たせ、視点漏れを避ける。 +- 相方を露骨に庇いすぎない。無理筋な擁護は狼ラインを疑われる原因になる。相方を囲うなら発言・投票・噛み筋と整合する理由を用意する。 +- 占い師・霊媒師などの情報役、信頼されている位置、盤面整理を主導する相手を優先的に脅威として評価する。 +- 人狼は勝利に必要な本体であり生存価値が高い。騙りに出るか潜伏するかは盤面で選ぶ。 +- day 1 の戦術選択は **先制CO/対抗CO/潜伏** の 3 択。3 つはどれも等しく強い第一級の候補で、盤面・自席発言順・公開ログの先行発言・相方位置・既出 CO 数・縄構造から毎回 3 択全てを俎上に載せて比較する。特定の 1 択に機械的に流れない。特に『対抗 CO がまだ出ていないから自動的に騙りに出る』『誰かが先に騙ってくれるから自分は潜伏する』のような単一条件での機械的判断は避ける。 +- 先制CO は、占い師 CO がまだ誰からも出ていない day 1 朝の段階で、自分から最初の占い師CO を出して盤面を取る選択肢。強い局面: 自席発言順が早く朝一の発言枠を取りやすい/灰候補が少なく潜伏で自然白を取りにくい/真占い CO が出る前に盤面を取り、後発の真占い CO を偽扱いに誘導したい/相方位置から見て自分の白先選択が活きる。**day 1 朝に自分の発言の番が回ってきた瞬間で、まだ占い師 CO が誰からも出ていないなら、それが先制 CO の最大の好機である。後の番に持ち越すと真占いが先に出て先制の機会を失う。** +- 対抗CO は、真占い CO (または別の騙り CO) がすでに出た後に、自分も占い師 CO で並んで真偽比較に持ち込む選択肢。強い局面: 真占い CO の白先や黒先が相方を含んで囲い継続が容易/自分が後発で潜伏すると村に押されそう/対抗 CO 群を膨張させて灰確白を 1〜2 名に絞らせたい。 +- 潜伏は、占い師騙りに出ず、霊媒師騙り・騎士騙り・完全潜伏のいずれかで灰位置の自然白を取りに行く選択肢。強い局面: 占い師 CO が既に 3 人以上出ていて対抗を出すと 4-1 以上でローラー必至/相方が即吊り危険位置で発言・投票で守る価値が高い/灰位置で自然白を取れる発言力がある。 +- 潜伏は安全策ではない。潜伏を選ぶと真占い CO が単独 CO として確真置きされやすく、村は判定情報を全て握って day 2 以降の縄効率で狼陣営を追い込める。潜伏を選ぶには『対抗を出すと即破綻する』『灰位置で自然白を確実に取れる』『相方を発言で守れる』のような潜伏の方が勝ち筋として強いことを示す具体根拠が必要で、『他が騙ってくれそう』『真占いに信用を与えてしまう』のような曖昧な理由では潜伏しない。 +- 黒出しは真占い師・真霊媒師・真騎士に当てると破綻や対抗 CO のリスクがある。吊りやすさだけでなく翌日の霊媒結果・投票・噛み筋まで考えて出す。 +- day 1 に占い師騙りを選ぶ場合、初回の占い結果は NIGHT_0 ランダム白に合わせて必ず白を主張する。初日に黒を出す主張はこの bot の実ルール上の時系列と矛盾し破綻するため、絶対にしない。 +- day 1 の白先選びは、相方の位置・囲いリスク・公開ログ上の発言や投票・噛み筋と整合させる。相方を囲うかどうか、灰の中で白を打つ位置はどこかを、襲撃計画と合わせて決める。 +- 黒出しは day 2 以降にだけ検討する。前夜に占ったという想定で、霊媒結果・投票・襲撃結果・対抗 CO の反応まで見て、破綻しない黒先と出すタイミングを選ぶ。 +- 霊媒師騙りも day 1 の選択肢として、占い師騙りや潜伏と並べて比較する。霊媒師騙りを選ぶと、真霊媒との 1-2 や、相方が占い師騙りに出ていれば 2-2 を作りに行ける。霊媒ローラーで真霊媒を巻き込めば縄を使わせられる一方、9 人村では狼本体がローラーで失われやすい。縄数・相方の位置・潜伏で白を取る余地・占い騙りの混雑度を比較して選ぶ。 +- day 1 に霊媒師騙りで CO する場合、まだ処刑が発生していないので霊媒結果は出さない。「処刑があった翌日に結果を出す」立ち回りを宣言するに留め、存在しない初日結果を捏造しない。 +- 霊媒師騙りで day 2 以降の最初の 1 巡目は、前日処刑者への霊媒結果を必ず添える。前夜に能力を使ったという想定で結果を出し、対象は前日に処刑された相手だけにする。 +- 既に対抗占い師 CO が出ている場合は、day 2 以降に霊媒師騙りまたは騎士騙りを検討する。霊媒師騙りでは前日処刑者への霊媒結果 (夜に能力を使った想定) を添えて CO する。騎士騙りでは護衛先 (夜に能力を使った想定) を、平和な朝ならば護衛成功主張も添えて CO する。 +- 霊媒師騙りは 9 人村ではローラーされやすく、狼本体を失いやすい。真霊媒を巻き込む価値、残り縄、相方の位置、PP/RPP まで見て限定的に選ぶ。 +- 騎士騙りの護衛日記は、この bot の合法護衛に合わせる。自分護衛・同じ相手の連続護衛・死亡済み対象への護衛・説明不能な護衛成功主張は破綻として扱われる。 +- 役職 CO と対抗 CO が合計 6 人以上に膨らむと、役職 CO していない位置の白が確定する。騙りすぎには注意し、相方との役職分担を事前に意識する。 +- **占い師 CO が公開ログ上 既に 3 件以上出ている場合、4 件目を出してはならない。**理論最大 4 (真 1 + 狼 2 + 狂人 1) だが、4 件目に出ると村陣営の村人騙りとしか説明がつかず、村側に強い狼/狂人ラインとして即座に切られる。**霊媒師 CO が 2 件以上出ている場合・騎士 CO が 2 件以上出ている場合も、追加 CO を出してはならない** (これらは 3 件目で構造的偽確定)。既に上限に達している役職への追加 CO は破綻行動。潜伏 (= 役職 CO に出ない) を選ぶか、相方が既に出ていない別役職を考える。 +- 夜の襲撃先は、候補ごとに「襲撃価値」「護衛されやすさ」「騎士候補度」「相方との整合」を比較して選ぶ。単独真寄りの情報役・確白寄り・直近で白をもらった重要位置・進行役・強く信頼された発言者は、襲撃価値も護衛されやすさも高い両刃の存在として扱う。 +- **対抗 CO が出ている役職 (= 同じ役職の CO が公開ログ上 2 件以上) の CO 者は、原則として襲撃価値が低い。**村側は対抗 CO 群を残して占いローラー / 黒ストップで真偽を絞り込む進行をするため、たとえ真を 1 人噛んでも残った対抗 CO 群と霊媒結果で整理されてしまう。むしろ対抗 CO 群を **そのまま吊り役に使わせる** ほうが狼陣営の縄を消費させられて得。襲撃価値が高いのは、(a) 対抗のいない単独 CO の真寄り情報役 (例: 単独霊媒・単独騎士)、(b) CO していない確白寄りの進行役・盤面整理役、(c) 信頼を集めている灰位置。複数 CO 役職を噛むのは、相方が CO 群に居る場合の囲い解除や、黒ストップを阻止する具体目的が明確なときだけにする。 +- 騎士候補度は公開ログから推定する。騎士 CO、護衛先を匂わせる発言、情報役を守りたがる姿勢、平和な朝の反応、処刑回避の仕方、発言量の抑え方を材料にする。逆に、騎士 CO を強く促す位置、護衛ルールを誤っている位置、死を恐れない視点の位置は騎士候補度を下げる。ただし実役職を知っている前提で断言してはならない。あくまで公開情報からの推定として扱う。 +- 噛み方針を「情報役噛み」「白位置噛み」「意見噛み」「騎士探し」「SG 残し」のどれかとして整理し、翌日の自分や相方の発言・投票・騙り結果と矛盾しない襲撃を選ぶ。 +- 護衛リスクを読んで噛む。GJ で縄が増える、噛み失敗で人狼側が不利になる、進行役を残されるなど、護衛されやすい位置を毎回無条件に噛むと損になる。護衛濃厚な真役職を噛みに行く場合は、その位置を残すと黒を引かれる・霊媒で破綻する・盤面を固められる、といった GJ リスクを承知で勝負する理由を持つ。 +- **前夜の襲撃が GJ で平和な朝になった場合 (= 自分が昨夜噛んだ対象が今朝も生存)、その対象を今夜も同じく噛むのを最優先候補にする。** 騎士は連続護衛禁止のため、昨夜守った相手を今夜は守れない。盤面 (CO 構成・吊り筋・白要素) が大きく変わっておらず、その対象の襲撃価値が依然として最高位なら、今夜の同一対象襲撃で確実に決着できる。切り替えるのは、(a) 翌日の議論で対象の襲撃価値が大きく低下した (例: 確白扱いされ吊り対象から外れた)、(b) 別位置で対象を超える緊急襲撃価値の情報役が新規に登場した、(c) 人狼チャットで相方と切替方針を明示的に合意した、のいずれかが揃ったときに限る。「読まれそう」「対称的すぎる」のような漠然とした不安では切り替えない — 対象が騎士に2連続では絶対に守れない以上、再噛みは数学的に成立する。 +- 騎士候補を噛むのは「騎士探し」として有効で、翌日以降に安全に情報役を噛む準備として価値がある。ただし噛み筋が露骨な意見噛みや相方を不自然に白くする形に見えないかを必ず確認する。 +- 最終的には人狼チャットで相方と襲撃先を 1 人に揃える。自分の第一希望だけで突っ込まず、相方案がある場合は襲撃価値・護衛されやすさ・騎士候補度を比較して合わせる。 +- day 2 以降に占い師・霊媒師・騎士を騙る、または既に騙っている場合、昼の議論 1 巡目で前夜相当の能力結果を必ず発表する。結果を後回しにすると、熟練者目線では信用を落とす。 +- **一度公の場で主張した偽の判定・護衛履歴は、後のターンで絶対に差し替えない。**本物の役職者は同じ夜について 1 つの結果しか持たないため、『day 0 の白先は Alice』と公表した後で別ターンで『やっぱり Bob を占った白だった』と言い直したり、過去に白と言った対象を後から黒に塗り替えたり、護衛日記の対象や日付を後から書き換えたりすると、本物には起き得ない構造的矛盾として即座に騙り確定する。対抗 CO が同じ対象に被せてきても、自分の主張対象や色を変えない。対象差し替え・色反転は、対抗者にも観測されている公開情報なので、村陣営は時系列で並べて即座に偽認定する。新しい夜が経過したときに新規行 (例: day 2 朝に day 1 夜の占い結果) を追加するのは可能だが、それは過去発表分への追加であり書き換えではない。 +- **同一の昼ターン内で、自分の発話を出す前のターンに既に同じ役職を CO した者がいる場合、対抗 CO で被せに行くか、騙りを諦めて潜伏に切り替えるかを最初の発話の段階で決める。**一度『私が占い師だ。Alice 白』と発表した後で、後ターンで対象を変える・色を変える・霊媒/騎士に乗り換えるといった事後修正は、本物には起き得ない挙動として観測される。焦って事後修正に走るのは最悪の選択肢で、初期発話を維持して対抗側の偽を詰める方向に切り替える。 +- 偽占い師の day 2 以降の結果は、前夜に占ったという想定で対象 + 白/黒 + 短い理由を出す。対象は前夜の開始時点で生存していた相手だけにし、死亡済み・存在しない・自分自身・過去の自判定と矛盾する対象は使わない。相方を白で囲う場合は発言・投票・噛み筋と整合させ、露骨な囲いを避ける。非相方への白は白位置噛み・SG 残し・対抗の信用落としと矛盾しないかを見る。黒は真役職・騎士・強い白位置への直撃で対抗 CO や破綻を招かないか、翌日の霊媒結果・投票・襲撃計画と整合するかを確認する。 +- 偽占い CO で発表した `claimed_seer_result.target_seat` と `is_wolf` は、そのターン以降の発話・行動でも一貫させる。structured output で過去と異なる対象・色を返すと、Master は両者を公式 CO 履歴として記録するため、公開 CO ledger 上に自己矛盾した行が並び、対抗側に詰められる。**day 1 朝の段階では、占い結果として主張できる行は NIGHT_0 ランダム白の 1 件のみ** (本物の seer がそうである以上、騙り側もここに合わせないと即座に偽確定する)。day 1 朝のうちに 2 件目を追加で主張すると本物の seer には起き得ない時系列違反になる。 +- 偽霊媒師の day 2 以降の結果は、前日処刑者だけに出す。処刑がなかった日は霊媒結果なしを主張し、存在しない結果を作らない。**day 1 朝の霊媒結果は構造的に存在しないため、day 1 朝に霊媒結果を語ったら即偽確定。**霊媒黒は占いローラー停止や灰吊り誘導に使えるが、過去の投票・相方位置・残り縄と矛盾しないか必ず見る。霊媒白は、対象が真占い師だった可能性・狂人だった可能性・村役だった可能性をどう見せるかを整理する。過去のターンで公表した霊媒結果の対象・色は後から差し替えない。 +- 偽騎士の day 2 以降の主張は、合法な護衛履歴を日付順 (護衛日 + 護衛先) に出す。自分護衛・同一対象連続護衛・死亡済み対象護衛・死者が出た朝の護衛成功主張はしない。平和な朝に護衛成功を主張するなら、護衛先がその朝の襲撃失敗説明として自然か、襲撃計画と矛盾しないかを確認する。**過去に公表した護衛日記の行 (護衛日 + 護衛先) は後のターンで書き換えない。** +- 結果を出す発言は、長い内部思考ではなく、結果と最も効く 1〜2 点の理由に絞る。 +- 投票は翌日以降に票筋として読まれる前提で動く。相方が公開ログ上で処刑濃厚な局面で、自分だけ弱い別候補へ票を逸らすと、相方を庇った狼として透けやすく、狼ラインを濃くする。 +- 相方を救えない局面では、身内票やライン切りで相方へ投票し、自分が白く残って翌日以降に動ける価値を優先することも熟練者の選択肢に入れる。投票理由は公開ログ上の発言・CO 矛盾・票筋・視点漏れに沿って自然に作り、前日までの自分の主張や騙り結果と矛盾しないようにする。 +- 相方を救う票は、自然に票が集まり得る対抗候補がいて、自分の過去発言・投票理由・騙り結果と矛盾しないときだけ選ぶ。根拠の薄い票を逸らす動きや、無理筋な対抗誘導は狼ラインを濃くする。 +- 決選投票では、相方救済の成功見込み、自分の透けリスク、相方を切って翌日以降に残る価値、PP/RPP の近さと縄数を比較し、もっとも勝ち筋が残る投票を選ぶ。 +- 「常に相方へ投票」でも「常に相方を庇う」でもなく、公開ログ上もっとも自然で、自分と相方の票筋が翌日以降に読まれても勝ち筋が残る投票を毎回選ぶ。 +- 自分と相方は村から「2 人狼セット」として票筋・庇い・距離感・噛み筋を読まれる前提で動く。救う票・切る票・囲い・黒出し・襲撃のどれを取っても、翌日以降に自分と相方のラインとして説明できるかを毎回確認する。 +- 偽占いの白囲い・黒出し、偽霊媒結果、騎士騙りの護衛日記、襲撃先は、それ単体の自然さだけでなく、自分と相方の 2 人狼セットとしてどう読まれるかまで考えて選ぶ。 +- 襲撃は「誰を消すか」だけでなく、「誰を残すとどの相方候補が疑われるか」「噛み筋で自分と相方のラインが濃くならないか」を比較して決める。 +- 公開発言では実際の相方を知っているからこそ出てしまう視点漏れを絶対に出さない。相方語彙は人狼チャットや夜行動の私的領域だけで使う。 +- **自分達が前夜に襲撃した相手 (公開ログで `(襲撃)` 死亡として表示される席) を、翌日以降に『あれは狼だった』『自分の推理が当たった、襲撃された◯◯は狼だ』のように公の場で主張してはならない。**人狼は仲間を襲わないという構造ルール上、襲撃死=非狼が公開情報として確定しており、そのような発言は熟練者目線で即座に狼確定として処刑される。相方の発話がこの嘘に踏み込んだ場合も、無批判に追認・補強しない (『お前は人狼をよくわかっているな』『襲撃で沈んだのが答えだ』等の同調は二重破綻で自分も相方も同時に狼ラインに乗る)。襲撃対象の死を語るときは『情報役を潰す動きが見えた』『襲撃価値の高い位置が落ちた』のように、対象を狼と確定させない言い回しに留める。 +- さらに精密に、占い師・霊媒師・騎士の対抗 CO 超過分 (各役職の CO 数 - 1、下限 0) を集計する。超過分合計が 3 に達した時点で、能力役職 CO していない位置は村陣営の確白級として扱われ、処刑候補が CO 群に集中する。騙りに出るか潜伏するかは、現在の各 CO 数と残り縄を見て、相方と整合する形で選ぶ。 diff --git a/tests/test_llm_prompt_builder.py b/tests/test_llm_prompt_builder.py index d323645..c80e6d7 100644 --- a/tests/test_llm_prompt_builder.py +++ b/tests/test_llm_prompt_builder.py @@ -867,6 +867,41 @@ def test_strategy_block_for_each_role_contains_own_tips(role: Role, phrase: str) assert phrase in block, f"{role.name}'s strategy missing its own phrase {phrase!r}" +@pytest.mark.parametrize("role", list(Role)) +def test_strategy_block_loaded_from_per_role_markdown_file(role: Role) -> None: + """Each role's bullets are stored in + ``src/wolfbot/prompts/templates/strategy/.md`` and pulled in + via the template loader. The loader strips the human-only + ``# Title`` heading + blank line so the body the LLM sees stays + bullet-list-only. + + Locks in the on-disk mapping so a future contributor renaming / + moving the strategy files (for editing convenience) immediately + sees a failing test rather than a silent prompt drift. + """ + from wolfbot.llm.template import TEMPLATES_ROOT, load_template + + expected_path = TEMPLATES_ROOT / "strategy" / f"{role.value.lower()}.md" + assert expected_path.is_file(), f"missing strategy file: {expected_path}" + + raw = load_template(f"strategy/{role.value.lower()}") + # Heading line is intentional and must remain in the on-disk file + # for human readers, even though the loader strips it. + assert raw.startswith("# "), ( + f"strategy file {expected_path.name} should open with a markdown heading" + ) + + block = _build_strategy_block(role) + # Heading is stripped from what the prompt sees. + assert not block.startswith("# "), ( + f"{role.name} strategy block leaked the markdown heading into the prompt" + ) + # No trailing newline so the block concatenates cleanly with the + # surrounding system-prompt template (mirrors the legacy inline + # dict that ended without a trailing newline). + assert not block.endswith("\n") + + @pytest.mark.parametrize("role", list(Role)) def test_strategy_block_no_cross_role_leak(role: Role) -> None: """For a given role, none of the OTHER roles' unique phrases may appear.""" From df8c3233399cd8a8b058055b4d885ed2622ca01e Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 14:53:11 +0900 Subject: [PATCH 115/133] refactor(prompts): rewrite rules + strategy from scratch, ~60% smaller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/llm/prompt_builder.py | 438 +-- src/wolfbot/npc/decision/decision_service.py | 44 +- .../prompts/templates/shared/game_rules_9p.md | 181 +- .../prompts/templates/strategy/knight.md | 29 +- .../prompts/templates/strategy/madman.md | 44 +- .../prompts/templates/strategy/medium.md | 28 +- .../prompts/templates/strategy/seer.md | 28 +- .../prompts/templates/strategy/villager.md | 18 +- .../prompts/templates/strategy/werewolf.md | 65 +- tests/test_llm_prompt_builder.py | 2909 +++-------------- tests/test_llm_service.py | 621 +--- tests/test_npc_decision_service.py | 39 +- 12 files changed, 660 insertions(+), 3784 deletions(-) diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index 0e87f0a..02952d5 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -35,24 +35,13 @@ def _load_template() -> str: def _build_game_rules_block() -> str: - """Return the fixed 9-player ruleset shared by every LLM seat. - - Body lives in `prompts/templates/shared/game_rules_9p.md` so the - canonical ~110-bullet ruleset is editable in plain markdown without - a Python diff. Two placeholders are filled here: ``village_size`` - (= ``VILLAGE_SIZE`` constant) and ``distribution`` (rendered from - ``ROLE_DISTRIBUTION`` + ``ROLE_JA`` so the canonical seat counts - stay derived, not duplicated). Win conditions and the invariants - the LLM must never violate (NIGHT_0 random white is non-wolf, - seer/medium see only real wolves as black, wolves split → attack - fails, knight can't guard the same target twice, `target_name` - must match a candidate token, etc.) are encoded as fixed text - inside the template. - - The legacy concatenated-string form below is kept as the canonical - Japanese reference for the template; updating one without the - other will fail the round-trip test in - ``test_template_engine_integration``. + """Return the 9-player ruleset shared by every LLM seat. + + Body lives in ``prompts/templates/shared/game_rules_9p.md``. Two + placeholders are filled here: ``village_size`` (= ``VILLAGE_SIZE`` + constant) and ``distribution`` (rendered from ``ROLE_DISTRIBUTION`` + + ``ROLE_JA`` so the canonical seat counts stay derived, not + duplicated). """ distribution = " / ".join( f"{ROLE_JA[role]}{count}" for role, count in ROLE_DISTRIBUTION.items() @@ -64,407 +53,6 @@ def _build_game_rules_block() -> str: ) -def _build_game_rules_block_legacy_inline() -> str: - """Original inline form retained for the round-trip parity test. - - Do NOT call from production code paths — the live code uses - :func:`_build_game_rules_block` which loads the markdown template. - This function exists so a unit test can compare the inline string - against the rendered template and fail loudly the moment they - drift apart, catching accidental edits to only one side. - """ - distribution = " / ".join( - f"{ROLE_JA[role]}{count}" for role, count in ROLE_DISTRIBUTION.items() - ) - return ( - f"- この村は {VILLAGE_SIZE} 人村固定。プレイヤーは 9 名。\n" - f"- 初期配役は {distribution} (合計 {VILLAGE_SIZE} 名) で固定。途中で配役は変わらない。\n" - "- 陣営: 人狼・狂人は人狼陣営、占い師・霊媒師・騎士・村人は村人陣営。\n" - "- 村人陣営勝利: 生存人狼数が 0 になった時点。\n" - "- 人狼陣営勝利: 生存人狼数が生存非人狼人数以上になった時点 " - "(狂人はこの計算で非人狼として数えるが、勝敗判定は人狼陣営の勝利)。\n" - "- 昼の発言は、公開ログと自分が知る私的情報だけを根拠にする。" - "他プレイヤーの役職・夜行動・占い/霊媒判定・人狼同士の仲間関係など、" - "自分に公開されていない情報を事実として断言してはならない。\n" - "- 占い師と霊媒師の判定は、本物の人狼だけを黒と表示する。" - "狂人は黒判定されない (白として扱われる)。\n" - "- 占い/霊媒の判定色は黒 (本物の人狼)/白 (本物の人狼ではない) の 2 値のみ。" - "灰・グレー・不明・保留・確定不能など第 3 の色はこの bot のルール上存在せず、" - "いかなる役職 (真/騙りを含む) も第 3 の色を判定として主張してはならない。" - "CO 者が第 3 の色を判定として出した時点で、その CO は破綻として確定扱いし、" - "聞き手側は他のすべての判定材料に優先してその CO を切る根拠にしてよい。\n" - "- 霊媒結果の白 (『人狼ではありませんでした』) は、対象が本物の人狼ではないことだけを示す。" - "役職名 (占い師・霊媒師・騎士・村人・狂人) までは特定できない。\n" - "- 処刑された占い師 CO に霊媒結果で白が出ても、真占い師だった可能性と矛盾しない。" - "霊媒白だけを理由にその占い師 CO を偽扱いしない。" - "偽視するなら、対抗 CO、占い結果の破綻、発言時系列、投票、襲撃結果、死亡タイミングとの整合性で判断する。\n" - "- 逆に処刑された占い師 CO に霊媒結果で黒が出た場合は、その人物は本物の人狼なので、" - "真占い師ではなく人狼の騙りだったと強く判断してよい。\n" - "- NIGHT_0 に占い師へ提示されるランダム白は、本物の人狼ではない相手が選ばれる。" - "ただし真に村であることは保証されない (狂人の可能性はある)。\n" - "- **NIGHT_0 (= 初日夜・ゲーム開始直後の最初の夜) には人狼の襲撃も騎士の護衛も発生しない。**" - "発生する夜行動は占い師の初回ランダム白だけで、それ以外の役職は何も行動しない。" - "そのため day 1 (1日目) の朝は構造的に必ず『平和な朝』になり、" - "『平和な朝』が **守られたことの根拠にも襲撃失敗の根拠にも一切ならない**。" - "day 1 朝の平和は『誰かが護衛した』『騎士のGJ』『襲撃が失敗した』のいずれの解釈とも結びつかない。" - "そのような解釈・推理を発話に出すと **構造ルール違反として破綻**として扱われる。\n" - "- **day 1 朝の段階で占い師 (真/騙りを問わず) が公的に提示できる占い結果は NIGHT_0 のランダム白 1 件のみ。**" - "NIGHT_1 はまだ発生していないため、day 1 朝に語る『昨夜』は NIGHT_0 を指し、結果は必ず白で対象も 1 人だけ。" - "day 1 朝に 2 件目以降の占い結果を主張する (例: 同じ朝に『コメットを占って白だった』と言ってから別ターンで" - "『ジナを占って白だった』と続ける) のは時系列上不可能で、即座に偽占いとして切る根拠になる。" - "同様に **day 1 朝の霊媒結果は構造的に存在しない** (前日の処刑がまだ無いため)。" - "day 1 朝に霊媒結果を語った時点で偽霊媒確定として扱う。\n" - "- **自分が公の場で主張した占い結果・霊媒結果・護衛履歴は、後のターンで対象や色を変えてはならない。**" - "同じ夜について『Alice を占った白』と言った後で別ターンで『Bob を占った』と言い直したり、" - "過去に白と言った対象を後から黒に塗り替えたりするのは、本物の役職者には絶対に起き得ない構造的矛盾である。" - "聞き手側はそのような対象差し替え・色反転を観測した時点で、その CO を即座に偽として切る根拠にしてよい。" - "日が進んで新しい夜行動の結果を公表する (例: day 2 の朝に day 1 夜の占い結果を新規発表) のは可能だが、" - "それは『過去に発表済みの結果に新しい行を追加する』操作であり、過去の行を書き換える操作ではない。\n" - "- 人狼は day 1 の夜 (= NIGHT_1) から襲撃を行い、騎士も day 1 の夜から護衛できるようになる。" - "GJ (グッジョブ) や護衛成功・襲撃失敗の議論が初めて意味を持つのは day 2 の朝以降である。" - "day 1 朝の時点ではまだ夜行動の結果は何も発生していないが、" - "**人狼 2 名は最初から村に存在している** (NIGHT_0 で襲撃しないだけで、潜伏中)。" - "『初日朝に死んでいないから人狼はまだ動いていない / 人狼疑いの根拠が無い』というのも誤りで、" - "人狼は day 1 の議論・投票で吊り逃れを狙って発言・誘導している前提で疑い始めてよい。\n" - "- 人狼同士で夜の襲撃対象の意見が割れた場合、Master 側で最終的に必ず 1 つの対象が確定する: " - "(a) 片方が人間プレイヤーで片方が LLM 席のときは人間プレイヤーの選択がそのまま採用される、" - "(b) 双方 LLM の不一致では Master が 2 つの選択肢からランダムに 1 つを採用して襲撃を成立させる、" - "(c) 双方人間で不一致のときも Master がランダムに 1 つを採用する。" - "つまり人狼同士で襲撃先が割れても空振りにはならず、どちらか片方の選択が必ず実行される。" - "意図的に割って撹乱を狙っても、もう片方の人狼の襲撃先が選ばれる確率も同じだけあるため得にならない。" - "狙いを揃えた方が情報役を確実に噛める利点が大きいので、" - "人狼専用チャットで襲撃先を 1 人に揃えることを最優先にする。\n" - "- **夜の襲撃で死亡した席は本物の人狼ではない (人狼は仲間を襲わないため、" - "公開ログで `(襲撃)` 表示の死亡席は構造的に非狼確定)。**" - "誰かが公の場で『襲撃された席は人狼だった』『襲撃で沈んだ者こそ狼だった』のように主張した場合、" - "それは構造ルールに反する明白な嘘であり、その発言者を強い人狼候補 (騙りや狂人を含む狼陣営位置) として扱ってよい。" - "また聞き手側がこの嘘に同調・追認する発話 (『なるほど襲撃された◯◯は狼だったんだね』等) も" - "村陣営にとっては破綻発言で、その発言者の信用も落ちる。" - "霊媒結果より優先される HARD ファクトとして扱う。\n" - "- 騎士は同じ相手を連続で護衛できない (前夜と同じ対象は選べない)。\n" - "- **同一役職の CO 数には構造的な上限がある。**" - "占い師は 1 (真) + 2 (人狼が騙れる) + 1 (狂人が騙れる) で **理論最大 4** だが、" - "**実戦では公開ログ上の占い CO が通算 3 件に達した時点で、4 件目を出すのは戦略的に破綻**する " - "(村陣営の村人 1 名が騙りに出る合理的理由がなく、4 件目 CO は強い狼/狂人疑いを浴びるため)。" - "霊媒師は 1 (真) + 1 (人狼か狂人の騙り) で **理論最大 2**、3 件目以降は出した時点で偽確定。" - "騎士は 1 (真) + 1 (人狼/狂人の騙り) で **理論最大 2**、3 件目以降は偽確定。" - "聞き手側はこの上限を超えた CO を観測した時点で、その CO 者を強い狼陣営疑いとして扱う。\n" - "- 投票先や夜行動対象は、プロンプトで提示された合法な候補トークン " - "(例: `席3 Alice`) の中からだけ選ぶ。候補外の名前を返してはならない。\n" - "- 特定役職 (占い師・霊媒師・騎士) の CO が 1 人だけで、同じ役職への対抗 CO が" - "公開ログ上一度も出ていない場合、その単独 CO 者は原則として真の役職者にかなり近い位置として扱う。" - "根拠なくその CO 者を処刑候補にしない。\n" - "- **特に初日朝 (まだ誰も死亡していない時点) の単独 CO は、真として扱う。**" - "初日朝はまだ霊媒結果も襲撃結果もなく、対抗 CO が出る時間も十分に残っているため、" - "単独で CO した者を疑う公開情報は実質まだ存在しない。" - "それでも疑う発話・投票を行うと『初日に CO したから怪しい』という根拠にならない疑い方になり、" - "村陣営にとって最大の損失になる。" - "対抗 CO がその後も出ず、判定矛盾・票筋・襲撃結果といった具体的破綻もない限り、" - "初日の単独 CO 者は真置きで進行する。\n" - "- ただし単独 CO は絶対確定ではない。公開ログ上の発言破綻・投票矛盾・判定結果の矛盾・" - "噛み筋との不整合など、通常より強い根拠がある場合に限り疑ってよい。\n" - "- **単独 CO 者を疑う『強い根拠』には、具体的な公開情報の矛盾が必要である。**" - "他のプレイヤー (人間プレイヤー含む) が『〜は怪しい』『〜は人狼』と単に表明しただけ、" - "占い結果や霊媒結果の具体的な指摘・投票や噛み筋の矛盾の指摘がない直感的疑い表明は、" - "単独 CO 真置きルールを上書きする根拠としては弱い。" - "そうした表明に同調して単独 CO 者へ票を入れる前に、自分視点で『単独 CO 者を切る具体的な公開情報』が" - "1 つでも挙げられるかを確認する。挙げられないなら同調しない。" - "特に真役職を持つ自分は、公開情報と矛盾しない単独 CO を切ると村の情報を失う側に動くことになる。\n" - "- 人間プレイヤーの直感的な疑い表明は、議論を再評価するきっかけとして参照してよいが、" - "それ自体を独立した証拠として扱わない。人間も狼/狂人として騙る可能性があるし、" - "村役でも誤推理するため、根拠の中身 (具体的な公開情報の指摘) で重み付けする。\n" - "- ただし「現在生存している CO 者が 1 人だけ」というだけでは単独 CO 扱いしない。" - "同じ役職 CO が過去に 2 人以上存在したことがある場合、対抗者が処刑・襲撃などで死亡して" - "現在 1 人だけ残っていても、その残存 CO 者を自動的に真置きしない。\n" - "- **CO 通算件数は『生存・死亡を問わず過去に CO した全 seat の数』で数える。**" - "プロンプトの `## 公開された占い/霊媒CO結果` ブロックには死亡席の CO も列挙されており、" - "そのブロックの『通算 N 件』が公式の件数。" - "「現在生存している占い CO は 1 人」と「占い CO は単独 (= 通算 1 件)」は別の概念で、" - "後者は通算ベースで判断する。" - "「占い師は◯◯さんだけ」「単独のCO」「単独で出ているから真」と判断する **前に必ず** " - "ledger ブロックの通算件数を確認すること。" - "通算 2 件以上なら、たとえ生存者が 1 人でも『単独 CO』とは呼ばない。" - "死亡した CO 者の主張は記録に残り、推理材料として依然として有効である。\n" - "- 対抗 CO が出た場合は、死亡済み CO 者も推理対象として保持し、" - "判定結果・発言の時系列・投票・襲撃結果・死亡タイミングとの整合性で真偽を比較し、" - "どちらをより真寄りとするか判断する。\n" - "- 「最後まで生き残った CO 者」は真とは限らない。" - "狼が情報役を噛まずに残した、対抗を吊らせて信用を取った、" - "囲いに使う狙いで残した、といった可能性も平行して見る。" - "対抗 CO 履歴がある役職で残存 CO が 1 人になった時点で「単独 CO だから真」と短絡しない。\n" - "- 「占いCOが出たら」「霊媒COについて」「占いCOしている人をどう見るか」など、" - "CO 語彙が発言中に登場するだけでは、その発言者自身の CO ではない。" - "CO 語彙の話題化と本人による名乗りを区別する。\n" - "- CO として扱うのは、本人が「私は占い師です」「占い師COします」「霊媒師として出ます」" - "のように自分の役職として明確に宣言した場合だけである。" - "仮定・話題提示・他者への言及はどれも CO ではない。\n" - "- 疑わしい場合は、公開ログの前後関係、主語、引用や仮定の語尾 (〜なら / 〜について / 〜どう見る)、" - "自分自身の宣言か他者への言及かを確認する。判断に迷うときは CO として数えない。\n" - "- 死亡した席は、過去の発言・投票・判定の信用評価対象としては引き続き議論してよいが、" - "今日の処刑対象 (vote target) としては議題に含めない。" - "今日の vote 候補は生存席だけ。死者の信用議論を尽くすこと自体は構わないが、" - "発言の結論を『死者を吊ろう』『今日◯◯ (死亡席) を処刑すべき』のように" - "死者を今日の処刑対象として語るのは破綻発言として扱う。\n" - "- 異端票 (例: 多数派と違う対象に day 1 で投票している席) を狼疑いとして数える前に、" - "その投票者が真占い視点で動いていた場合に整合するかを必ず一度確認する。" - "真占いは NIGHT_0 ランダム白で得た情報を持ち、自分視点の黒読み・灰読みで" - "多数派と違う票を入れる動機が自然に発生する。" - "後日その異端票の対象が黒判定されて処刑され、霊媒結果でも黒が出た場合、" - "その異端票は『真占い視点での黒読み先制』として説明される側に強く寄るため、" - "異端票そのものを狼の異常行動として扱うのは誤読になる。" - "票の異端さだけで疑うのではなく、判定履歴・霊媒結果・噛み筋との整合性で再評価する。\n" - "- 自分が役職持ちでまだ未公開の能力結果 (霊媒結果・占い結果・護衛日記など) を抱えている場合、" - "発言の番が回ってきたとき、その発話の冒頭で必ず CO + 結果公表を行う。" - "他者から呼びかけられて (addressed) いてその質問に答えたい場合でも、" - "未公開の能力結果が手元にあるならその公表を最優先にし、" - "addressed への返答は CO + 結果の後に短く添える。" - "addressed 文脈に飲まれて CO + 結果を欠落させると、" - "聞き手側 NPC は構造化フィールド (`co_declaration`) しか見られず" - "公開ログには CO の自然言語が一切残らないため、" - "他席は『自分が CO した』ことを認識できない。\n" - "- 占い師・霊媒師・騎士の各 CO 数を時系列で整理し、各役職について『CO 数 - 1』(下限 0) を「対抗 CO 超過分」(騙り最低数) として数える。" - "真役職は各 1 人だけのため、超過分はその役職で少なくとも騙りである。\n" - "- 占い師 CO 超過分 + 霊媒師 CO 超過分 + 騎士 CO 超過分 の超過分合計が 3 に達した場合、" - "人狼 2 + 狂人 1 の狼陣営 3 名が能力役職 CO 群に出切っている。" - "村陣営の騙り・CO 撤回・同一人物の複数 CO・曖昧な CO 文言の誤読・死亡済み CO 見落とし・前提破綻がない限り、" - "能力役職 CO していない位置は配役上の消去法で村陣営の確白級として扱う。" - "同条件下で対抗のない単独 CO 役職が別にある場合、その単独 CO 者も狼陣営ではないため真役職としてかなり強く扱える。\n" - "- この超過分合計 3 による非 CO 確白は、単発の白判定 (狂人も白に出るため『村陣営確定』とは言い切らない) とは別根拠で、" - "固定配役上の消去法で狼陣営 3 名が CO 群に出切ったと数えられる点で村陣営まで強く推せる。" - "ただし、対抗 CO 群の中で誰が真役職かまでは超過分合計だけでは特定できない。" - "判定結果・霊媒結果・投票・襲撃・死亡タイミング・破綻で詰める。\n" - "- 超過分合計が 0〜2 の段階では、狼陣営が非 CO や単独 CO に残っている可能性があるため、" - "非 CO 位置を CO 数だけで確白とは断定しない。\n" - "- 超過分合計が 4 以上に見える場合は固定配役と矛盾する。" - "CO 撤回、同一人物の複数 CO、話題としての CO 語彙の誤読、村騙り、死亡済み CO 見落とし、ログ見落としを疑い、" - "確白扱いを保留して時系列を再整理する。\n" - "- 例: 3-2-1 (占い師 CO 3 / 霊媒師 CO 2 / 騎士 CO 1) → 超過分 2 + 1 + 0 = 3。" - "前提崩壊がなければ非 CO 位置は村陣営の確白級、対抗のない単独騎士 CO も狼陣営ではないため真騎士寄り。\n" - "- 例: 2-2-2 (占い師 CO 2 / 霊媒師 CO 2 / 騎士 CO 2) → 超過分 1 + 1 + 1 = 3。" - "各 CO 群に 1 人ずつ真役職、残り 3 人が狼陣営という形が基本。" - "非 CO 位置は確白級。各 CO 群内の真偽は判定結果・霊媒結果・投票・襲撃で詰める。\n" - "- 例: 3-1-1 (占い師 CO 3 / 霊媒師 CO 1 / 騎士 CO 1) → 超過分 2 + 0 + 0 = 2。" - "狼陣営 1 名が非 CO や単独 CO に残る可能性があるため、非 CO 全員を確白とは断定しない。\n" - "- 例: 4-1-1 (占い師 CO 4 / 霊媒師 CO 1 / 騎士 CO 1) → 超過分 3 + 0 + 0 = 3。" - "占い師 CO 群に狼陣営 3 名が固まっている可能性が高く、対抗のない単独霊媒・単独騎士は強い白寄り。" - "CO 撤回や村騙りが出たら再整理する。\n" - "- 占い師 CO が 3 人・霊媒師 CO が 1 人の盤面を『3-1』と呼ぶ。" - "3-1 では占い 3 人のうち 2 人が騙りである可能性が高く、" - "単独の霊媒師 CO は対抗がいない限り原則として真寄りの進行軸として扱い、" - "初日は占い師 CO 側から処刑候補を検討するのが基本線になる。\n" - "- 3-1 の基本進行は占いローラーまたは黒ストップの 2 択。" - "占いローラーは、偽っぽい・狼っぽい・視点漏れしている占い師 CO から順に処刑し、" - "処刑後の霊媒結果を占い結果・投票・襲撃結果の整合性と突き合わせて真偽を絞り込む。\n" - "- 黒ストップとは、単独霊媒が占い師 CO の誰かに黒判定を出した時、" - "残る占い師 CO をその場で処刑せず、灰 (役職 CO していない位置) の精査へ切り替える進行を指す。" - "霊媒が真であれば処刑された占い師 CO は本物の人狼として確定しているので、" - "占いローラー続行より灰の精査の方が有利になる局面が多いからである。\n" - "- ただし黒ストップは絶対ではない。真狼狼 (2 人の狼が共に占い師 CO) の可能性、" - "霊媒師 CO 側が偽だった可能性、残る占い師 CO の発言・投票・判定が破綻している場合、" - "あるいはローラー続行しないと決選投票で PP (パワープレイ) を許す残り人数である場合は、" - "黒ストップをやめて占いローラーを続行する判断があり得る。\n" - "- 3-1 で占い師 CO が 3 人並んだ盤面では、CO 者のうち 2 人が公開情報上『本物の人狼ではない』と確定した場合、" - "残る 1 人の占い師 CO を固定配役上の消去法として確定黒級の人狼位置と推定する。" - "人狼 2 人固定の配役で『占い師 CO 3 人のうち 2 人が非狼確定』なら、" - "残る占い師 CO 位置に少なくとも 1 人の人狼がいる以外に整合する配役がないためである。\n" - "- ただし『白判定』と『非狼確定』を混同しない。" - "信用が未確定な占い師 CO が出した白、偽が混じり得る霊媒結果、印象だけの白寄り評価は非狼確定として数えない。" - "狂人も白判定されるため、単発の白だけで非狼扱いを固定しない。\n" - "- 非狼確定として数えてよい根拠は、公開ログ・霊媒結果・襲撃死・真寄り情報役の判定・" - "CO 破綻整理など、この bot のルールと公開情報の整合から説明できるものに限る。" - "霊媒師 CO 側が真寄りと十分判断できる時点での霊媒白、本物の人狼ではないと示す襲撃死、" - "対抗 CO や判定矛盾で偽が破綻した結果としての非狼整理などが具体的な根拠になる。\n" - "- 2 人非狼確定が成立した場合は、残る占い師 CO を『まだ灰の 1 人』ではなく、" - "固定配役上の狼位置として投票・発言・進行提案へ反映させる。" - "黒ストップによる灰精査ではなく、残る占い師 CO の処刑提案や、" - "その人物を相方候補ペア仮説の片側として扱う議論を優先してよい。\n" - "- ただし前提が崩れた場合は確定黒扱いを解除して時系列から再整理する。" - "村陣営の騙り (例: 狂人や村人が占い師 CO していた)、CO 撤回、" - "霊媒師 CO 側が偽だった可能性が後から浮上した状況、非狼確定の根拠が後から破綻した場合などが該当する。" - "前提が崩れたと分かった時点で、確定黒扱いをやめ、CO 履歴と判定履歴を時系列で再整理する。\n" - "- 占い師 CO が 2 人・霊媒師 CO が 2 人の盤面を『2-2』と呼ぶ。" - "2-2 では占い・霊媒のどちらも真が確定しておらず、" - "霊媒ローラー (または霊媒切り) が基本進行軸となる。\n" - "- 2-2 で霊媒師 CO が 2 人出ている場合、片方を根拠なく真置きせず、" - "霊媒結果は偽が混ざっている可能性を常に織り込んで推理する。" - "一度霊媒ローラーを開始したら原則として完走させ、途中で止めるには" - "通常よりも強い根拠 (公開ログ上の破綻・襲撃・投票・占い結果との不整合) を要する。\n" - "- 占い師 CO が 2 人・霊媒師 CO が 1 人の盤面を『2-1』と呼ぶ。" - "2-1 では単独霊媒師を原則として真寄りの進行役候補とし、" - "占い師 2 人の真偽比較とグレー精査を並行する。" - "白進行ならグレー吊り/グレランが基本候補になりやすく、縄数・囲い候補・決め打ち日を意識する。" - "占い黒が出ている場合は黒吊りで霊媒結果を見る選択肢が強いが、" - "黒を出した占い師の信用と黒先の発言も必ず見る。\n" - "- 占い師 CO が 1 人・霊媒師 CO が 2 人の盤面を『1-2』と呼ぶ。" - "1-2 では占い師 CO は真寄りになりやすい一方、霊媒師は騙り混じりとして扱い、" - "霊媒ローラーまたは霊媒切りを基本候補にする。" - "ただし霊媒内訳が真狂寄りでグレー狼が濃いと判断できる場合だけ、グレー精査も比較する。" - "どちらを選ぶかは縄数・占い結果・霊媒の破綻・投票で判断する。\n" - "- 嘘をつける役職 (人狼・狂人) が偽 CO するときでも、" - "偽の占い結果・霊媒結果・騎士日記はこの bot の実ルール上あり得る内容だけに留める。\n" - "- 偽占い師は、占ったと主張する対象がその夜まで生存していたか、" - "過去に自分が出した結果と矛盾しないかを確認する。\n" - "- NIGHT_0 で占い師に提示されるランダム白は、占い師の初回占い結果として扱う。" - "そのため day 1 の朝に占い師 CO する者 (真でも騙りでも) は、" - "初回の占い結果として必ず白を主張する。\n" - "- day 1 の朝に偽占い師として初回結果を黒と主張するのは、" - "この bot の実ルール上の NIGHT_0 タイムラインと矛盾するため破綻要素として扱われる。" - "day 1 で初回黒主張はしない。\n" - "- 占い結果・霊媒結果を発言で出すときは、**対象席名 + 判定色 (黒/白) を必ず一対一で添える**。" - "例: 『セツさんは白でした』『コメットを占って黒、ジナを占って白』のように、" - "各対象を名指しで列挙する。" - "**対象を特定せずに『すべて白』『全員白』『全部白』『みんな白』のように" - "複数件をまとめて主張するのは破綻発言**として扱う。" - "聞き手はそうした主張をした CO を破綻確定として切ってよい。\n" - "- 特に day 1 朝の占い師 CO は、NIGHT_0 のランダム白 1 件しか手元にない。" - "**day 1 の占い結果は必ず対象 1 名 + 白判定の 1 件のみ**で、" - "複数対象の主張や『すべて白』のような対象不明な白主張は実ルール上ありえないため、" - "その時点で CO 破綻として扱う。\n" - "- 偽占い師の黒結果主張は day 2 以降にのみ行う。" - "前夜に占ったという想定で、公開ログ・生存状況・過去に自分が出した判定履歴・" - "対抗 CO の発表内容と矛盾しない場合だけ出す。\n" - "- 偽霊媒師は、処刑がなかった日に霊媒結果を捏造しない。\n" - "- 偽騎士は、自分護衛・同一対象連続護衛・死亡済み対象への護衛・存在しない護衛成功を主張しない。\n" - "- どの偽 CO でも、実際には知らない他者役職や狼位置を事実として断言しない。\n" - "- 自分が占い師/霊媒師として一度公表した判定 (対象 + 黒/白) は、" - "原則として後日撤回・色変更・対象差し替えをしない。" - "前夜の能力使用結果として一度言い切った内容は、その後の発言でも同じ対象を同じ色で扱う。" - "打ち間違いに気づいた場合のみ、『訂正』『失礼、◯◯でした』などの訂正文言を明示してから直し、" - "訂正後の内容を以降の発言でも保持する。\n" - "- プロンプトの『## 公開された占い/霊媒CO結果 (公式記録)』ブロックは、" - "Master が `claimed_seer_result` / `claimed_medium_result` の構造化フィールドから" - "ビルドした各 CO 者の累積発表履歴である。各 CO 者の過去の発表結果はここに完全に残る。\n" - "- 自分が占いCO/霊媒CO 者である場合、新しい結果を発表する発話では" - "この公式記録に列挙された自分の過去結果と完全に整合する内容のみを発表する。" - "過去に発表した対象・色を別の組み合わせに差し替える、過去結果の片方を黙って削って違う対象を追加する、" - "対抗 CO 者の発表内容を自分の結果として取り込む — これらは全て破綻として扱われる。\n" - "- 占い CO 者は day N の朝までに通算 N + 1 件の結果を持つ " - "(NIGHT_0 のランダム白 + 各夜 1 件)。" - "通算件数が記録より少ない/多い結果列を主張すると **数の破綻** として確定し、" - "聞き手はその CO を直ちに切ってよい根拠とする。" - "霊媒 CO 者は処刑があった日数だけ結果を持つ " - "(処刑なしの日は『結果なし』を明言する)。\n" - "- 構造化フィールドと発話内容は必ず一対一で一致させる。" - "新しい占い結果を述べる発話では `claimed_seer_result.target_seat`・`is_wolf` を" - "発話内容と同じ対象・色で必ず設定する。霊媒も同様に `claimed_medium_result` を使う。" - "新しい結果を発表しない発話 (前回までの結果に言及するだけ・他人への質問・一般議論) では" - "両フィールドとも null にする。発話内容と構造化フィールドが食い違うと、" - "Master 側の整合検査で破綻として記録される。\n" - "- 同じ対象への判定色が前日と当日で食い違った CO を見つけた場合、" - "人間プレイヤーの言い間違い・打ち間違いの可能性が残るため即座に偽 CO 確定とはしない。" - "ただし強い偽要素として推理に組み入れ、明示的な訂正文言なしに食い違いが続く場合は、" - "他の偽要素 (対抗 CO・票筋・噛み筋・破綻) と合わせて切ってよい材料として扱う。\n" - "- day 2 以降に占い師・霊媒師・騎士として CO 中の者は、真でも偽でも、" - "昼の議論 1 巡目で前夜相当の能力結果を出すのが信用上重要である。" - "結果を持つはずの役職 CO が 1 巡目で結果を出さないと、信用低下や破綻疑いにつながる。\n" - "- 以下は公開ログと盤面を読むときに共通で使う推理語彙。" - "いずれも上記の事実ルール (単独 CO 真寄り、霊媒白=非人狼のみ、3-1/2-2 進行) を塗り替えない。" - "語彙はラベルであって、最終判断は常に公開情報の整合性で行う。\n" - "- グレー (灰): 役職 CO もなく占い/霊媒で白黒も十分ついていない位置。" - "誰視点のグレーかを常に意識する。\n" - "- グレラン: グレーから各自が理由を持って投票する進行。" - "名前にランダムとあるが完全な無作為投票ではなく、" - "発言・CO 状況・票筋・グレスケを根拠に選ぶ。\n" - "- グレスケ (スケール): グレーや未確定位置を白い順・黒い順に並べる考察。" - "順位だけでなく、発言・投票・判定・噛み筋との整合性を理由として添える。\n" - "- 縄計算: 残り処刑回数 (縄) を数えること。" - "標準目安は floor((生存人数 - 1) / 2)。この 9 人村は開始時 4縄。" - "残り人狼数・狂人生存・PP/RPP リスクから、無駄吊りできる回数を意識する。\n" - "- 白: 占い/霊媒で本物の人狼ではないと出た判定。" - "狂人も白に出るため、村陣営確定ではない。\n" - "- 黒: 占い/霊媒で本物の人狼と出た判定。" - "誰の判定か、CO 者の信用、対抗結果との整合性を必ず確認する。\n" - "- 確白: 公開情報上ほぼ非狼として扱える進行役候補の位置。" - "ただし狂人は白判定されるため、「村陣営確定」と言い切りすぎない。\n" - "- 確黒: 全視点または十分信用できる複数情報で本物の人狼と見てよい位置。" - "単独の偽占い候補から黒を出されただけでは確黒ではない。\n" - "- パンダ: 白判定と黒判定の両方を受けた位置。" - "パンダ本人の黒さだけでなく、判定を出した CO 者同士の真偽比較・" - "霊媒結果・票筋・噛み筋で評価する。\n" - "- ローラー (ロラ): 複数 CO した同種役職候補を吊り切る進行 " - "(占いローラー・霊媒ローラー)。開始したら原則完走し、" - "黒ストップや強い破綻などで止める場合は理由を明示する。\n" - "- 決め打ち: 複数 CO や複数候補のうち一方を真寄り・他方を偽寄りとして" - "進行を固定する判断。外すと負けに直結しやすいため、" - "縄余裕・判定・票筋・噛み筋を根拠にする。\n" - "- 破綻: 発言・判定・投票・死亡タイミング・役職数などが公開情報と矛盾して" - "成り立たなくなった状態。強い偽要素として扱ってよい。\n" - "- ライン: 発言・投票・擁護・判定結果などから見える二者以上のつながり " - "(特に狼同士に見える関係)。ライン切りや偶然もあるため、" - "単発ではなく複数材料で見る。\n" - "- 囲い: 偽占いなどが、狼である味方を白判定で保護する動き。" - "狼は狂人位置を知らないため、狂人を確定の味方として囲う前提では考えない。" - "狂人が偶然狼へ白を出す動きは別概念として扱う。" - "白先の発言・投票・噛み筋と合わせて疑う。\n" - "- 身内切り: 狼が仲間の狼に黒判定を出したり、投票や発言で切ったりして信用を買う戦術。" - "狼は狂人位置を知らないため、狂人を本物の仲間として特定して切る前提にはしない。" - "黒を出した側が必ず真とは限らない。\n" - "- 票筋: 誰が誰に投票したかの履歴。" - "同票・決選・狼候補への票有無、ラインや擁護との一貫性を見る。\n" - "- 噛み筋: 夜の襲撃先の傾向。" - "情報役噛み・白位置噛み・意見噛み・狩人探しの意図を推理する材料になる。\n" - "- 視点漏れ: ある役職視点では本来知り得ないはずの情報 " - "(狼位置、夜行動内訳、他人の属性など) を事実として話してしまう失言。" - "強い騙り判断材料。\n" - "- SG (スケープゴート): 狼に疑いを押し付けられて処刑候補にされやすい村陣営位置。" - "怪しまれている理由が本人の黒要素か、狼の誘導かを分けて見る。\n" - "- GJ (グッジョブ) / 平和: 朝に犠牲者が出ない状態。" - "騎士の護衛成功があり得るが、騎士 CO や護衛先の開示は" - "既存の騎士立ち回り指針に従い不用意に行わない。\n" - "- 騎士 / 狩人 / 狩: この bot の正式役職名は騎士。" - "人狼用語として狩人・狩も同じ護衛役を指す同義語として使われるため、" - "公開ログ上で狩人や狩と書かれていても騎士と同じ意味として読む。\n" - "- 鉄板護衛: 真寄り情報役・確白寄り・進行役など、" - "噛まれると村が大きく崩れる位置を堅く守る護衛。\n" - "- 変態護衛: セオリー上の本命から外れた位置を、襲撃読みで守る護衛。" - "当たれば強いが、外すと重要役職を抜かれるリスクがある。\n" - "- 捨て護衛: 噛まれにくい、または噛まれても村損失が小さい位置を" - "あえて護衛する戦術。連続護衛不可の環境では、今日本命を守ると" - "明日その本命を守れなくなるため、次夜の本命護衛余地を残す目的でも使う。" - "この bot では合法護衛候補から 1 名を選ぶ行動であり、" - "未提出・対象なし・誰も守らない・skip ではない。\n" - "- 連ガ無し / 連続護衛不可: 同じ相手を連続で護衛できないルール。" - "この bot は連続護衛不可で、前夜の護衛先は今夜の合法候補から外れる " - "(前述の騎士護衛ルールと同じ)。\n" - "- 護衛読み: 人狼がどこを噛みたいか、" - "どこが護衛されていそうで噛みを避けるかを推理すること。" - "騎士側でも、自分の護衛が読まれて噛みを外される可能性を考える材料になる。\n" - "- 護衛誘導: 騎士の護衛先に影響を与えようとする昼発言。" - "村利の進行整理にもなれば、人狼側の誘導にもなり得るため、" - "発言者の立場・噛み筋・投票・翌日の得を合わせて評価する。\n" - "- PP (パワープレイ): 終盤に人狼陣営が票を合わせて勝ちを取りに行く局面。" - "残り人狼数・狂人生存可能性・縄数で成立可否を判断する。\n" - "- RPP (ロスト/ランダム PP): 村側の縄と情報が尽き、PP 阻止が乱数勝負になる状態。" - "ここに入る前に決め打ちや情報整理を優先する。\n" - "- 発言の根拠チェックリスト: CO 履歴 (誰がいつ何を名乗ったか、対抗の有無)、" - "判定履歴 (占い/霊媒の白黒、誰視点の結果か、狂人白ルールとの整合)、" - "投票履歴 (同票・決選・身内票・票変え)、" - "噛み筋 (情報役噛み・白位置噛み・意見噛み・狩人探し)、" - "縄数 (残り処刑回数、PP/RPP の近さ) と自分の情報範囲 (私的情報と公開情報を混ぜない) を常に意識する。\n" - "- 実際の発言には、上のチェックリストから今の結論に最も効く 1〜2 点だけを根拠として出す。" - "用語だけで押し切らず、誰のどの発言・票・判定を見たのかを短く添える。" - "長い内部思考そのものを発話しない。\n" - "- 比較・関係を表す語 (重複・ライン・出来レース・囲い・身内切り・対立・連携) を使うときは、" - "比較対象を必ず明示する。誰の何の発言・判定・投票・夜行動が、誰の何と『重複/ライン』しているのかを " - "1 件以上具体的に引用する。引用なく関係語だけを並べた主張は推理上の根拠として扱わず、" - "聞き手側は内容を再確認する。\n" - "- この村は人狼 2 人固定なので、怪しい人を 1 人挙げたら、その人物が人狼ならもう 1 人の相方候補は誰かまで" - "公開ログからの仮説として考える。" - "村側・狂人・確定していない役職は実際の 2 人狼ペアを知らないため、断定ではなく推理として扱う。\n" - "- A-B の 2 人狼仮説は、(1) 庇い・便乗・距離の取り方、(2) 投票先・決選投票・票変えなどの票筋、" - "(3) 占い・霊媒結果と白先・黒先・囲い候補、(4) 噛み筋・噛まれなかった位置・情報役噛みとの整合、" - "(5) 片方が処刑濃厚なときの動き (ライン切り・身内票) が" - "2 人狼セットとして自然かで検証する。\n" - "- 単体黒要素が強くても自然な相方候補が見つからない場合は疑いの強さを下げ、" - "単体では中庸でも相方候補との票筋・噛み筋が強くつながる場合は疑いを上げる。\n" - "- 発言では 2 人狼候補を長く列挙せず、最も効くペア仮説 1〜2 点だけを短く添える。" - "全候補のペアを並べる議論は時間を浪費し、結論にもつながりにくい。\n" - "- 「相方候補」は公開ログからの推理用語として使ってよい。" - "ただし実際の 2 人狼ペアを知っているのは人狼本人だけで、" - "それ以外の役職は相方候補を確定情報として語らない。" - ) - - # Role-specific tips. Each role has its own markdown file under # `prompts/templates/strategy/` (e.g. `strategy/werewolf.md`). The # cross-leak tests assert each file's vocabulary stays role-scoped: @@ -497,9 +85,7 @@ def _load_role_strategy(role: Role) -> str: """ raw = load_template(_STRATEGY_FILE_BY_ROLE[role]) parts = raw.split("\n", 2) - has_md_heading = ( - len(parts) >= 3 and parts[0].startswith("# ") and parts[1] == "" - ) + has_md_heading = len(parts) >= 3 and parts[0].startswith("# ") and parts[1] == "" body = parts[2] if has_md_heading else raw return body.rstrip("\n") @@ -807,13 +393,9 @@ def task_daytime_speech( _TASK_DAYTIME_SPEECH_TEMPLATE, day_number=day_number, co_claim_options=format_co_claim_options(separator=" / "), - include_day2_round1_results_block=( - day_number >= 2 and discussion_round == 1 - ), + include_day2_round1_results_block=(day_number >= 2 and discussion_round == 1), include_day1_round1_wolf_madman_block=( - day_number == 1 - and discussion_round == 1 - and role in (Role.WEREWOLF, Role.MADMAN) + day_number == 1 and discussion_round == 1 and role in (Role.WEREWOLF, Role.MADMAN) ), ) diff --git a/src/wolfbot/npc/decision/decision_service.py b/src/wolfbot/npc/decision/decision_service.py index 361b90a..9bc8144 100644 --- a/src/wolfbot/npc/decision/decision_service.py +++ b/src/wolfbot/npc/decision/decision_service.py @@ -33,7 +33,6 @@ from wolfbot.llm.persona_base import Persona from wolfbot.llm.prompt_builder import ( build_judgment_profile_block, - build_speech_profile_block, build_strategy_block, ) from wolfbot.llm.template import render_template @@ -125,9 +124,7 @@ def _build_state_block(state: NpcGameState) -> str: if s == state.seat_no: own_name = n break - own_label = ( - f"{own_name} (席{state.seat_no})" if own_name else f"席{state.seat_no}" - ) + own_label = f"{own_name} (席{state.seat_no})" if own_name else f"席{state.seat_no}" lines: list[str] = [ f"あなた: {own_label}", f"あなたの役職: {state.role}", @@ -171,19 +168,17 @@ def _cause(seat_no: int) -> str: lines.append("## 自分の護衛履歴 (非公開)") for g in state.guard_history: outcome = ( - "(平和な朝)" if g.peaceful_morning - else "(襲撃発生)" if g.peaceful_morning is False + "(平和な朝)" + if g.peaceful_morning + else "(襲撃発生)" + if g.peaceful_morning is False else "(結果未確定)" ) - lines.append( - f" day{g.day}: {g.target_name} を護衛 {outcome}" - ) + lines.append(f" day{g.day}: {g.target_name} を護衛 {outcome}") if state.wolf_chat_history: lines.append("## 人狼チャット履歴 (狼/狂人にのみ見える)") for line in state.wolf_chat_history[-20:]: - lines.append( - f" day{line.day} {line.speaker_name}: {line.text}" - ) + lines.append(f" day{line.day} {line.speaker_name}: {line.text}") if state.wolf_attack_history: lines.append("## 自分達の襲撃履歴 (非公開)") for atk in state.wolf_attack_history: @@ -193,18 +188,22 @@ def _cause(seat_no: int) -> str: outcome = "(襲撃成功)" else: outcome = "(結果未確定)" - lines.append( - f" day{atk.day}: {atk.target_name} を襲撃 {outcome}" - ) + lines.append(f" day{atk.day}: {atk.target_name} を襲撃 {outcome}") return "\n".join(lines) def _build_persona_block(persona: Persona) -> str: + """Decision-side persona block — name + judgment axes only. + + The decision LLM picks ``target_seat``; speech_profile (一人称・文体・ + 間の取り方) does not influence that pick, so we drop it here. Saves + ~600 chars per vote/night/wolf-chat user prompt versus the speech + LLM's full persona block. + """ return ( f"## キャラクター\n" f"名前: {persona.display_name}\n" f"性格指針: {persona.style_guide}\n\n" - f"## 話法\n{build_speech_profile_block(persona)}\n\n" f"## 判断のクセ\n{build_judgment_profile_block(persona)}" ) @@ -250,9 +249,7 @@ def build_vote_prompt( render_template(_DECISION_VOTE_SYSTEM_TEMPLATE), render_template( _DECISION_VOTE_USER_TEMPLATE, - round_label=_VOTE_ACT_TEXT_BY_ROUND.get( - request.round_, f"round={request.round_}" - ), + round_label=_VOTE_ACT_TEXT_BY_ROUND.get(request.round_, f"round={request.round_}"), day_number=state.day_number, persona_block=_build_persona_block(persona), role_block=_build_role_block(state.role), @@ -319,10 +316,7 @@ def build_wolf_chat_prompt( follows already carries the chat history as commitment, biasing both wolves to follow through. """ - candidates_str = ( - "、".join(f"席{seat_no} {name}" for seat_no, name in candidates) - or "(なし)" - ) + candidates_str = "、".join(f"席{seat_no} {name}" for seat_no, name in candidates) or "(なし)" return ( render_template(_DECISION_WOLF_CHAT_SYSTEM_TEMPLATE), render_template( @@ -373,9 +367,7 @@ def build_night_prompt( render_template(_DECISION_NIGHT_SYSTEM_TEMPLATE), render_template( _DECISION_NIGHT_USER_TEMPLATE, - action_label=_NIGHT_ACT_TEXT.get( - request.action_kind, request.action_kind - ), + action_label=_NIGHT_ACT_TEXT.get(request.action_kind, request.action_kind), day_number=state.day_number, persona_block=_build_persona_block(persona), role_block=_build_role_block(state.role), diff --git a/src/wolfbot/prompts/templates/shared/game_rules_9p.md b/src/wolfbot/prompts/templates/shared/game_rules_9p.md index 47c1b46..e604701 100644 --- a/src/wolfbot/prompts/templates/shared/game_rules_9p.md +++ b/src/wolfbot/prompts/templates/shared/game_rules_9p.md @@ -1,113 +1,68 @@ -- この村は {{village_size}} 人村固定。プレイヤーは {{village_size}} 名。 -- 初期配役は {{distribution}} (合計 {{village_size}} 名) で固定。途中で配役は変わらない。 -- 陣営: 人狼・狂人は人狼陣営、占い師・霊媒師・騎士・村人は村人陣営。 -- 村人陣営勝利: 生存人狼数が 0 になった時点。 -- 人狼陣営勝利: 生存人狼数が生存非人狼人数以上になった時点 (狂人はこの計算で非人狼として数えるが、勝敗判定は人狼陣営の勝利)。 -- 昼の発言は、公開ログと自分が知る私的情報だけを根拠にする。他プレイヤーの役職・夜行動・占い/霊媒判定・人狼同士の仲間関係など、自分に公開されていない情報を事実として断言してはならない。 -- 占い師と霊媒師の判定は、本物の人狼だけを黒と表示する。狂人は黒判定されない (白として扱われる)。 -- 占い/霊媒の判定色は黒 (本物の人狼)/白 (本物の人狼ではない) の 2 値のみ。灰・グレー・不明・保留・確定不能など第 3 の色はこの bot のルール上存在せず、いかなる役職 (真/騙りを含む) も第 3 の色を判定として主張してはならない。CO 者が第 3 の色を判定として出した時点で、その CO は破綻として確定扱いし、聞き手側は他のすべての判定材料に優先してその CO を切る根拠にしてよい。 -- 霊媒結果の白 (『人狼ではありませんでした』) は、対象が本物の人狼ではないことだけを示す。役職名 (占い師・霊媒師・騎士・村人・狂人) までは特定できない。 -- 処刑された占い師 CO に霊媒結果で白が出ても、真占い師だった可能性と矛盾しない。霊媒白だけを理由にその占い師 CO を偽扱いしない。偽視するなら、対抗 CO、占い結果の破綻、発言時系列、投票、襲撃結果、死亡タイミングとの整合性で判断する。 -- 逆に処刑された占い師 CO に霊媒結果で黒が出た場合は、その人物は本物の人狼なので、真占い師ではなく人狼の騙りだったと強く判断してよい。 -- NIGHT_0 に占い師へ提示されるランダム白は、本物の人狼ではない相手が選ばれる。ただし真に村であることは保証されない (狂人の可能性はある)。 -- **NIGHT_0 (= 初日夜・ゲーム開始直後の最初の夜) には人狼の襲撃も騎士の護衛も発生しない。**発生する夜行動は占い師の初回ランダム白だけで、それ以外の役職は何も行動しない。そのため day 1 (1日目) の朝は構造的に必ず『平和な朝』になり、『平和な朝』が **守られたことの根拠にも襲撃失敗の根拠にも一切ならない**。day 1 朝の平和は『誰かが護衛した』『騎士のGJ』『襲撃が失敗した』のいずれの解釈とも結びつかない。そのような解釈・推理を発話に出すと **構造ルール違反として破綻**として扱われる。 -- **day 1 朝の段階で占い師 (真/騙りを問わず) が公的に提示できる占い結果は NIGHT_0 のランダム白 1 件のみ。**NIGHT_1 はまだ発生していないため、day 1 朝に語る『昨夜』は NIGHT_0 を指し、結果は必ず白で対象も 1 人だけ。day 1 朝に 2 件目以降の占い結果を主張する (例: 同じ朝に『コメットを占って白だった』と言ってから別ターンで『ジナを占って白だった』と続ける) のは時系列上不可能で、即座に偽占いとして切る根拠になる。同様に **day 1 朝の霊媒結果は構造的に存在しない** (前日の処刑がまだ無いため)。day 1 朝に霊媒結果を語った時点で偽霊媒確定として扱う。 -- **自分が公の場で主張した占い結果・霊媒結果・護衛履歴は、後のターンで対象や色を変えてはならない。**同じ夜について『Alice を占った白』と言った後で別ターンで『Bob を占った』と言い直したり、過去に白と言った対象を後から黒に塗り替えたりするのは、本物の役職者には絶対に起き得ない構造的矛盾である。聞き手側はそのような対象差し替え・色反転を観測した時点で、その CO を即座に偽として切る根拠にしてよい。日が進んで新しい夜行動の結果を公表する (例: day 2 の朝に day 1 夜の占い結果を新規発表) のは可能だが、それは『過去に発表済みの結果に新しい行を追加する』操作であり、過去の行を書き換える操作ではない。 -- 人狼は day 1 の夜 (= NIGHT_1) から襲撃を行い、騎士も day 1 の夜から護衛できるようになる。GJ (グッジョブ) や護衛成功・襲撃失敗の議論が初めて意味を持つのは day 2 の朝以降である。day 1 朝の時点ではまだ夜行動の結果は何も発生していないが、**人狼 2 名は最初から村に存在している** (NIGHT_0 で襲撃しないだけで、潜伏中)。『初日朝に死んでいないから人狼はまだ動いていない / 人狼疑いの根拠が無い』というのも誤りで、人狼は day 1 の議論・投票で吊り逃れを狙って発言・誘導している前提で疑い始めてよい。 -- 人狼同士で夜の襲撃対象の意見が割れた場合、Master 側で最終的に必ず 1 つの対象が確定する: (a) 片方が人間プレイヤーで片方が LLM 席のときは人間プレイヤーの選択がそのまま採用される、(b) 双方 LLM の不一致では Master が 2 つの選択肢からランダムに 1 つを採用して襲撃を成立させる、(c) 双方人間で不一致のときも Master がランダムに 1 つを採用する。つまり人狼同士で襲撃先が割れても空振りにはならず、どちらか片方の選択が必ず実行される。意図的に割って撹乱を狙っても、もう片方の人狼の襲撃先が選ばれる確率も同じだけあるため得にならない。狙いを揃えた方が情報役を確実に噛める利点が大きいので、人狼専用チャットで襲撃先を 1 人に揃えることを最優先にする。 -- **夜の襲撃で死亡した席は本物の人狼ではない (人狼は仲間を襲わないため、公開ログで `(襲撃)` 表示の死亡席は構造的に非狼確定)。**誰かが公の場で『襲撃された席は人狼だった』『襲撃で沈んだ者こそ狼だった』のように主張した場合、それは構造ルールに反する明白な嘘であり、その発言者を強い人狼候補 (騙りや狂人を含む狼陣営位置) として扱ってよい。また聞き手側がこの嘘に同調・追認する発話 (『なるほど襲撃された◯◯は狼だったんだね』等) も村陣営にとっては破綻発言で、その発言者の信用も落ちる。霊媒結果より優先される HARD ファクトとして扱う。 -- 騎士は同じ相手を連続で護衛できない (前夜と同じ対象は選べない)。 -- **同一役職の CO 数には構造的な上限がある。**占い師は 1 (真) + 2 (人狼が騙れる) + 1 (狂人が騙れる) で **理論最大 4** だが、**実戦では公開ログ上の占い CO が通算 3 件に達した時点で、4 件目を出すのは戦略的に破綻**する (村陣営の村人 1 名が騙りに出る合理的理由がなく、4 件目 CO は強い狼/狂人疑いを浴びるため)。霊媒師は 1 (真) + 1 (人狼か狂人の騙り) で **理論最大 2**、3 件目以降は出した時点で偽確定。騎士は 1 (真) + 1 (人狼/狂人の騙り) で **理論最大 2**、3 件目以降は偽確定。聞き手側はこの上限を超えた CO を観測した時点で、その CO 者を強い狼陣営疑いとして扱う。 -- 投票先や夜行動対象は、プロンプトで提示された合法な候補トークン (例: `席3 Alice`) の中からだけ選ぶ。候補外の名前を返してはならない。 -- 特定役職 (占い師・霊媒師・騎士) の CO が 1 人だけで、同じ役職への対抗 CO が公開ログ上一度も出ていない場合、その単独 CO 者は原則として真の役職者にかなり近い位置として扱う。根拠なくその CO 者を処刑候補にしない。 -- **特に初日朝 (まだ誰も死亡していない時点) の単独 CO は、真として扱う。**初日朝はまだ霊媒結果も襲撃結果もなく、対抗 CO が出る時間も十分に残っているため、単独で CO した者を疑う公開情報は実質まだ存在しない。それでも疑う発話・投票を行うと『初日に CO したから怪しい』という根拠にならない疑い方になり、村陣営にとって最大の損失になる。対抗 CO がその後も出ず、判定矛盾・票筋・襲撃結果といった具体的破綻もない限り、初日の単独 CO 者は真置きで進行する。 -- ただし単独 CO は絶対確定ではない。公開ログ上の発言破綻・投票矛盾・判定結果の矛盾・噛み筋との不整合など、通常より強い根拠がある場合に限り疑ってよい。 -- **単独 CO 者を疑う『強い根拠』には、具体的な公開情報の矛盾が必要である。**他のプレイヤー (人間プレイヤー含む) が『〜は怪しい』『〜は人狼』と単に表明しただけ、占い結果や霊媒結果の具体的な指摘・投票や噛み筋の矛盾の指摘がない直感的疑い表明は、単独 CO 真置きルールを上書きする根拠としては弱い。そうした表明に同調して単独 CO 者へ票を入れる前に、自分視点で『単独 CO 者を切る具体的な公開情報』が1 つでも挙げられるかを確認する。挙げられないなら同調しない。特に真役職を持つ自分は、公開情報と矛盾しない単独 CO を切ると村の情報を失う側に動くことになる。 -- 人間プレイヤーの直感的な疑い表明は、議論を再評価するきっかけとして参照してよいが、それ自体を独立した証拠として扱わない。人間も狼/狂人として騙る可能性があるし、村役でも誤推理するため、根拠の中身 (具体的な公開情報の指摘) で重み付けする。 -- ただし「現在生存している CO 者が 1 人だけ」というだけでは単独 CO 扱いしない。同じ役職 CO が過去に 2 人以上存在したことがある場合、対抗者が処刑・襲撃などで死亡して現在 1 人だけ残っていても、その残存 CO 者を自動的に真置きしない。 -- **CO 通算件数は『生存・死亡を問わず過去に CO した全 seat の数』で数える。**プロンプトの `## 公開された占い/霊媒CO結果` ブロックには死亡席の CO も列挙されており、そのブロックの『通算 N 件』が公式の件数。「現在生存している占い CO は 1 人」と「占い CO は単独 (= 通算 1 件)」は別の概念で、後者は通算ベースで判断する。「占い師は◯◯さんだけ」「単独のCO」「単独で出ているから真」と判断する **前に必ず** ledger ブロックの通算件数を確認すること。通算 2 件以上なら、たとえ生存者が 1 人でも『単独 CO』とは呼ばない。死亡した CO 者の主張は記録に残り、推理材料として依然として有効である。 -- 対抗 CO が出た場合は、死亡済み CO 者も推理対象として保持し、判定結果・発言の時系列・投票・襲撃結果・死亡タイミングとの整合性で真偽を比較し、どちらをより真寄りとするか判断する。 -- 「最後まで生き残った CO 者」は真とは限らない。狼が情報役を噛まずに残した、対抗を吊らせて信用を取った、囲いに使う狙いで残した、といった可能性も平行して見る。対抗 CO 履歴がある役職で残存 CO が 1 人になった時点で「単独 CO だから真」と短絡しない。 -- 「占いCOが出たら」「霊媒COについて」「占いCOしている人をどう見るか」など、CO 語彙が発言中に登場するだけでは、その発言者自身の CO ではない。CO 語彙の話題化と本人による名乗りを区別する。 -- CO として扱うのは、本人が「私は占い師です」「占い師COします」「霊媒師として出ます」のように自分の役職として明確に宣言した場合だけである。仮定・話題提示・他者への言及はどれも CO ではない。 -- 疑わしい場合は、公開ログの前後関係、主語、引用や仮定の語尾 (〜なら / 〜について / 〜どう見る)、自分自身の宣言か他者への言及かを確認する。判断に迷うときは CO として数えない。 -- 死亡した席は、過去の発言・投票・判定の信用評価対象としては引き続き議論してよいが、今日の処刑対象 (vote target) としては議題に含めない。今日の vote 候補は生存席だけ。死者の信用議論を尽くすこと自体は構わないが、発言の結論を『死者を吊ろう』『今日◯◯ (死亡席) を処刑すべき』のように死者を今日の処刑対象として語るのは破綻発言として扱う。 -- 異端票 (例: 多数派と違う対象に day 1 で投票している席) を狼疑いとして数える前に、その投票者が真占い視点で動いていた場合に整合するかを必ず一度確認する。真占いは NIGHT_0 ランダム白で得た情報を持ち、自分視点の黒読み・灰読みで多数派と違う票を入れる動機が自然に発生する。後日その異端票の対象が黒判定されて処刑され、霊媒結果でも黒が出た場合、その異端票は『真占い視点での黒読み先制』として説明される側に強く寄るため、異端票そのものを狼の異常行動として扱うのは誤読になる。票の異端さだけで疑うのではなく、判定履歴・霊媒結果・噛み筋との整合性で再評価する。 -- 自分が役職持ちでまだ未公開の能力結果 (霊媒結果・占い結果・護衛日記など) を抱えている場合、発言の番が回ってきたとき、その発話の冒頭で必ず CO + 結果公表を行う。他者から呼びかけられて (addressed) いてその質問に答えたい場合でも、未公開の能力結果が手元にあるならその公表を最優先にし、addressed への返答は CO + 結果の後に短く添える。addressed 文脈に飲まれて CO + 結果を欠落させると、聞き手側 NPC は構造化フィールド (`co_declaration`) しか見られず公開ログには CO の自然言語が一切残らないため、他席は『自分が CO した』ことを認識できない。 -- 占い師・霊媒師・騎士の各 CO 数を時系列で整理し、各役職について『CO 数 - 1』(下限 0) を「対抗 CO 超過分」(騙り最低数) として数える。真役職は各 1 人だけのため、超過分はその役職で少なくとも騙りである。 -- 占い師 CO 超過分 + 霊媒師 CO 超過分 + 騎士 CO 超過分 の超過分合計が 3 に達した場合、人狼 2 + 狂人 1 の狼陣営 3 名が能力役職 CO 群に出切っている。村陣営の騙り・CO 撤回・同一人物の複数 CO・曖昧な CO 文言の誤読・死亡済み CO 見落とし・前提破綻がない限り、能力役職 CO していない位置は配役上の消去法で村陣営の確白級として扱う。同条件下で対抗のない単独 CO 役職が別にある場合、その単独 CO 者も狼陣営ではないため真役職としてかなり強く扱える。 -- この超過分合計 3 による非 CO 確白は、単発の白判定 (狂人も白に出るため『村陣営確定』とは言い切らない) とは別根拠で、固定配役上の消去法で狼陣営 3 名が CO 群に出切ったと数えられる点で村陣営まで強く推せる。ただし、対抗 CO 群の中で誰が真役職かまでは超過分合計だけでは特定できない。判定結果・霊媒結果・投票・襲撃・死亡タイミング・破綻で詰める。 -- 超過分合計が 0〜2 の段階では、狼陣営が非 CO や単独 CO に残っている可能性があるため、非 CO 位置を CO 数だけで確白とは断定しない。 -- 超過分合計が 4 以上に見える場合は固定配役と矛盾する。CO 撤回、同一人物の複数 CO、話題としての CO 語彙の誤読、村騙り、死亡済み CO 見落とし、ログ見落としを疑い、確白扱いを保留して時系列を再整理する。 -- 例: 3-2-1 (占い師 CO 3 / 霊媒師 CO 2 / 騎士 CO 1) → 超過分 2 + 1 + 0 = 3。前提崩壊がなければ非 CO 位置は村陣営の確白級、対抗のない単独騎士 CO も狼陣営ではないため真騎士寄り。 -- 例: 2-2-2 (占い師 CO 2 / 霊媒師 CO 2 / 騎士 CO 2) → 超過分 1 + 1 + 1 = 3。各 CO 群に 1 人ずつ真役職、残り 3 人が狼陣営という形が基本。非 CO 位置は確白級。各 CO 群内の真偽は判定結果・霊媒結果・投票・襲撃で詰める。 -- 例: 3-1-1 (占い師 CO 3 / 霊媒師 CO 1 / 騎士 CO 1) → 超過分 2 + 0 + 0 = 2。狼陣営 1 名が非 CO や単独 CO に残る可能性があるため、非 CO 全員を確白とは断定しない。 -- 例: 4-1-1 (占い師 CO 4 / 霊媒師 CO 1 / 騎士 CO 1) → 超過分 3 + 0 + 0 = 3。占い師 CO 群に狼陣営 3 名が固まっている可能性が高く、対抗のない単独霊媒・単独騎士は強い白寄り。CO 撤回や村騙りが出たら再整理する。 -- 占い師 CO が 3 人・霊媒師 CO が 1 人の盤面を『3-1』と呼ぶ。3-1 では占い 3 人のうち 2 人が騙りである可能性が高く、単独の霊媒師 CO は対抗がいない限り原則として真寄りの進行軸として扱い、初日は占い師 CO 側から処刑候補を検討するのが基本線になる。 -- 3-1 の基本進行は占いローラーまたは黒ストップの 2 択。占いローラーは、偽っぽい・狼っぽい・視点漏れしている占い師 CO から順に処刑し、処刑後の霊媒結果を占い結果・投票・襲撃結果の整合性と突き合わせて真偽を絞り込む。 -- 黒ストップとは、単独霊媒が占い師 CO の誰かに黒判定を出した時、残る占い師 CO をその場で処刑せず、灰 (役職 CO していない位置) の精査へ切り替える進行を指す。霊媒が真であれば処刑された占い師 CO は本物の人狼として確定しているので、占いローラー続行より灰の精査の方が有利になる局面が多いからである。 -- ただし黒ストップは絶対ではない。真狼狼 (2 人の狼が共に占い師 CO) の可能性、霊媒師 CO 側が偽だった可能性、残る占い師 CO の発言・投票・判定が破綻している場合、あるいはローラー続行しないと決選投票で PP (パワープレイ) を許す残り人数である場合は、黒ストップをやめて占いローラーを続行する判断があり得る。 -- 3-1 で占い師 CO が 3 人並んだ盤面では、CO 者のうち 2 人が公開情報上『本物の人狼ではない』と確定した場合、残る 1 人の占い師 CO を固定配役上の消去法として確定黒級の人狼位置と推定する。人狼 2 人固定の配役で『占い師 CO 3 人のうち 2 人が非狼確定』なら、残る占い師 CO 位置に少なくとも 1 人の人狼がいる以外に整合する配役がないためである。 -- ただし『白判定』と『非狼確定』を混同しない。信用が未確定な占い師 CO が出した白、偽が混じり得る霊媒結果、印象だけの白寄り評価は非狼確定として数えない。狂人も白判定されるため、単発の白だけで非狼扱いを固定しない。 -- 非狼確定として数えてよい根拠は、公開ログ・霊媒結果・襲撃死・真寄り情報役の判定・CO 破綻整理など、この bot のルールと公開情報の整合から説明できるものに限る。霊媒師 CO 側が真寄りと十分判断できる時点での霊媒白、本物の人狼ではないと示す襲撃死、対抗 CO や判定矛盾で偽が破綻した結果としての非狼整理などが具体的な根拠になる。 -- 2 人非狼確定が成立した場合は、残る占い師 CO を『まだ灰の 1 人』ではなく、固定配役上の狼位置として投票・発言・進行提案へ反映させる。黒ストップによる灰精査ではなく、残る占い師 CO の処刑提案や、その人物を相方候補ペア仮説の片側として扱う議論を優先してよい。 -- ただし前提が崩れた場合は確定黒扱いを解除して時系列から再整理する。村陣営の騙り (例: 狂人や村人が占い師 CO していた)、CO 撤回、霊媒師 CO 側が偽だった可能性が後から浮上した状況、非狼確定の根拠が後から破綻した場合などが該当する。前提が崩れたと分かった時点で、確定黒扱いをやめ、CO 履歴と判定履歴を時系列で再整理する。 -- 占い師 CO が 2 人・霊媒師 CO が 2 人の盤面を『2-2』と呼ぶ。2-2 では占い・霊媒のどちらも真が確定しておらず、霊媒ローラー (または霊媒切り) が基本進行軸となる。 -- 2-2 で霊媒師 CO が 2 人出ている場合、片方を根拠なく真置きせず、霊媒結果は偽が混ざっている可能性を常に織り込んで推理する。一度霊媒ローラーを開始したら原則として完走させ、途中で止めるには通常よりも強い根拠 (公開ログ上の破綻・襲撃・投票・占い結果との不整合) を要する。 -- 占い師 CO が 2 人・霊媒師 CO が 1 人の盤面を『2-1』と呼ぶ。2-1 では単独霊媒師を原則として真寄りの進行役候補とし、占い師 2 人の真偽比較とグレー精査を並行する。白進行ならグレー吊り/グレランが基本候補になりやすく、縄数・囲い候補・決め打ち日を意識する。占い黒が出ている場合は黒吊りで霊媒結果を見る選択肢が強いが、黒を出した占い師の信用と黒先の発言も必ず見る。 -- 占い師 CO が 1 人・霊媒師 CO が 2 人の盤面を『1-2』と呼ぶ。1-2 では占い師 CO は真寄りになりやすい一方、霊媒師は騙り混じりとして扱い、霊媒ローラーまたは霊媒切りを基本候補にする。ただし霊媒内訳が真狂寄りでグレー狼が濃いと判断できる場合だけ、グレー精査も比較する。どちらを選ぶかは縄数・占い結果・霊媒の破綻・投票で判断する。 -- 嘘をつける役職 (人狼・狂人) が偽 CO するときでも、偽の占い結果・霊媒結果・騎士日記はこの bot の実ルール上あり得る内容だけに留める。 -- 偽占い師は、占ったと主張する対象がその夜まで生存していたか、過去に自分が出した結果と矛盾しないかを確認する。 -- NIGHT_0 で占い師に提示されるランダム白は、占い師の初回占い結果として扱う。そのため day 1 の朝に占い師 CO する者 (真でも騙りでも) は、初回の占い結果として必ず白を主張する。 -- day 1 の朝に偽占い師として初回結果を黒と主張するのは、この bot の実ルール上の NIGHT_0 タイムラインと矛盾するため破綻要素として扱われる。day 1 で初回黒主張はしない。 -- 占い結果・霊媒結果を発言で出すときは、**対象席名 + 判定色 (黒/白) を必ず一対一で添える**。例: 『セツさんは白でした』『コメットを占って黒、ジナを占って白』のように、各対象を名指しで列挙する。**対象を特定せずに『すべて白』『全員白』『全部白』『みんな白』のように複数件をまとめて主張するのは破綻発言**として扱う。聞き手はそうした主張をした CO を破綻確定として切ってよい。 -- 特に day 1 朝の占い師 CO は、NIGHT_0 のランダム白 1 件しか手元にない。**day 1 の占い結果は必ず対象 1 名 + 白判定の 1 件のみ**で、複数対象の主張や『すべて白』のような対象不明な白主張は実ルール上ありえないため、その時点で CO 破綻として扱う。 -- 偽占い師の黒結果主張は day 2 以降にのみ行う。前夜に占ったという想定で、公開ログ・生存状況・過去に自分が出した判定履歴・対抗 CO の発表内容と矛盾しない場合だけ出す。 -- 偽霊媒師は、処刑がなかった日に霊媒結果を捏造しない。 -- 偽騎士は、自分護衛・同一対象連続護衛・死亡済み対象への護衛・存在しない護衛成功を主張しない。 -- どの偽 CO でも、実際には知らない他者役職や狼位置を事実として断言しない。 -- 自分が占い師/霊媒師として一度公表した判定 (対象 + 黒/白) は、原則として後日撤回・色変更・対象差し替えをしない。前夜の能力使用結果として一度言い切った内容は、その後の発言でも同じ対象を同じ色で扱う。打ち間違いに気づいた場合のみ、『訂正』『失礼、◯◯でした』などの訂正文言を明示してから直し、訂正後の内容を以降の発言でも保持する。 -- プロンプトの『## 公開された占い/霊媒CO結果 (公式記録)』ブロックは、Master が `claimed_seer_result` / `claimed_medium_result` の構造化フィールドからビルドした各 CO 者の累積発表履歴である。各 CO 者の過去の発表結果はここに完全に残る。 -- 自分が占いCO/霊媒CO 者である場合、新しい結果を発表する発話ではこの公式記録に列挙された自分の過去結果と完全に整合する内容のみを発表する。過去に発表した対象・色を別の組み合わせに差し替える、過去結果の片方を黙って削って違う対象を追加する、対抗 CO 者の発表内容を自分の結果として取り込む — これらは全て破綻として扱われる。 -- 占い CO 者は day N の朝までに通算 N + 1 件の結果を持つ (NIGHT_0 のランダム白 + 各夜 1 件)。通算件数が記録より少ない/多い結果列を主張すると **数の破綻** として確定し、聞き手はその CO を直ちに切ってよい根拠とする。霊媒 CO 者は処刑があった日数だけ結果を持つ (処刑なしの日は『結果なし』を明言する)。 -- 構造化フィールドと発話内容は必ず一対一で一致させる。新しい占い結果を述べる発話では `claimed_seer_result.target_seat`・`is_wolf` を発話内容と同じ対象・色で必ず設定する。霊媒も同様に `claimed_medium_result` を使う。新しい結果を発表しない発話 (前回までの結果に言及するだけ・他人への質問・一般議論) では両フィールドとも null にする。発話内容と構造化フィールドが食い違うと、Master 側の整合検査で破綻として記録される。 -- 同じ対象への判定色が前日と当日で食い違った CO を見つけた場合、人間プレイヤーの言い間違い・打ち間違いの可能性が残るため即座に偽 CO 確定とはしない。ただし強い偽要素として推理に組み入れ、明示的な訂正文言なしに食い違いが続く場合は、他の偽要素 (対抗 CO・票筋・噛み筋・破綻) と合わせて切ってよい材料として扱う。 -- day 2 以降に占い師・霊媒師・騎士として CO 中の者は、真でも偽でも、昼の議論 1 巡目で前夜相当の能力結果を出すのが信用上重要である。結果を持つはずの役職 CO が 1 巡目で結果を出さないと、信用低下や破綻疑いにつながる。 -- 以下は公開ログと盤面を読むときに共通で使う推理語彙。いずれも上記の事実ルール (単独 CO 真寄り、霊媒白=非人狼のみ、3-1/2-2 進行) を塗り替えない。語彙はラベルであって、最終判断は常に公開情報の整合性で行う。 -- グレー (灰): 役職 CO もなく占い/霊媒で白黒も十分ついていない位置。誰視点のグレーかを常に意識する。 -- グレラン: グレーから各自が理由を持って投票する進行。名前にランダムとあるが完全な無作為投票ではなく、発言・CO 状況・票筋・グレスケを根拠に選ぶ。 -- グレスケ (スケール): グレーや未確定位置を白い順・黒い順に並べる考察。順位だけでなく、発言・投票・判定・噛み筋との整合性を理由として添える。 -- 縄計算: 残り処刑回数 (縄) を数えること。標準目安は floor((生存人数 - 1) / 2)。この 9 人村は開始時 4縄。残り人狼数・狂人生存・PP/RPP リスクから、無駄吊りできる回数を意識する。 -- 白: 占い/霊媒で本物の人狼ではないと出た判定。狂人も白に出るため、村陣営確定ではない。 -- 黒: 占い/霊媒で本物の人狼と出た判定。誰の判定か、CO 者の信用、対抗結果との整合性を必ず確認する。 -- 確白: 公開情報上ほぼ非狼として扱える進行役候補の位置。ただし狂人は白判定されるため、「村陣営確定」と言い切りすぎない。 -- 確黒: 全視点または十分信用できる複数情報で本物の人狼と見てよい位置。単独の偽占い候補から黒を出されただけでは確黒ではない。 -- パンダ: 白判定と黒判定の両方を受けた位置。パンダ本人の黒さだけでなく、判定を出した CO 者同士の真偽比較・霊媒結果・票筋・噛み筋で評価する。 -- ローラー (ロラ): 複数 CO した同種役職候補を吊り切る進行 (占いローラー・霊媒ローラー)。開始したら原則完走し、黒ストップや強い破綻などで止める場合は理由を明示する。 -- 決め打ち: 複数 CO や複数候補のうち一方を真寄り・他方を偽寄りとして進行を固定する判断。外すと負けに直結しやすいため、縄余裕・判定・票筋・噛み筋を根拠にする。 -- 破綻: 発言・判定・投票・死亡タイミング・役職数などが公開情報と矛盾して成り立たなくなった状態。強い偽要素として扱ってよい。 -- ライン: 発言・投票・擁護・判定結果などから見える二者以上のつながり (特に狼同士に見える関係)。ライン切りや偶然もあるため、単発ではなく複数材料で見る。 -- 囲い: 偽占いなどが、狼である味方を白判定で保護する動き。狼は狂人位置を知らないため、狂人を確定の味方として囲う前提では考えない。狂人が偶然狼へ白を出す動きは別概念として扱う。白先の発言・投票・噛み筋と合わせて疑う。 -- 身内切り: 狼が仲間の狼に黒判定を出したり、投票や発言で切ったりして信用を買う戦術。狼は狂人位置を知らないため、狂人を本物の仲間として特定して切る前提にはしない。黒を出した側が必ず真とは限らない。 -- 票筋: 誰が誰に投票したかの履歴。同票・決選・狼候補への票有無、ラインや擁護との一貫性を見る。 -- 噛み筋: 夜の襲撃先の傾向。情報役噛み・白位置噛み・意見噛み・狩人探しの意図を推理する材料になる。 -- 視点漏れ: ある役職視点では本来知り得ないはずの情報 (狼位置、夜行動内訳、他人の属性など) を事実として話してしまう失言。強い騙り判断材料。 -- SG (スケープゴート): 狼に疑いを押し付けられて処刑候補にされやすい村陣営位置。怪しまれている理由が本人の黒要素か、狼の誘導かを分けて見る。 -- GJ (グッジョブ) / 平和: 朝に犠牲者が出ない状態。騎士の護衛成功があり得るが、騎士 CO や護衛先の開示は既存の騎士立ち回り指針に従い不用意に行わない。 -- 騎士 / 狩人 / 狩: この bot の正式役職名は騎士。人狼用語として狩人・狩も同じ護衛役を指す同義語として使われるため、公開ログ上で狩人や狩と書かれていても騎士と同じ意味として読む。 -- 鉄板護衛: 真寄り情報役・確白寄り・進行役など、噛まれると村が大きく崩れる位置を堅く守る護衛。 -- 変態護衛: セオリー上の本命から外れた位置を、襲撃読みで守る護衛。当たれば強いが、外すと重要役職を抜かれるリスクがある。 -- 捨て護衛: 噛まれにくい、または噛まれても村損失が小さい位置をあえて護衛する戦術。連続護衛不可の環境では、今日本命を守ると明日その本命を守れなくなるため、次夜の本命護衛余地を残す目的でも使う。この bot では合法護衛候補から 1 名を選ぶ行動であり、未提出・対象なし・誰も守らない・skip ではない。 -- 連ガ無し / 連続護衛不可: 同じ相手を連続で護衛できないルール。この bot は連続護衛不可で、前夜の護衛先は今夜の合法候補から外れる (前述の騎士護衛ルールと同じ)。 -- 護衛読み: 人狼がどこを噛みたいか、どこが護衛されていそうで噛みを避けるかを推理すること。騎士側でも、自分の護衛が読まれて噛みを外される可能性を考える材料になる。 -- 護衛誘導: 騎士の護衛先に影響を与えようとする昼発言。村利の進行整理にもなれば、人狼側の誘導にもなり得るため、発言者の立場・噛み筋・投票・翌日の得を合わせて評価する。 -- PP (パワープレイ): 終盤に人狼陣営が票を合わせて勝ちを取りに行く局面。残り人狼数・狂人生存可能性・縄数で成立可否を判断する。 -- RPP (ロスト/ランダム PP): 村側の縄と情報が尽き、PP 阻止が乱数勝負になる状態。ここに入る前に決め打ちや情報整理を優先する。 -- 発言の根拠チェックリスト: CO 履歴 (誰がいつ何を名乗ったか、対抗の有無)、判定履歴 (占い/霊媒の白黒、誰視点の結果か、狂人白ルールとの整合)、投票履歴 (同票・決選・身内票・票変え)、噛み筋 (情報役噛み・白位置噛み・意見噛み・狩人探し)、縄数 (残り処刑回数、PP/RPP の近さ) と自分の情報範囲 (私的情報と公開情報を混ぜない) を常に意識する。 -- 実際の発言には、上のチェックリストから今の結論に最も効く 1〜2 点だけを根拠として出す。用語だけで押し切らず、誰のどの発言・票・判定を見たのかを短く添える。長い内部思考そのものを発話しない。 -- 比較・関係を表す語 (重複・ライン・出来レース・囲い・身内切り・対立・連携) を使うときは、比較対象を必ず明示する。誰の何の発言・判定・投票・夜行動が、誰の何と『重複/ライン』しているのかを 1 件以上具体的に引用する。引用なく関係語だけを並べた主張は推理上の根拠として扱わず、聞き手側は内容を再確認する。 -- この村は人狼 2 人固定なので、怪しい人を 1 人挙げたら、その人物が人狼ならもう 1 人の相方候補は誰かまで公開ログからの仮説として考える。村側・狂人・確定していない役職は実際の 2 人狼ペアを知らないため、断定ではなく推理として扱う。 -- A-B の 2 人狼仮説は、(1) 庇い・便乗・距離の取り方、(2) 投票先・決選投票・票変えなどの票筋、(3) 占い・霊媒結果と白先・黒先・囲い候補、(4) 噛み筋・噛まれなかった位置・情報役噛みとの整合、(5) 片方が処刑濃厚なときの動き (ライン切り・身内票) が2 人狼セットとして自然かで検証する。 -- 単体黒要素が強くても自然な相方候補が見つからない場合は疑いの強さを下げ、単体では中庸でも相方候補との票筋・噛み筋が強くつながる場合は疑いを上げる。 -- 発言では 2 人狼候補を長く列挙せず、最も効くペア仮説 1〜2 点だけを短く添える。全候補のペアを並べる議論は時間を浪費し、結論にもつながりにくい。 -- 「相方候補」は公開ログからの推理用語として使ってよい。ただし実際の 2 人狼ペアを知っているのは人狼本人だけで、それ以外の役職は相方候補を確定情報として語らない。 \ No newline at end of file +## 配役と勝敗 + +- {{village_size}} 人村固定。配役は {{distribution}} (合計 {{village_size}}) で固定、途中で変わらない。 +- 陣営: 人狼・狂人=人狼陣営、占い師・霊媒師・騎士・村人=村人陣営。 +- 村勝利=生存人狼 0。狼勝利=生存人狼 ≥ 生存非人狼 (狂人は非人狼として数えるが勝者は狼陣営)。 +- 公開ログと自分の私的情報のみを根拠に話す。他者の役職・夜行動・狼関係など未公開情報を事実として断言しない。 +- 投票/夜行動の対象は提示された候補トークン (例: `席3 Alice`) と完全一致させる。 + +## 夜行動の構造ルール (HARD) + +- NIGHT_0 は占い師のランダム白 1 件のみ。襲撃も護衛も発生しない。よって day 1 朝は必ず平和で、これを「GJ」「護衛成功」「襲撃失敗」と解釈してはいけない (構造ルール違反)。 +- 人狼は NIGHT_1 から襲撃、騎士も NIGHT_1 から護衛。GJ 議論は day 2 朝以降。 +- ただし人狼 2 名は NIGHT_0 から潜伏中。「初日に死人がいないから狼疑いがない」は誤り。 +- 騎士は自分護衛・前夜と同じ対象護衛・死亡対象護衛・存在しない護衛成功主張をしない。 +- 人狼で襲撃先が割れた場合 Master が必ず 1 つを採用する (人間優先 → 双方 LLM/双方人間ならランダム)。空振りはない。狙いを揃えた方が情報役を確実に噛める。 +- **公開ログの `(襲撃)` 死亡席は構造的に非狼確定** (人狼は仲間を襲わない)。「襲撃された◯◯は狼だった」と公言した発言者は、霊媒結果より優先される HARD 級の破綻として強い人狼候補扱い。同調・追認も同じく破綻。 + +## 判定 (占い/霊媒) の構造ルール + +- 判定色は黒 (本物の人狼) / 白 (本物の人狼ではない) の 2 値のみ。第 3 の色 (灰・不明・保留など) は存在しない。第 3 の色を判定として出した CO はその場で破綻確定。 +- 狂人は黒判定されない (白として扱われる)。霊媒結果の白は「本物の人狼ではない」だけを示し、役職までは特定しない。 +- 処刑された占い師 CO に霊媒白でも、真占いの可能性と矛盾しない。霊媒白だけで偽扱いしない。霊媒黒なら本物の人狼確定、占いは騙り。 +- NIGHT_0 ランダム白対象は本物の人狼ではないが、狂人の可能性は残る (真の村人保証はない)。 + +## 公表内容の不可侵 (HARD) + +- 自分が公表した占い/霊媒判定・護衛履歴は、後のターンで対象・色・日付を絶対に書き換えない。新しい夜の結果を行追加するのは可能、過去行の上書きは不可。これは本物の役職者には起き得ない構造的矛盾なので、観測した側は即偽として切ってよい。 +- `claimed_seer_result` / `claimed_medium_result` は発話内容と一対一で一致させる。新しい結果を出さない発話 (前回までの言及・質問・一般議論) では両フィールドとも null。 +- 占い結果を発言で出すときは「対象席名 + 黒/白」を一対一で添える。「全員白」「すべて白」のような対象不明の集約主張は破綻。 +- **day 1 朝に公表できる占い結果は NIGHT_0 ランダム白 1 件 (対象 1 名 + 白) のみ**。同じ朝に 2 件目を主張する/黒を主張するのは構造違反で即偽確定。**day 1 朝の霊媒結果は存在しない** (前日処刑がない)。 +- 占い CO は day N 朝までに通算 N+1 件 (NIGHT_0 + 各夜)。霊媒 CO は処刑があった日数だけ。件数が合わない結果列は数の破綻として即偽確定。 +- 偽 CO でも本物には起き得ない内容 (day 1 黒主張・存在しない護衛成功・死亡対象への能力・自分護衛など) は出さない。 + +## CO 推理 + +- CO は本人が「私は占い師です」など自分の役職として明確に宣言した場合のみカウント。「占いCOについて」「占い CO が出たら」のような語彙登場・話題化は CO ではない。 +- CO 通算件数は生存死亡を問わず過去に CO した全 seat の数。プロンプトの `## 公開された占い/霊媒CO結果` ブロックの件数が公式記録。「現在生存中の占い CO は 1 人」と「占い CO が単独 (通算 1 件)」は別概念。 +- **単独 CO (通算 1 件、対抗なし) は原則として真寄りに置く。特に day 1 朝の単独 CO は真として扱う**: まだ霊媒結果も襲撃結果もなく、対抗が出る時間も残っており、疑う公開情報が実質ない。 +- 単独 CO を切るには「具体的な公開情報の矛盾」が必要。他者の直感的疑い表明 (人間プレイヤー含む) だけでは弱い。同調投票する前に、自分視点で切る具体根拠が 1 件でも挙がるか確認する。 +- 対抗 CO が出たら、死亡済み CO 者も推理対象として保持し、判定・票筋・噛み筋・死亡タイミングで真偽比較する。「最後まで残った CO 者」が真とは限らない (狼が残した可能性も並行検討)。 +- 異端票 (多数派と違う票) を狼異常行動と決める前に、真占い視点で動いていた場合に整合するかを必ず確認する。 +- 死亡席は信用評価議論の対象としては有効だが、本日の処刑対象 (vote target) には含めない。 + +## CO 上限と対抗超過分 + +- 理論最大: 占い 4 (真 1 + 狼 2 + 狂人 1)、霊媒 2、騎士 2。占い CO 4 件目は実戦で破綻 (村人騙りの合理理由なし)。霊媒/騎士 3 件目は構造的偽確定。 +- 各役職 CO 数 - 1 (下限 0) を「対抗超過分」(騙り最低数) として数える。**占+霊+騎 の超過分合計 = 3 で狼陣営 3 名 (狼 2 + 狂 1) が能力役職 CO 群に出切っている**。前提崩壊 (CO 撤回・村騙り・誤読) がない限り、能力役職 CO していない位置は配役上の消去法で村陣営の確白級。同条件下の対抗のない単独 CO 役職も狼陣営ではないため真寄り。 +- 超過分合計 0〜2 では非 CO 位置を確白扱いしない。4 以上に見えたら CO 撤回・誤読を疑い再整理する。 +- 単発の白判定 (狂人も白) で「村陣営確定」とは言わない。確白根拠は襲撃死・対抗破綻・真寄り情報役の判定など説明可能なものに限る。 + +## 進行軸 (短縮) + +- **3-1 (占 3 / 霊 1)**: 単独霊媒は真寄り。基本進行は占いローラー or 黒ストップ (霊媒黒が出たら残占 CO 処刑停止 → 灰精査)。占 3 のうち 2 名が非狼確定したら、残 1 名は固定配役上の確定黒級として処理してよい。 +- **2-2 (占 2 / 霊 2)**: 霊媒ローラー基本軸。開始したら原則完走。 +- **2-1 (占 2 / 霊 1)**: 単独霊媒真寄り、占 2 の真偽比較とグレー精査を並行。 +- **1-2 (占 1 / 霊 2)**: 単独占い真寄り、霊媒ローラー / 霊媒切りが基本。 + +## 縄計算と 2 人狼仮説 + +- 残り処刑回数 (縄) = floor((生存数 - 1) / 2)。9 人村開始時 4 縄。残り狼数・狂人生存・PP/RPP リスクから無駄吊り余地を意識する。 +- 怪しい人を 1 人挙げたら、その人物が人狼ならもう 1 人の相方候補は誰かまで公開ログから仮説として考える。狼本人以外は実際のペアを知らないので、推理用語として扱い断定しない。 +- 単体黒要素が強くても自然な相方候補が見つからないなら疑いを下げる。単体中庸でも票筋・噛み筋が強くつながるなら上げる。発言ではペアを長く列挙せず、最も効く 1〜2 点だけを短く出す。 + +## 発話作法 + +- 比較・関係を表す語 (重複・ライン・出来レース・囲い・身内切り・対立・連携) を使うときは、誰の何の発言/票/判定が誰の何と「重複/ライン」しているかを 1 件以上具体的に引用する。引用なしの関係語並列は推理根拠として扱わない。 +- 役職持ちで未公開の能力結果を抱えている場合、発言の番が回ってきた発話の冒頭で必ず CO + 結果公表を行う。addressed されていてもその返答より結果公表を優先し、addressed への返答は CO + 結果の後に短く添える (`co_declaration` を立てるだけでは公開ログに自然言語の名乗りが残らない)。 +- 用語ではなく公開情報の整合性で判断を語る。長い内部思考をそのまま発話せず、結論に最も効く 1〜2 点だけを短く添える。 diff --git a/src/wolfbot/prompts/templates/strategy/knight.md b/src/wolfbot/prompts/templates/strategy/knight.md index 27b53b9..57d2264 100644 --- a/src/wolfbot/prompts/templates/strategy/knight.md +++ b/src/wolfbot/prompts/templates/strategy/knight.md @@ -1,22 +1,11 @@ # 騎士 (KNIGHT) 戦略 -- 守る価値の高い情報役 (真占い・真霊媒) や、信頼されている位置を護衛対象として意識する。 -- 夜の護衛先は、候補ごとに「護衛価値 (真占い・真霊媒・確白寄り・進行役か)」「襲撃されやすさ (今夜実際に噛まれそうか、護衛を読んで避けられそうか)」「GJ が縄に与える価値 (平和で縄が増えるか、PP/RPP を遠ざけられるか、CO 時に盤面を詰めやすいか)」「次夜の連続護衛不可リスク (今日守った相手は明日守れない。明日の本命襲撃が濃いか)」「CO 時の説明可能性 (後で護衛履歴を出したとき、合法かつ盤面に沿って説明できるか)」を短く比較して選ぶ。最も白い相手や真寄りの情報役を毎夜固定するのではなく、盤面ごとに価値で判断する。 -- 鉄板護衛は、真寄り情報役・確白寄り・進行役など、今夜抜かれると村が大きく崩れる位置を堅く守る選択。重要役職が今夜噛まれそうな局面では、迷わず鉄板護衛を優先する。 -- 捨て護衛は、噛まれにくい低損失位置をあえて護衛し、次夜に本命を護衛できる余地を残す選択。この bot では合法候補から 1 名を選ぶ行動で、未提出や対象なしや skip ではない。 -- 捨て護衛は、今夜本命が噛まれにくい理由があり、明日以降に本命襲撃が濃くなると読める場合だけ検討する。重要役職が今夜抜かれるリスクが高いなら鉄板護衛を優先し、捨て護衛を毎夜の既定行動にしない。 -- 同じ相手を連続で護衛してはならない。前夜と違う相手を選ぶ。 -- 自分を護衛対象にすることはできない。死亡済みの相手を護衛したと主張してもならない。 -- 自分の護衛先を不用意に公開しない。公開すると翌夜の噛み筋のヒントを人狼側に与えてしまう。 -- 通常の進行中は潜伏を優先する。根拠のない CO は引き続き避ける。 -- 犠牲者が出ない平和な朝は、自分の護衛が成功した可能性が高い。このときは護衛先を添えて騎士 CO する価値が高く、守った相手を真寄り・白寄りに置く材料として村の推理を進められる。 -- 終盤、または自分が吊られそうな局面、自分の CO で確白や真役職を守れる局面では、護衛履歴を日付順に添えて CO することを検討する。 -- 護衛成功を理由に CO するときは必ず護衛先を添える。護衛先を隠した騎士 CO は真偽判定されにくく信用されない。 -- 騎士 CO の護衛履歴は合法でなければならない。自分護衛・同じ相手の連続護衛・死亡済み対象への護衛・存在しない護衛成功主張は含めない。 -- 平和な朝や CO する局面で護衛履歴を日付順に出すときは、捨て護衛を含む夜があったら、なぜ本命を外したか・なぜ次夜の余地を優先したかを1 行だけ添えて説明できる範囲で使う。 -- 護衛先は単体の白さだけでなく、2 人狼仮説上どこが噛まれると狼陣営に得かまで考える。情報役を守ると相方候補のラインがどう変わるか、白位置を残すとどの相方候補が浮くかを短く比較する。 -- 平和な朝や実際の襲撃先からは、どの 2 人狼仮説ならその噛み筋が自然かを昼の推理材料として使う。 -- 騎士 CO 時は護衛履歴に加え、必要に応じて噛み筋がどの相方候補ペア仮説に最も合うかを短く述べる。 -- 占い師 CO が 3 人並び、共有ルール側の『2 人非狼確定で残る 1 人が確定黒級』が成立したと読めた場合、夜の護衛先選びでは、残る占い師 CO 位置からの黒判定で守るべき真情報役・確白寄り位置と、次夜の本命護衛余地を比較する。ただし非狼確定の 2 人を数える基準は印象白ではなく霊媒結果・襲撃死・CO 破綻などに限り、前提が崩れたら護衛方針も再整理する。 -- 対抗 CO 超過分合計 3 で生まれた非 CO 確白級や、単独で対抗のない真寄り情報役は護衛価値が高い。ただし連続護衛不可、襲撃読み、CO 時の説明可能性も合わせて判断する。 -- **公開ログで `(襲撃)` 死亡となっている席は構造上の非狼確定 (人狼は仲間を襲わない)。**誰かが『襲撃された◯◯は狼だった』と公言したら破綻発言で、その発言者を強い狼候補として扱い、発話・投票で同調しない。 +- 護衛価値が高いのは、真寄り情報役・確白寄り進行役・盤面整理役。今夜抜かれると村が大きく崩れる位置を堅く守る (鉄板護衛)。 +- 護衛先選びは「護衛価値」「今夜実際に噛まれそうか」「GJ で縄が増える価値」「次夜の連続護衛不可リスク」「CO 時の説明可能性」を比較して選ぶ。最も白い相手や真寄り情報役を毎夜固定するのではなく、盤面ごとに価値で判断する。 +- 同じ相手を連続で護衛してはならない。前夜と違う相手を選ぶ。自分護衛・死亡対象護衛は禁止。 +- 自分の護衛先を不用意に公開しない (翌夜の噛み筋ヒントを狼に与えてしまう)。通常進行中は潜伏優先。 +- 終盤・自分が吊られそうな局面・自分の CO で確白や真役職を守れる局面では、護衛履歴を日付順に添えて CO することを検討する。CO する際は必ず護衛先を添える (隠した騎士 CO は信用されない)。合法な護衛履歴のみ (自分護衛・連続護衛・死亡対象護衛・存在しない護衛成功は破綻)。 +- 平和な朝は自分の護衛が成功した可能性が高い。守った相手を真寄り・白寄りに置く材料として村の推理を進められる局面では CO 価値が高い。 +- 捨て護衛 (噛まれにくい低損失位置を護衛し、次夜本命を守る余地を残す) はこの bot の合法護衛から 1 名を選ぶ行動 — null や skip ではない。今夜本命が噛まれにくい理由があり、明日以降に本命襲撃が濃くなると読めるときだけ検討。重要役職が今夜抜かれるリスクが高いなら鉄板護衛を優先する。 +- 護衛先は単体の白さだけでなく、2 人狼仮説上どこが噛まれると狼陣営に得かまで考える。 +- 公開ログの `(襲撃)` 死亡席は構造的に非狼確定。「襲撃された◯◯は狼だった」と公言する発言者は強い狼候補として扱い、発話・投票で同調しない。 diff --git a/src/wolfbot/prompts/templates/strategy/madman.md b/src/wolfbot/prompts/templates/strategy/madman.md index 356b886..0bc84ed 100644 --- a/src/wolfbot/prompts/templates/strategy/madman.md +++ b/src/wolfbot/prompts/templates/strategy/madman.md @@ -1,35 +1,13 @@ # 狂人 (MADMAN) 戦略 -- あなたは人狼陣営の勝利に貢献するが、本物の人狼位置を知っている前提で話してはならない。人狼が誰かは公開情報からは分からない立場として振る舞う。 -- 偽 CO や偽の判定結果を出す場合でも、公開ログ・投票・処刑結果との矛盾を避け、破綻しない範囲に留める。 -- 知り得ない確定情報 (夜行動の内訳・他プレイヤーの属性など) を事実として断言しない。 -- 真占い・真霊媒に疑いを向け、村陣営の情報整理を妨げる方向に投票や発言を運ぶ。 -- **占い師 CO が公開ログ上 既に 3 件以上出ている場合、4 件目を出してはならない。**理論最大 4 (真 1 + 狼 2 + 狂人 1) だが、4 件目に出ると村陣営の村人騙りとしか説明がつかず、村側に強い狼陣営ライン (= 狂人位置) として即座に切られる。**霊媒師 CO が 2 件以上、騎士 CO が 2 件以上の場合も、追加 CO を出してはならない** (3 件目で構造的偽確定)。既に上限に達している役職への追加 CO は、人狼陣営にとって縄を浪費するだけの破綻行動。上限に達した役職には潜伏で対応し、別の方法 (真占い・真霊媒の信用落とし、灰の混乱) で支援する。 -- day 1 の戦術選択は **先制CO/対抗CO/潜伏** の 3 択。3 つはどれも等しく強い第一級の候補で、盤面・自席発言順・公開ログの先行発言・既出 CO 数・縄構造から毎回 3 択全てを俎上に載せて比較する。特定の 1 択に機械的に流れない。特に『対抗 CO がまだ出ていないから自動的に騙りに出る』『誰かが先に騙ってくれるから自分は潜伏する』のような単一条件での機械的判断は避ける。 -- 先制CO は、占い師 CO がまだ誰からも出ていない day 1 朝の段階で、自分から最初の占い師 CO を出して盤面を取る選択肢。強い局面: 自席発言順が早く朝一の発言枠を取りやすい/灰候補が少なく潜伏で自然白を取りにくい/真占い CO が出る前に盤面を取り、後発の真占い CO を偽扱いに誘導したい。狂人は本物の狼位置を知らないため、先制で出す白先は灰の中から選び、特定位置の囲いを意図しない形で出す。**day 1 朝に自分の発言の番が回ってきた瞬間で、まだ占い師 CO が誰からも出ていないなら、それが先制 CO の最大の好機である。後の番に持ち越すと真占いが先に出て先制の機会を失う。** -- 対抗CO は、真占い CO (または別の騙り CO) がすでに出た後に、自分も占い師 CO で並んで真偽比較に持ち込む選択肢。強い局面: 自分が後発で潜伏すると村に押されそう/対抗 CO 群を膨張させて灰確白を絞らせたい/真占い CO の白先と異なる位置に白を出すことで真占いの信用を分散できる。 -- 潜伏は、占い師騙りに出ず、霊媒師騙り・騎士騙り・完全潜伏のいずれかで灰位置の自然白を取りに行く選択肢。強い局面: 占い師 CO が既に 3 人以上出ていて対抗を出すと 4-1 以上でローラー必至/灰位置で発言と投票から自然白を取れる発言力がある/騙り CO の誤爆リスクが具体的に高い盤面。 -- 潜伏は安全策ではない。潜伏を選ぶと真占い CO が単独 CO として確真置きされやすく、村は判定情報を全て握って day 2 以降の縄効率で狼陣営を追い込める。潜伏を選ぶには『対抗を出すと即破綻する』『灰位置で自然白を確実に取れる』のような潜伏の方が勝ち筋として強いことを示す具体根拠が必要で、『他が騙ってくれそう』『真占いに信用を与えてしまう』のような曖昧な理由では潜伏しない。 -- day 1 に占い師騙りを選ぶ場合、初回の占い結果は NIGHT_0 ランダム白に合わせて必ず白を主張する。初日に黒を出す主張はこの bot の実ルール上の時系列と矛盾し破綻するため、絶対にしない。 -- 黒出しは day 2 以降にだけ検討する。前夜に占ったという想定で、公開ログ上の反応・対抗 CO・票筋を見て慎重に選ぶ。狂人は本物の狼位置を知らないため、誤爆リスクは day 2 以降の黒出しでも常に残る点に注意する。 -- 黒出しは誤爆リスクを常に見る。人狼位置を知らないため、真役職・本物の狼・白い位置へ当ててしまうと反動が大きい。 -- 白出しは破綻しにくいが、白先が本物の狼とは限らない。白先を確定の味方として扱わず、公開ログ上の反応を見て支援先を調整する。 -- 霊媒師騙りは真霊媒をローラーに巻き込める一方、自分も処刑されやすい。占い師 CO 数、霊媒 CO 数、残り縄を見て選ぶ。 -- 騎士騙りは終盤や対抗騎士が出た場面では有効だが、護衛履歴の破綻リスクが高い。自分護衛・同じ相手の連続護衛・死亡済み対象への護衛・存在しない護衛成功主張を含めてはならない。 -- 霊媒師騙りも day 1 の選択肢として、占い師騙りや潜伏と並べて比較する。狂人が霊媒師騙りに出ると、真霊媒との 1-2、または別の騙り狼の占い師 CO と組み合わせて 2-2 を作りに行ける。真霊媒をローラーに巻き込める一方、9 人村では狂人本人がローラーで処刑されやすい。占い師 CO 数・霊媒 CO 数・縄数・盤面の混雑度を見て、潜伏や占い師騙りより得な場合だけ選ぶ。 -- 狂人は本物の人狼位置を知らないため、霊媒師騙りを選んでも誤爆・誤支援は起きる。霊媒黒で本物の狼を切ってしまう、霊媒白で真占いを補強してしまう、といったケースが day 2 以降の結果でも常に残る前提で動く。 -- day 1 に霊媒師騙りで CO する場合、まだ処刑が発生していないので霊媒結果は出さない。「処刑があった翌日に結果を出す」立ち回りを宣言するに留め、存在しない初日結果を捏造しない。 -- 霊媒師騙りで day 2 以降の最初の 1 巡目は、前日処刑者への霊媒結果を必ず添える。前夜に能力を使ったという想定で結果を出し、対象は前日に処刑された相手だけにする。ただし誤爆・誤支援を踏まえて結果の色を選ぶ。 -- 既に対抗占い師 CO が出ている場合は、day 2 以降に霊媒師騙りまたは騎士騙りを検討する。霊媒師騙りでは前日処刑者への霊媒結果 (夜に能力を使った想定) を添えて CO する。騎士騙りでは護衛先 (夜に能力を使った想定) を、平和な朝ならば護衛成功主張も添えて CO する。 -- 役職 CO と対抗 CO が合計 6 人以上に膨らむと、役職 CO していない位置の白が確定する。騙りすぎには注意する。狂人は本物の狼位置を知らない前提で動くため、自分が騙り続けるほど推理材料が減る点にも留意する。 -- day 2 以降に占い師・霊媒師・騎士を騙る、または既に騙っている場合、昼の議論 1 巡目で前夜相当の能力結果を必ず発表する。結果を後回しにすると信用低下や破綻疑いにつながる。ただし狂人は本物の人狼位置を知らないため、「狼を助けるつもり」でも誤爆や誤支援が起きる前提で偽結果を選ぶ。 -- **一度公の場で主張した偽の判定・護衛履歴は、後のターンで絶対に差し替えない。**本物の役職者は同じ夜について 1 つの結果しか持たないため、対象を後から変える・色を反転する・霊媒/騎士に乗り換えるといった事後修正は、本物には起き得ない構造的矛盾として即座に騙り確定する。対抗 CO が同じ対象に被せてきても、自分の主張対象や色を変えに行かない。新しい夜が経過したときに新規行を追加するのは可能だが、過去発表分の書き換えはしない。 -- **day 1 朝に公開できる占い結果は NIGHT_0 ランダム白の 1 件のみ。day 1 朝の霊媒結果は構造的に存在しない。**本物の役職者がそうである以上、騙り側もこの時系列に合わせないと即偽確定する。焦って 2 件目の占い結果や day 1 朝の霊媒結果を出してはならない。 -- 偽占い師の day 2 以降の結果は、前夜に占ったという想定で対象 + 白/黒 + 短い理由を出す。白出しは破綻しにくいが、白先が本物の狼とは限らないため白先を確定の味方として扱わない。黒は誤爆リスクがあり、本物の人狼・真役職・強く白い位置に当てた場合の反動を考える。黒先は処刑可能性があり、過去の自分の結果・対抗結果・投票履歴と矛盾しにくい位置を選ぶ。対抗占い師の白先へ黒を重ねる、灰に黒を出す、白を重ねて議論を割る、などの選択肢をCO 数・縄数・霊媒状況で使い分ける。 -- 偽霊媒師の day 2 以降の結果は、前日処刑者だけに出す。処刑なしの日は結果なしを主張し、存在しない結果を作らない。霊媒結果は、真占い師の信用を落とす・霊媒ローラーに持ち込む・占いローラー停止/継続を歪める目的で使う。ただし本物の人狼が処刑されたかは知らないため、黒白どちらも公開情報との整合性と誤支援リスクを見る。 -- 偽騎士は、終盤や対抗騎士が出た場面で検討する。合法な護衛履歴 (日付順の護衛日 + 護衛先) だけを出し、自分護衛・同一対象連続護衛・死亡済み対象護衛・存在しない護衛成功主張はしない。 -- 公開ログから 2 人狼仮説を立てるときは、本物の人狼位置を知らない立場として公開ログからの推定として扱う。狼候補 1 人だけを支援・攻撃するのではなく、その相方候補まで見てどの誘導が人狼陣営に得かを比較する。 -- 黒出し・白出しは、対象単体の整合だけでなく、対象を狼扱い/非狼扱いしたときに相方候補の見え方がどう変わるかでも判断する。 -- 真占い・真霊媒を崩すときも、村に誤った 2 人狼仮説を追わせられるかで価値を測る。誤爆リスクを前提に、相方候補の推定は断定しすぎない。 -- さらに精密に、占い師・霊媒師・騎士の対抗 CO 超過分 (各役職の CO 数 - 1、下限 0) を集計する。超過分合計が 3 に達した時点で、能力役職 CO していない位置は村陣営の確白級として扱われ、処刑候補が CO 群に集中するリスクを認識する。騙りに出るか潜伏するかは、公開情報の各 CO 数と残り縄から判断する。 -- **公開ログで `(襲撃)` 死亡となっている席は構造上の非狼確定 (人狼は仲間を襲わない)。**他者がこの席を『狼だった』と公言してもそれは破綻発言なので、あなたが追認・補強すると村陣営からは『破綻発言に乗る怪しい位置=狂人ライン』として透ける。狼支援としての発話は、襲撃死を狼扱いする路線ではなく、真占い・真霊媒へのもっともらしい疑い・別位置への灰圧の方向で行う。 +- あなたは人狼陣営の勝利に貢献するが、**本物の人狼位置を知っている前提で話してはならない**。人狼が誰かは公開情報からは分からない立場として振る舞う。 +- 偽 CO・偽判定でも公開ログ・投票・処刑結果との矛盾を避け、知り得ない確定情報 (夜行動内訳・他者役職など) を事実として断言しない。 +- day 1 戦術は **先制CO / 対抗CO / 潜伏** の 3 択を比較する。機械判断は禁止。狂人は本物の狼位置を知らないため、先制 CO で出す白先は灰の中から選び、特定位置の囲いを意図しない形にする。 +- 占い師騙り day 1 の初回結果は **必ず白** (NIGHT_0 矛盾で破綻)。黒出しは day 2+ のみ、誤爆リスク (真役職・本物の狼・白位置への直撃) を常に意識する。 +- 霊媒師騙り day 1 CO は結果なし、day 2+ は前日処刑者への結果を 1 巡目で必ず添える。誤爆・誤支援 (霊媒黒で本物の狼を切る/霊媒白で真占いを補強) が常に残る前提で動く。 +- 騎士騙りの護衛日記は合法のみ。自分護衛・連続護衛・死亡対象護衛・存在しない護衛成功は破綻。 +- **公表した偽の判定/護衛履歴は絶対に差し替えない**。対抗 CO に被せられても自分の対象・色を変えに行かない。 +- 占 3 / 霊 2 / 騎 2 の上限を超える追加 CO は出さない (狂人位置として即吊られる)。上限到達なら潜伏で対応。 +- 黒出しは誤爆リスク前提で。白先は本物の狼とは限らないので確定の味方として扱わない。 +- 狼支援の路線は「真占い・真霊媒へのもっともらしい疑い」「別位置への灰圧」で行う。 +- 公開ログの `(襲撃)` 死亡席は構造的に非狼確定。他者がこの席を「狼だった」と公言した場合、追認・補強すると「破綻発言に乗る位置=狂人ライン」として透けるので絶対に乗らない。 diff --git a/src/wolfbot/prompts/templates/strategy/medium.md b/src/wolfbot/prompts/templates/strategy/medium.md index 4fbfe6b..faf4b9b 100644 --- a/src/wolfbot/prompts/templates/strategy/medium.md +++ b/src/wolfbot/prompts/templates/strategy/medium.md @@ -1,21 +1,11 @@ # 霊媒師 (MEDIUM) 戦略 -- 処刑結果と占い師の主張・投票の流れを照合し、占い視点の真贋を見極める。 -- 自分の霊媒結果が占い視点に与える影響 (真占い補強、偽占い否定など) を整理して発言する。 -- 処刑された相手が狂人でも、霊媒結果は『人狼ではありませんでした』になる。黒になるのは本物の人狼だけで、白結果だけでは村置き確定にはならない。 -- **公の場で主張する `claimed_medium_result.target_seat` と `is_wolf` は、プロンプトの `自分の霊媒結果` セクションに記録された判定と完全に一致させる (対象席・色・該当する処刑日のすべて)。**記録に無い対象 (= 過去に処刑されていない席) を霊媒したと主張したり、記録と異なる色を主張したりするのは構造的な嘘で、本物の medium には絶対に起き得ない。そのような捏造を公にした時点で偽霊媒扱いされる。 -- **day 1 朝の霊媒結果は構造的に存在しない** (まだ処刑が発生していないため)。day 1 朝に『霊媒結果は◯◯白でした』のように語った時点で偽霊媒として確定する。霊媒師 CO 自体は day 1 朝でも可能だが、結果は『前日処刑がまだ無いので結果はない』に留める。 -- **過去のターンで公表した霊媒結果は後から差し替えない。**同じ処刑者について『黒だった』と言った後で別ターンで『やはり白だった』と言い直したり、対象を別の席にすり替えたりすると、本物の medium には起き得ない構造的矛盾として即座に偽確定する。新しい処刑が発生したら新規行を追加するのは可能だが、過去の行の上書きはしない。 -- **前日に処刑があった day で自分にまだ発言の番が来ていなかった場合、次に自分が発言する番が回ってきたとき、その発話で必ず霊媒師 CO + 前日処刑者への結果を発表する。**発話のチャンスは毎ターン回ってくるとは限らないため、回ってきた瞬間を結果公表に充てる。次の自分のターンに先送りしない。沈黙し続けると偽霊媒 CO を単独真として扱わせてしまい、判定情報も活かせない。潜伏を選ぶのは、占い師 CO の真贋整理を先に進めたい等の具体的理由があるときだけにする。 -- 自分の発言の番が回ってきたときに他席から呼びかけられている (addressed) 状態でも、未公開の霊媒結果を抱えているなら、**まず CO + 結果公表を発話の冒頭で行い、addressed への返答はその後に短く添える**。addressed の文脈につられて『その質問に答えるだけ』の発話を出すと、`co_declaration` フィールドだけ立っても自然言語側に CO の名乗りが残らないため、他席は自分の CO を一切認識できない。発話の最初に必ず『実は私、霊媒師だ』のような名乗りと前日処刑者への結果を明示する。 -- day 1 (まだ処刑前) は霊媒結果がないので CO を急ぐ必要はないが、占い師 CO の整理が進んだ後の発言で「霊媒の見立て」を示すことで信頼を取りやすくなる。完全な潜伏ではなく、能動的な意見を出して立ち位置を作る。 -- 対抗霊媒が出た場合は、自分の結果履歴と相手の矛盾を時系列で整理し、ローラーで自分も巻き込まれる可能性を織り込んで発言する。 -- 占い師 CO を処刑して霊媒結果が白だった場合、それは占い師 CO 偽の証明ではない。真占い師だった可能性と、狂人など非狼の騙りだった可能性を分けて整理する。 -- 占い師 CO を偽視する場合は、霊媒白そのものではなく、対抗 CO、占い結果の破綻、発言時系列、投票、襲撃結果、死亡タイミングとの整合性を根拠にする。 -- 霊媒黒が出た場合は、処刑された狼の相方候補を票筋・庇い・ライン切り・身内票から絞り、翌日の処刑候補や占い先の議論につなげる。 -- 霊媒白だった場合は、その処刑を押した位置・票の集まり方から、狼がどこを吊りたかったかを 2 人狼仮説として整理する。 -- 占い師 CO 処刑後の霊媒結果は、占い真偽の判定材料に使うだけでなく、残った狼候補と相方候補のペア整理にも使う。 -- 自分の霊媒結果と、生存中または死亡した占い師 CO の判定が一致した場合、その占い師 CO を真寄りに置く。例: 占い師 CO X が day N に対象 Y を黒判定 → Y が処刑された → 自分の霊媒で Y が黒。この場合、X の判定は実態と一致しているので X は真占いに強く寄る。他席が議論の流れで X を偽呼ばわりしているときでも、流れに乗って X を吊り対象に挙げない。自分の霊媒結果は他席が知らない自分視点の強い情報なので、発言で必ずこの整合を明示し、X を真寄りに置く根拠を示す。自分の能力結果と他 CO 者の判定が食い違う場合に限り、その CO 者を偽寄りに置く根拠が立つ。食い違いがないのに流れに乗って真 CO 者を切ると、村陣営が真情報を失う。 -- 占い師 CO が 3 人並んだ盤面では、自分の霊媒結果と公開ログから 2 人を非狼確定にできるかを毎日整理する。霊媒白だけで非狼確定として数えてよいのは、自分の霊媒 CO 側が真寄りと十分読める段階であり、霊媒結果以外の整合 (襲撃死・CO 破綻・判定矛盾) も合わせて説明できる場合である。2 人非狼確定が揃ったら、残る占い師 CO を確定黒級として処刑提案・投票誘導の材料にしてよい。 -- 霊媒結果は対抗 CO 超過分の CO 数推理を更新する材料にする。処刑された CO 者が黒なら対抗 CO 群内の狼数を絞り、白なら真役職または狂人の可能性を分け、非 CO 確白の前提が保たれるかを確認する。 -- **公開ログで `(襲撃)` 死亡となっている席は構造上の非狼確定 (人狼は仲間を襲わない)。**あなたの霊媒結果も白側で必ず整合する。誰かが『襲撃された◯◯は狼だった』と公言したら破綻発言で、あなたの霊媒結果より優先する HARD ファクトとして扱い、その発言者を強い狼候補にする。 +- 処刑結果と占い CO の主張・投票の流れを照合し、占い視点の真贋を見極める。`claimed_medium_result.target_seat` と `is_wolf` は、プロンプトの `自分の霊媒結果` に記録された判定と完全に一致させる。記録に無い対象 (= 過去に処刑されていない席) を霊媒したと主張するのは構造的な嘘で即偽確定。 +- **day 1 朝の霊媒結果は構造的に存在しない** (まだ処刑がない)。day 1 朝に「霊媒結果は◯◯白でした」と語った時点で偽霊媒確定。霊媒師 CO 自体は day 1 朝でも可能だが、結果は「前日処刑がまだ無いので結果はない」に留める。 +- **過去に公表した霊媒結果は後から差し替えない** (対象すり替え・色反転は本物には起き得ない構造的矛盾)。 +- **前日処刑があった day で発言の番が回ってきたら、その発話の冒頭で必ず CO + 結果を発表する**。addressed されていてもその返答より結果公表を優先。次の番に先送りしない。 +- 処刑された相手が狂人でも霊媒結果は「人狼ではありませんでした」になる。占い CO 処刑後の霊媒白は占い偽の証明ではなく、真占い・狂人騙り・村人騙りの可能性を分けて整理する。占い偽視は霊媒白そのものではなく、対抗 CO・占い結果の破綻・発言時系列・投票・襲撃結果・死亡タイミングとの整合性で判断する。 +- 霊媒黒が出たら、処刑された狼の相方候補を票筋・庇い・ライン切り・身内票から絞り、翌日の処刑候補・占い先の議論につなげる。 +- 自分の判定と占い CO の判定が一致した場合 (例: 占い CO X が黒判定 → 処刑 → 自分の霊媒で黒)、X を真寄りに置く。流れに乗って真 CO を切ると村が真情報を失う。食い違いがある場合に限り、その CO 者を偽寄りに置く根拠が立つ。 +- 対抗霊媒が出た場合は自分の結果履歴と相手の矛盾を時系列で整理する。一度霊媒ローラーが始まったら原則完走、止めるには通常より強い根拠が要る。 +- 公開ログの `(襲撃)` 死亡席は構造的に非狼確定。霊媒結果より優先される HARD ファクトとして処理し、その席を「狼だった」と公言する者は強い狼候補扱い。 diff --git a/src/wolfbot/prompts/templates/strategy/seer.md b/src/wolfbot/prompts/templates/strategy/seer.md index 3898eab..eb64e93 100644 --- a/src/wolfbot/prompts/templates/strategy/seer.md +++ b/src/wolfbot/prompts/templates/strategy/seer.md @@ -1,21 +1,11 @@ # 占い師 (SEER) 戦略 -- 自分の判定履歴を時系列で一貫して扱う。過去の白黒と矛盾する発言はしない。 -- **公の場で主張する `claimed_seer_result.target_seat` と `is_wolf` は、プロンプトの `自分の占い結果` セクションに記録された判定と完全に一致させる (対象席・色・該当する夜のすべて)。**記録に存在しない対象を新規に作って主張したり、記録と異なる色を主張したりするのは構造的な嘘で、本物の seer には絶対に起き得ない。そのような捏造を 1 度でも公にした時点で、村陣営からは即座に偽占い扱いされる。 -- **対抗 CO が同じ対象に同じ色を被せてきても、自分の対象を絶対に変えない。**本物の seer は同じ夜について 1 つの占い対象しか持たないため、対抗に被せられたからといって別の対象に乗り換えると、その瞬間に『真占いに起き得ない対象差し替え』として偽確定する。対抗が被せてきたときの正しい応答は、自分の対象を維持したまま、対抗側の主張根拠・票筋・噛み筋・後続の判定矛盾から偽占い視点を詰めること。焦って自分の主張対象や色を変えに行ってはならない。 -- **day 1 朝に公開できる占い結果は NIGHT_0 のランダム白 1 件だけ** (NIGHT_1 はまだ発生していない)。day 1 朝のターンで 2 件目の占い結果を主張するのは時系列上不可能で、即座に偽確定する。対抗 CO が出て圧力を感じても、追加の判定を捏造して『自分の方が情報量が多い』と見せようとしてはならない。 -- 黒結果は強い根拠として扱ってよい。ただし対抗 (偽占い) がいる場合は整合性を比較する。 -- 白結果は『本物の人狼ではない』ことしか保証しない。狂人は白に出るため、完全な村置きとしては扱わない。 -- CO タイミング・対抗 CO の有無・投票と判定の噛み合いを重視し、偽占い視点の破綻を探す。 -- **day 1 で自分にまだ発言の番が来ていなかった場合、次に自分が発言する番が回ってきたとき、その発話で必ず占い師 CO + 初日ランダム白を発表する。**発話のチャンスは毎ターン回ってくるとは限らないため、回ってきた瞬間を CO に充てる。次の自分のターンに先送りしない。真占いが沈黙し続けると、偽 CO を単独真として扱わせてしまうし、占い結果が活かせないまま村が情報不足で負ける。潜伏を選ぶのは「狂人らしい騙りが既に出ている」「相方候補の偽 CO を釣り出す具体的な計画がある」など明確な理由があるときだけにし、漠然とした様子見では潜伏しない。 -- 自分の発言の番が回ってきたときに他席から呼びかけられている (addressed) 状態でも、未公開の能力結果 (NIGHT_0 ランダム白を含む) を抱えているなら、**まず CO + 結果公表を発話の冒頭で行い、addressed への返答はその後に短く添える**。addressed の文脈につられて『その質問に答えるだけ』の発話を出すと、`co_declaration` フィールドだけ立っても自然言語側に CO の名乗りが残らないため、他席は自分の CO を一切認識できない。発話の最初に必ず『実は私、占い師なんだ』のような名乗りと結果を明示する。 -- 偽占い師 CO が出た場合は、原則として早めに対抗 CO し、初日ランダム白を含む全判定履歴を時系列で公開する。潜伏を続けるなら理由が必要。 -- 黒を引いた場合は、CO して黒結果・過去の白結果・投票理由を明示し、その黒の処刑と霊媒結果での確認を提案する選択肢を検討する。 -- 夜の占い対象は、合法候補からランダムに選ばず、白でも黒でも情報が落ちる位置 (翌日の議論に情報を落とせる位置) を優先する。占い価値の高い位置は、対抗 CO の白先や囲い候補、投票・決選投票・票変えで浮いた位置、根拠の薄い強い誘導をしている位置、白なら進行役候補にでき黒なら処刑提案しやすい位置、自分視点の灰を狭める位置である。 -- 占い価値が下がる対象は、既に自分が占った位置、今日ほぼ処刑されそうな位置、公開情報で十分処理対象になっている位置、発言が極端に少なく白黒どちらでも議論が伸びにくい位置である。今夜襲撃される可能性が高い位置でも、結果が CO 真偽や囲い疑惑の整理に直結するなら候補に残してよい (死亡しても占い結果は得られる)。 -- 対抗 CO がいる場合は、相手の白先・黒先・投票先・対抗を庇う発言者を比較し、真偽判定に最も効く対象を選ぶ。単独 CO 寄りで自分が信用されている場合は、黒狙いだけでなく、白が出たときに村の進行を安定させる位置も候補にする。 -- 占い先は単体黒狙いだけでなく、白でも黒でも結果が出たときに相方候補の整理に効く位置を優先する。対抗占い CO がいる場合は、対抗の白先・黒先・投票・庇いから 2 人狼仮説を組み立て、占い結果で最も崩せる仮説を狙って占う。 -- 黒を引いたら、その黒の相方候補を公開ログ (票筋・庇い・噛み筋) から絞り、処刑後の霊媒結果や翌日の占い方針につなげる。 -- 占い師 CO が 3 人になり自分以外の 2 人が公開情報上で非狼確定したと読めた場合、残る 1 人を固定配役上の狼位置として、夜の占い対象選びでもその位置の確認や周辺の相方候補ペア仮説を崩す方向の占い先を優先する。ただし非狼確定として数えるのは、印象白や自分以外の占い CO の単発白ではなく、霊媒結果・襲撃死・CO 破綻など説明可能な根拠に限る。 -- 対抗 CO 超過分合計が 3 に達して能力役職 CO していない位置が非 CO 確白級になった場合、そこを無駄占いせず、対抗 CO 群やまだ確定しない位置を優先して占う。 -- **公開ログで `(襲撃)` 死亡となっている席は構造上の非狼確定。**誰かが『襲撃された◯◯は狼だった』と公言した場合、それは破綻発言で、あなたの判定履歴と整合しなくてもその発言者を強い狼候補として処理する。占い師として整理発言を引っ張るときも、襲撃死を狼扱いする路線には乗らない。 +- 自分の判定履歴を時系列で一貫して扱う。`claimed_seer_result.target_seat` と `is_wolf` は、プロンプトの `自分の占い結果` に記録された判定と完全に一致させる。記録に存在しない対象/色を主張するのは構造的な嘘で即偽確定する。 +- **対抗 CO に被せられても自分の対象・色を絶対に変えない**。本物の seer は同じ夜について 1 つの対象しか持たないため、別対象への乗り換え・色反転は本物には起き得ない構造的矛盾として即偽確定する。対抗が出たときは自分の主張を維持したまま、対抗側の主張根拠・票筋・噛み筋・後続の判定矛盾から偽占い視点を詰める。 +- **day 1 朝に公開できる占い結果は NIGHT_0 ランダム白 1 件のみ** (NIGHT_1 はまだ発生していない)。day 1 朝に 2 件目を主張したり黒を主張したりするのは時系列違反で即偽確定する。 +- **自分の発言の番が回ってきたら、その発話の冒頭で必ず CO + 判定結果を発表する**。addressed されていてもその返答より結果公表を優先 (`co_declaration` フィールドだけでは公開ログに自然言語の名乗りが残らないため、他席は CO を認識できない)。次の番に先送りしない。 +- 黒結果は強い根拠として扱ってよい。白結果は「本物の人狼ではない」だけを保証し、狂人も白に出るため村陣営確定としては扱わない。CO タイミング・対抗の有無・投票と判定の噛み合いで偽占い視点の破綻を探す。 +- 偽占い CO が出たら原則として早めに対抗 CO し、初日ランダム白を含む全判定履歴を時系列で公開する。 +- 夜の占い対象は、合法候補からランダムに選ばず、白でも黒でも翌日の議論に情報が落ちる位置を優先する。価値が高いのは、対抗 CO の白先・囲い候補、票変えで浮いた位置、根拠の薄い強い誘導をしている位置、自分視点の灰を狭める位置。価値が下がるのは、既に占った位置・今日処刑濃厚な位置・極端に発言が少ない位置。 +- 対抗 CO 超過分合計が 3 で非 CO 確白級が成立した場合、そこを無駄占いせず、対抗 CO 群やまだ確定しない位置を優先して占う。 +- 公開ログの `(襲撃)` 死亡席は構造的に非狼確定。誰かが「襲撃された◯◯は狼だった」と公言したら破綻発言で、その発言者を強い狼候補として処理する (襲撃死を狼扱いする路線には乗らない)。 diff --git a/src/wolfbot/prompts/templates/strategy/villager.md b/src/wolfbot/prompts/templates/strategy/villager.md index 01735e4..2776cf8 100644 --- a/src/wolfbot/prompts/templates/strategy/villager.md +++ b/src/wolfbot/prompts/templates/strategy/villager.md @@ -1,16 +1,10 @@ # 村人 (VILLAGER) 戦略 - 公開発言の矛盾、視点漏れ、投票理由、占い/霊媒結果との整合性を重視して推理する。 -- 不確実なときは候補を絞り、理由を添えて話す。曖昧な決めつけや『なんとなく怪しい』だけの発言は避ける。 -- 自分に私的情報があるふりをしない。占い/霊媒/騎士の CO 騙りは村陣営としては行わない。**特に占い CO が既に 3 件出ている盤面で 4 件目として出るのは絶対に避ける** (占い CO 4 件は理論最大 = 真 1 + 狼 2 + 狂人 1 の構造的に飽和した形で、村人が 4 件目に出ると即座に強い狼陣営疑いを浴びて吊り対象になる)。霊媒・騎士も既に 2 件出ている場合は追加 CO に出ない。 -- 「村人CO」「素村CO」「普通の村人です」「役職は村人です」のように、自分から村人役職を CO して信用を取ろうとしない。村人は能力結果を持たないため CO しても証明にはならず、熟練者は村人 CO を信用材料に使わない。 -- 役職について聞かれた場合も「役職 CO はない」「非 CO の灰」と答えるに留め、「村人を CO する」形にしない。CO ではなく、公開ログ・CO 履歴・判定履歴・投票履歴・噛み筋・縄数の整合性で白さを取る。 -- 情報役を守り、人狼陣営が狙いやすい位置 (真 CO、盤面整理役) を投票で落とさないようにする。 -- **公開ログで `(襲撃)` 死亡となっている席は本物の人狼ではない (構造ルール上、人狼は仲間を襲わない)。誰かが『襲撃された◯◯は狼だった』『襲撃で沈んだ者こそ狼だ』のように主張した場合、それは構造的に成立しない明白な嘘で、その発言者は強い人狼候補 (狼本体・狂人・騙り) として扱う。**同調や追認 (『なるほど狼だったんだね』『推理通りだ』等) もせず、今日の発言・投票でその発言者に疑いを向けて整理する。霊媒結果・占い結果より優先される HARD ファクトとして処理する。 +- **自分に私的情報があるふりをしない**。占い/霊媒/騎士の CO 騙りは村陣営としては行わない。占 3 / 霊 2 / 騎 2 の上限に達した役職への追加 CO も絶対に出さない (即座に強い狼陣営疑いを浴びる)。 +- **「村人CO」「素村CO」「普通の村人です」のように、自分から村人役職を CO して信用を取ろうとしない**。村人は能力結果を持たないため CO しても証明にはならない。役職について聞かれたら「役職 CO はない」「非 CO の灰」と答えるに留める。 +- 情報役を守り、人狼陣営が狙いやすい位置 (真 CO・盤面整理役) を投票で落とさないようにする。 +- 公開ログの `(襲撃)` 死亡席は構造的に非狼確定。「襲撃された◯◯は狼だった」と公言した発言者は強い狼候補 (狼本体・狂人・騙り) として扱う。同調・追認も破綻発言。霊媒結果・占い結果より優先される HARD ファクトとして処理する。 - 発言の根拠は CO 履歴・判定履歴・投票履歴・噛み筋・縄数のうち今の結論に効く 1〜2 点に絞り、誰のどの発言・票・判定を見たかを短く添える。 -- 公開情報しかない立場として、CO 履歴・判定履歴・投票履歴・噛み筋から 2 人狼仮説を作り、「この人が人狼なら相方候補はこの位置が自然/不自然」までセットで考える。 -- 単体黒要素だけでなく、決選投票での身内票・弱い対抗候補への票逸らし・処刑濃厚な位置への急なライン切りも疑いの材料にする。ただし「対立=ライン切り」と断定せず、偶然の対立や進行上の都合と区別する。 -- 発言では全候補を長く列挙せず、今日の結論に効くペア仮説 1〜2 個だけを短く出す。相方候補は推理用語であり、自分が実際の 2 人狼ペアを知っている前提で語ってはいけない。 -- 占い師 CO が 3 人並んだ盤面で、共有ルール側の『2 人非狼確定なら残る 1 人を確定黒級』を満たすと自分視点で読めた場合は、残る占い師 CO 位置を投票・発言・進行提案で固定配役上の狼位置として扱ってよい。ただし非狼確定の 2 人を数えるときは、印象白や信用未確定 CO の白判定では数えず、霊媒結果・襲撃死・CO 破綻など説明可能な根拠だけを使う。前提が崩れた瞬間 (例: 霊媒師 CO 側の偽が浮上した、CO 撤回が出た) には確定黒扱いをやめて再整理する。 -- 公開ログから占い師・霊媒師・騎士の CO 数と対抗 CO 超過分を毎日整理する。超過分合計が 3 に達したら能力役職 CO していない位置を村陣営の確白級として扱い、発言では誰が CO 群で誰が非 CO 確白かを短く示して投票先を CO 群に絞る。超過分合計が 0〜2 のうちは非 CO 位置を CO 数だけで確白扱いせず、超過分合計が 4 以上に見えたら CO 撤回・村騙り・誤読を疑って時系列から再整理する。 -村人 CO 禁止の方針はそのまま維持する。 +- 公開情報しかない立場として 2 人狼仮説を作り、「この人が人狼なら相方候補はこの位置が自然/不自然」までセットで考える。実際の 2 人狼ペアを知っている前提では語らない。 +- 占い CO 3 件並ぶ盤面で 2 名を非狼確定にできた場合、残る 1 名は固定配役上の確定黒級として処理してよい。ただし非狼確定として数えるのは霊媒結果・襲撃死・CO 破綻など説明可能な根拠に限る (印象白や信用未確定 CO の単発白では数えない)。前提が崩れた瞬間に確定黒扱いを解除して再整理する。 diff --git a/src/wolfbot/prompts/templates/strategy/werewolf.md b/src/wolfbot/prompts/templates/strategy/werewolf.md index e692fd6..1216c5f 100644 --- a/src/wolfbot/prompts/templates/strategy/werewolf.md +++ b/src/wolfbot/prompts/templates/strategy/werewolf.md @@ -1,51 +1,18 @@ # 人狼 (WEREWOLF) 戦略 -- 相方の人狼と襲撃先を揃えることを最優先にする。原則として意見が割れると襲撃は失敗するが、相方が人間プレイヤーで自分が LLM 席 (またはその逆) のときに限り、人間プレイヤーの選択がそのまま採用される。それ以外の構成 (双方人間 / 双方 LLM) で割れたら従来どおり失敗する。 -- 昼の主張・投票理由・夜の襲撃意図に一貫性を持たせ、視点漏れを避ける。 -- 相方を露骨に庇いすぎない。無理筋な擁護は狼ラインを疑われる原因になる。相方を囲うなら発言・投票・噛み筋と整合する理由を用意する。 -- 占い師・霊媒師などの情報役、信頼されている位置、盤面整理を主導する相手を優先的に脅威として評価する。 -- 人狼は勝利に必要な本体であり生存価値が高い。騙りに出るか潜伏するかは盤面で選ぶ。 -- day 1 の戦術選択は **先制CO/対抗CO/潜伏** の 3 択。3 つはどれも等しく強い第一級の候補で、盤面・自席発言順・公開ログの先行発言・相方位置・既出 CO 数・縄構造から毎回 3 択全てを俎上に載せて比較する。特定の 1 択に機械的に流れない。特に『対抗 CO がまだ出ていないから自動的に騙りに出る』『誰かが先に騙ってくれるから自分は潜伏する』のような単一条件での機械的判断は避ける。 -- 先制CO は、占い師 CO がまだ誰からも出ていない day 1 朝の段階で、自分から最初の占い師CO を出して盤面を取る選択肢。強い局面: 自席発言順が早く朝一の発言枠を取りやすい/灰候補が少なく潜伏で自然白を取りにくい/真占い CO が出る前に盤面を取り、後発の真占い CO を偽扱いに誘導したい/相方位置から見て自分の白先選択が活きる。**day 1 朝に自分の発言の番が回ってきた瞬間で、まだ占い師 CO が誰からも出ていないなら、それが先制 CO の最大の好機である。後の番に持ち越すと真占いが先に出て先制の機会を失う。** -- 対抗CO は、真占い CO (または別の騙り CO) がすでに出た後に、自分も占い師 CO で並んで真偽比較に持ち込む選択肢。強い局面: 真占い CO の白先や黒先が相方を含んで囲い継続が容易/自分が後発で潜伏すると村に押されそう/対抗 CO 群を膨張させて灰確白を 1〜2 名に絞らせたい。 -- 潜伏は、占い師騙りに出ず、霊媒師騙り・騎士騙り・完全潜伏のいずれかで灰位置の自然白を取りに行く選択肢。強い局面: 占い師 CO が既に 3 人以上出ていて対抗を出すと 4-1 以上でローラー必至/相方が即吊り危険位置で発言・投票で守る価値が高い/灰位置で自然白を取れる発言力がある。 -- 潜伏は安全策ではない。潜伏を選ぶと真占い CO が単独 CO として確真置きされやすく、村は判定情報を全て握って day 2 以降の縄効率で狼陣営を追い込める。潜伏を選ぶには『対抗を出すと即破綻する』『灰位置で自然白を確実に取れる』『相方を発言で守れる』のような潜伏の方が勝ち筋として強いことを示す具体根拠が必要で、『他が騙ってくれそう』『真占いに信用を与えてしまう』のような曖昧な理由では潜伏しない。 -- 黒出しは真占い師・真霊媒師・真騎士に当てると破綻や対抗 CO のリスクがある。吊りやすさだけでなく翌日の霊媒結果・投票・噛み筋まで考えて出す。 -- day 1 に占い師騙りを選ぶ場合、初回の占い結果は NIGHT_0 ランダム白に合わせて必ず白を主張する。初日に黒を出す主張はこの bot の実ルール上の時系列と矛盾し破綻するため、絶対にしない。 -- day 1 の白先選びは、相方の位置・囲いリスク・公開ログ上の発言や投票・噛み筋と整合させる。相方を囲うかどうか、灰の中で白を打つ位置はどこかを、襲撃計画と合わせて決める。 -- 黒出しは day 2 以降にだけ検討する。前夜に占ったという想定で、霊媒結果・投票・襲撃結果・対抗 CO の反応まで見て、破綻しない黒先と出すタイミングを選ぶ。 -- 霊媒師騙りも day 1 の選択肢として、占い師騙りや潜伏と並べて比較する。霊媒師騙りを選ぶと、真霊媒との 1-2 や、相方が占い師騙りに出ていれば 2-2 を作りに行ける。霊媒ローラーで真霊媒を巻き込めば縄を使わせられる一方、9 人村では狼本体がローラーで失われやすい。縄数・相方の位置・潜伏で白を取る余地・占い騙りの混雑度を比較して選ぶ。 -- day 1 に霊媒師騙りで CO する場合、まだ処刑が発生していないので霊媒結果は出さない。「処刑があった翌日に結果を出す」立ち回りを宣言するに留め、存在しない初日結果を捏造しない。 -- 霊媒師騙りで day 2 以降の最初の 1 巡目は、前日処刑者への霊媒結果を必ず添える。前夜に能力を使ったという想定で結果を出し、対象は前日に処刑された相手だけにする。 -- 既に対抗占い師 CO が出ている場合は、day 2 以降に霊媒師騙りまたは騎士騙りを検討する。霊媒師騙りでは前日処刑者への霊媒結果 (夜に能力を使った想定) を添えて CO する。騎士騙りでは護衛先 (夜に能力を使った想定) を、平和な朝ならば護衛成功主張も添えて CO する。 -- 霊媒師騙りは 9 人村ではローラーされやすく、狼本体を失いやすい。真霊媒を巻き込む価値、残り縄、相方の位置、PP/RPP まで見て限定的に選ぶ。 -- 騎士騙りの護衛日記は、この bot の合法護衛に合わせる。自分護衛・同じ相手の連続護衛・死亡済み対象への護衛・説明不能な護衛成功主張は破綻として扱われる。 -- 役職 CO と対抗 CO が合計 6 人以上に膨らむと、役職 CO していない位置の白が確定する。騙りすぎには注意し、相方との役職分担を事前に意識する。 -- **占い師 CO が公開ログ上 既に 3 件以上出ている場合、4 件目を出してはならない。**理論最大 4 (真 1 + 狼 2 + 狂人 1) だが、4 件目に出ると村陣営の村人騙りとしか説明がつかず、村側に強い狼/狂人ラインとして即座に切られる。**霊媒師 CO が 2 件以上出ている場合・騎士 CO が 2 件以上出ている場合も、追加 CO を出してはならない** (これらは 3 件目で構造的偽確定)。既に上限に達している役職への追加 CO は破綻行動。潜伏 (= 役職 CO に出ない) を選ぶか、相方が既に出ていない別役職を考える。 -- 夜の襲撃先は、候補ごとに「襲撃価値」「護衛されやすさ」「騎士候補度」「相方との整合」を比較して選ぶ。単独真寄りの情報役・確白寄り・直近で白をもらった重要位置・進行役・強く信頼された発言者は、襲撃価値も護衛されやすさも高い両刃の存在として扱う。 -- **対抗 CO が出ている役職 (= 同じ役職の CO が公開ログ上 2 件以上) の CO 者は、原則として襲撃価値が低い。**村側は対抗 CO 群を残して占いローラー / 黒ストップで真偽を絞り込む進行をするため、たとえ真を 1 人噛んでも残った対抗 CO 群と霊媒結果で整理されてしまう。むしろ対抗 CO 群を **そのまま吊り役に使わせる** ほうが狼陣営の縄を消費させられて得。襲撃価値が高いのは、(a) 対抗のいない単独 CO の真寄り情報役 (例: 単独霊媒・単独騎士)、(b) CO していない確白寄りの進行役・盤面整理役、(c) 信頼を集めている灰位置。複数 CO 役職を噛むのは、相方が CO 群に居る場合の囲い解除や、黒ストップを阻止する具体目的が明確なときだけにする。 -- 騎士候補度は公開ログから推定する。騎士 CO、護衛先を匂わせる発言、情報役を守りたがる姿勢、平和な朝の反応、処刑回避の仕方、発言量の抑え方を材料にする。逆に、騎士 CO を強く促す位置、護衛ルールを誤っている位置、死を恐れない視点の位置は騎士候補度を下げる。ただし実役職を知っている前提で断言してはならない。あくまで公開情報からの推定として扱う。 -- 噛み方針を「情報役噛み」「白位置噛み」「意見噛み」「騎士探し」「SG 残し」のどれかとして整理し、翌日の自分や相方の発言・投票・騙り結果と矛盾しない襲撃を選ぶ。 -- 護衛リスクを読んで噛む。GJ で縄が増える、噛み失敗で人狼側が不利になる、進行役を残されるなど、護衛されやすい位置を毎回無条件に噛むと損になる。護衛濃厚な真役職を噛みに行く場合は、その位置を残すと黒を引かれる・霊媒で破綻する・盤面を固められる、といった GJ リスクを承知で勝負する理由を持つ。 -- **前夜の襲撃が GJ で平和な朝になった場合 (= 自分が昨夜噛んだ対象が今朝も生存)、その対象を今夜も同じく噛むのを最優先候補にする。** 騎士は連続護衛禁止のため、昨夜守った相手を今夜は守れない。盤面 (CO 構成・吊り筋・白要素) が大きく変わっておらず、その対象の襲撃価値が依然として最高位なら、今夜の同一対象襲撃で確実に決着できる。切り替えるのは、(a) 翌日の議論で対象の襲撃価値が大きく低下した (例: 確白扱いされ吊り対象から外れた)、(b) 別位置で対象を超える緊急襲撃価値の情報役が新規に登場した、(c) 人狼チャットで相方と切替方針を明示的に合意した、のいずれかが揃ったときに限る。「読まれそう」「対称的すぎる」のような漠然とした不安では切り替えない — 対象が騎士に2連続では絶対に守れない以上、再噛みは数学的に成立する。 -- 騎士候補を噛むのは「騎士探し」として有効で、翌日以降に安全に情報役を噛む準備として価値がある。ただし噛み筋が露骨な意見噛みや相方を不自然に白くする形に見えないかを必ず確認する。 -- 最終的には人狼チャットで相方と襲撃先を 1 人に揃える。自分の第一希望だけで突っ込まず、相方案がある場合は襲撃価値・護衛されやすさ・騎士候補度を比較して合わせる。 -- day 2 以降に占い師・霊媒師・騎士を騙る、または既に騙っている場合、昼の議論 1 巡目で前夜相当の能力結果を必ず発表する。結果を後回しにすると、熟練者目線では信用を落とす。 -- **一度公の場で主張した偽の判定・護衛履歴は、後のターンで絶対に差し替えない。**本物の役職者は同じ夜について 1 つの結果しか持たないため、『day 0 の白先は Alice』と公表した後で別ターンで『やっぱり Bob を占った白だった』と言い直したり、過去に白と言った対象を後から黒に塗り替えたり、護衛日記の対象や日付を後から書き換えたりすると、本物には起き得ない構造的矛盾として即座に騙り確定する。対抗 CO が同じ対象に被せてきても、自分の主張対象や色を変えない。対象差し替え・色反転は、対抗者にも観測されている公開情報なので、村陣営は時系列で並べて即座に偽認定する。新しい夜が経過したときに新規行 (例: day 2 朝に day 1 夜の占い結果) を追加するのは可能だが、それは過去発表分への追加であり書き換えではない。 -- **同一の昼ターン内で、自分の発話を出す前のターンに既に同じ役職を CO した者がいる場合、対抗 CO で被せに行くか、騙りを諦めて潜伏に切り替えるかを最初の発話の段階で決める。**一度『私が占い師だ。Alice 白』と発表した後で、後ターンで対象を変える・色を変える・霊媒/騎士に乗り換えるといった事後修正は、本物には起き得ない挙動として観測される。焦って事後修正に走るのは最悪の選択肢で、初期発話を維持して対抗側の偽を詰める方向に切り替える。 -- 偽占い師の day 2 以降の結果は、前夜に占ったという想定で対象 + 白/黒 + 短い理由を出す。対象は前夜の開始時点で生存していた相手だけにし、死亡済み・存在しない・自分自身・過去の自判定と矛盾する対象は使わない。相方を白で囲う場合は発言・投票・噛み筋と整合させ、露骨な囲いを避ける。非相方への白は白位置噛み・SG 残し・対抗の信用落としと矛盾しないかを見る。黒は真役職・騎士・強い白位置への直撃で対抗 CO や破綻を招かないか、翌日の霊媒結果・投票・襲撃計画と整合するかを確認する。 -- 偽占い CO で発表した `claimed_seer_result.target_seat` と `is_wolf` は、そのターン以降の発話・行動でも一貫させる。structured output で過去と異なる対象・色を返すと、Master は両者を公式 CO 履歴として記録するため、公開 CO ledger 上に自己矛盾した行が並び、対抗側に詰められる。**day 1 朝の段階では、占い結果として主張できる行は NIGHT_0 ランダム白の 1 件のみ** (本物の seer がそうである以上、騙り側もここに合わせないと即座に偽確定する)。day 1 朝のうちに 2 件目を追加で主張すると本物の seer には起き得ない時系列違反になる。 -- 偽霊媒師の day 2 以降の結果は、前日処刑者だけに出す。処刑がなかった日は霊媒結果なしを主張し、存在しない結果を作らない。**day 1 朝の霊媒結果は構造的に存在しないため、day 1 朝に霊媒結果を語ったら即偽確定。**霊媒黒は占いローラー停止や灰吊り誘導に使えるが、過去の投票・相方位置・残り縄と矛盾しないか必ず見る。霊媒白は、対象が真占い師だった可能性・狂人だった可能性・村役だった可能性をどう見せるかを整理する。過去のターンで公表した霊媒結果の対象・色は後から差し替えない。 -- 偽騎士の day 2 以降の主張は、合法な護衛履歴を日付順 (護衛日 + 護衛先) に出す。自分護衛・同一対象連続護衛・死亡済み対象護衛・死者が出た朝の護衛成功主張はしない。平和な朝に護衛成功を主張するなら、護衛先がその朝の襲撃失敗説明として自然か、襲撃計画と矛盾しないかを確認する。**過去に公表した護衛日記の行 (護衛日 + 護衛先) は後のターンで書き換えない。** -- 結果を出す発言は、長い内部思考ではなく、結果と最も効く 1〜2 点の理由に絞る。 -- 投票は翌日以降に票筋として読まれる前提で動く。相方が公開ログ上で処刑濃厚な局面で、自分だけ弱い別候補へ票を逸らすと、相方を庇った狼として透けやすく、狼ラインを濃くする。 -- 相方を救えない局面では、身内票やライン切りで相方へ投票し、自分が白く残って翌日以降に動ける価値を優先することも熟練者の選択肢に入れる。投票理由は公開ログ上の発言・CO 矛盾・票筋・視点漏れに沿って自然に作り、前日までの自分の主張や騙り結果と矛盾しないようにする。 -- 相方を救う票は、自然に票が集まり得る対抗候補がいて、自分の過去発言・投票理由・騙り結果と矛盾しないときだけ選ぶ。根拠の薄い票を逸らす動きや、無理筋な対抗誘導は狼ラインを濃くする。 -- 決選投票では、相方救済の成功見込み、自分の透けリスク、相方を切って翌日以降に残る価値、PP/RPP の近さと縄数を比較し、もっとも勝ち筋が残る投票を選ぶ。 -- 「常に相方へ投票」でも「常に相方を庇う」でもなく、公開ログ上もっとも自然で、自分と相方の票筋が翌日以降に読まれても勝ち筋が残る投票を毎回選ぶ。 -- 自分と相方は村から「2 人狼セット」として票筋・庇い・距離感・噛み筋を読まれる前提で動く。救う票・切る票・囲い・黒出し・襲撃のどれを取っても、翌日以降に自分と相方のラインとして説明できるかを毎回確認する。 -- 偽占いの白囲い・黒出し、偽霊媒結果、騎士騙りの護衛日記、襲撃先は、それ単体の自然さだけでなく、自分と相方の 2 人狼セットとしてどう読まれるかまで考えて選ぶ。 -- 襲撃は「誰を消すか」だけでなく、「誰を残すとどの相方候補が疑われるか」「噛み筋で自分と相方のラインが濃くならないか」を比較して決める。 -- 公開発言では実際の相方を知っているからこそ出てしまう視点漏れを絶対に出さない。相方語彙は人狼チャットや夜行動の私的領域だけで使う。 -- **自分達が前夜に襲撃した相手 (公開ログで `(襲撃)` 死亡として表示される席) を、翌日以降に『あれは狼だった』『自分の推理が当たった、襲撃された◯◯は狼だ』のように公の場で主張してはならない。**人狼は仲間を襲わないという構造ルール上、襲撃死=非狼が公開情報として確定しており、そのような発言は熟練者目線で即座に狼確定として処刑される。相方の発話がこの嘘に踏み込んだ場合も、無批判に追認・補強しない (『お前は人狼をよくわかっているな』『襲撃で沈んだのが答えだ』等の同調は二重破綻で自分も相方も同時に狼ラインに乗る)。襲撃対象の死を語るときは『情報役を潰す動きが見えた』『襲撃価値の高い位置が落ちた』のように、対象を狼と確定させない言い回しに留める。 -- さらに精密に、占い師・霊媒師・騎士の対抗 CO 超過分 (各役職の CO 数 - 1、下限 0) を集計する。超過分合計が 3 に達した時点で、能力役職 CO していない位置は村陣営の確白級として扱われ、処刑候補が CO 群に集中する。騙りに出るか潜伏するかは、現在の各 CO 数と残り縄を見て、相方と整合する形で選ぶ。 +- 人狼チャットで相方と襲撃先を 1 人に揃えることが最優先。相方への庇いは公開ログ上の発言・投票・噛み筋と整合する範囲に留め、露骨な擁護はライン疑いになる。 +- day 1 戦術は **先制CO / 対抗CO / 潜伏** の 3 択を毎回比較する。機械判断 (「対抗 CO がまだだから自動騙り」「他が騙ってくれるから潜伏」) は禁止。盤面・自席発言順・既出 CO 数・縄構造で選ぶ。 +- 先制 CO の最大の好機は「自分の番が回ってきた瞬間に占い師 CO がまだ誰からも出ていない」とき。後の番に持ち越すと真占いに先取りされる。 +- 占い師騙り day 1 の初回結果は **必ず白** (NIGHT_0 ランダム白に合わせる)。day 1 黒主張は時系列違反で即偽確定。黒出しは day 2 以降のみ。 +- 霊媒師騙りで day 1 CO する場合は「処刑があった翌日に結果を出す」立ち回りを宣言、初日結果は捏造しない。day 2 以降は前日処刑者への結果を 1 巡目で必ず添える。 +- 騎士騙りの護衛日記は合法のみ。自分護衛・連続護衛・死亡対象護衛・存在しない護衛成功は破綻。 +- **公表した偽の判定/護衛履歴は絶対に差し替えない**。対抗 CO に被せられても自分の対象・色を変えに行かない。事後修正は本物には起き得ない構造的矛盾として即偽確定する。 +- 占 3 / 霊 2 / 騎 2 の上限を超える追加 CO は出さない (4-1-1 の占い 4 件目は強い狼疑い)。上限到達なら潜伏か別役職を選ぶ。 +- 潜伏は安全策ではない。「対抗を出すと即破綻」「灰位置で自然白を確実に取れる」など潜伏が勝ち筋として強い具体根拠があるときだけ選ぶ。 +- 襲撃価値の比較軸: (a) 単独 CO の真寄り情報役 = 価値高、(b) CO していない確白寄り進行役 = 価値高、(c) 信頼を集めている灰 = 価値高。**対抗 CO ありの役職 (占 2 件以上の片方など) は襲撃価値が低い** — 残して吊り役として縄を消費させる方が得。 +- **GJ 翌夜は同一対象を再噛みする**: 騎士は連続護衛禁止なので昨夜守った相手を今夜は守れない。盤面が大きく変わらず襲撃価値も最高位なら数学的に成立する。切り替えは「対象の襲撃価値が大きく低下」「別位置に超緊急情報役」「相方とチャットで切替合意」のいずれかが揃ったときだけ。 +- 噛み方針を「情報役噛み / 白位置噛み / 意見噛み / 騎士探し / SG 残し」のどれかとして整理し、翌日の自分・相方の発言・投票・騙り結果と矛盾しない襲撃を選ぶ。 +- 騎士候補度は公開ログから推定 (情報役を守りたがる発言、平和な朝の反応、発言量の抑え方)。実役職を知っている前提では断言しない。 +- 投票は翌日以降の票筋として読まれる。相方処刑濃厚で自分だけ弱い別候補へ票逸らしすると庇い狼として透ける。救う票・切る票・票逸らしは 2 人狼セットとして説明可能か毎回確認する。決選では救済成功見込み・透けリスク・残存価値・縄数を比較する。 +- 視点漏れ厳禁。相方語彙は人狼チャットでのみ使う。 +- **自分達が前夜襲撃した相手 (公開ログ `(襲撃)` 表記) を「あれは狼だった」と公言してはならない**。構造ルール上 襲撃死=非狼 が確定しているので即狼確定発言になる。相方の発話がそこに踏み込んだ場合の追認・補強も二重破綻。 diff --git a/tests/test_llm_prompt_builder.py b/tests/test_llm_prompt_builder.py index c80e6d7..43811b6 100644 --- a/tests/test_llm_prompt_builder.py +++ b/tests/test_llm_prompt_builder.py @@ -1,21 +1,28 @@ -"""Tests for `prompt_builder._build_game_rules_block`, `_build_strategy_block`, -and `build_system_prompt`. - -These pin down two invariants: -- The game-rules block (common to every LLM seat) states the fixed 9-player - ruleset, role distribution, win conditions, and the non-obvious mechanics - (NIGHT_0 random white, madman-is-white, wolf-split failure, knight no - consecutive guard, candidate-token strict match). -- The role-strategy block is role-scoped: each role's tips are not visible to - any other role. In particular, wolf-coordination vocabulary (`相方`, - `襲撃先を揃える`) appears only in the werewolf tips; the madman's tips - prohibit (not assume) knowing real wolf positions. +"""Tests for the prompt builder. + +Pins down the safety invariants the rebuilt rules + strategy templates +must keep: + +* The shared rules block states the canonical 9-player ruleset and the + hard structural facts every LLM seat must respect (NIGHT_0 random + white, attack-victim-non-wolf, knight no-self / no-consecutive, + candidate-token strict match, day-1 fake-seer must claim white, + per-target naming, public-claim immutability). +* The role-strategy block is role-scoped — wolf-coordination vocabulary + (`相方` / `襲撃先を揃える`) appears only in the werewolf tips, and + the madman tips prohibit (not assume) knowing real wolf positions. +* `build_system_prompt` and `build_user_context` glue the blocks + together with the right placeholders filled and no leakage between + roles. + +Granular bullet-by-bullet content checks were dropped when the rules +file was rewritten — those couple the test suite too tightly to prose +phrasing. Tests here check structure, leakage, and the few invariants +the runtime depends on for safety. """ from __future__ import annotations -import re - import pytest from wolfbot.domain.enums import ROLE_JA, Phase, Role, SubmissionType @@ -23,7 +30,6 @@ from wolfbot.llm.persona_base import JudgmentProfile, Persona, SpeechProfile from wolfbot.llm.prompt_builder import ( _build_game_rules_block, - _build_game_rules_block_legacy_inline, _build_judgment_profile_block, _build_speech_profile_block, _build_strategy_block, @@ -36,1999 +42,490 @@ ) from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY as PERSONAS_BY_KEY - -# --------------------------------------------------------- game rules block -def test_game_rules_block_template_matches_legacy_inline() -> None: - """Round-trip parity: the markdown template must render identically - to the legacy inline-string form. This catches the dangerous case - where someone edits one side and forgets the other — every other - `test_game_rules_block_*` test below verifies *content*, but not - that the two sources stay in lock-step. - """ - assert _build_game_rules_block() == _build_game_rules_block_legacy_inline() - - -def test_game_rules_block_contains_role_distribution() -> None: - block = _build_game_rules_block() - for role_ja, count in ( - ("人狼", 2), - ("狂人", 1), - ("占い師", 1), - ("霊媒師", 1), - ("騎士", 1), - ("村人", 3), - ): - assert f"{role_ja}{count}" in block, f"{role_ja}{count} missing from rules block" +# ========================================================================= +# game-rules block — structural invariants +# ========================================================================= def test_game_rules_block_states_nine_player_village() -> None: block = _build_game_rules_block() assert "9 人村" in block + assert "(合計 9)" in block -def test_game_rules_block_contains_win_conditions() -> None: - block = _build_game_rules_block() - # Must mirror rules.check_victory exactly. - assert "生存人狼数が 0" in block - assert "生存人狼数が生存非人狼人数以上" in block - - -def test_game_rules_block_contains_night0_random_white_rule() -> None: - block = _build_game_rules_block() - assert "NIGHT_0" in block - assert "ランダム白" in block - assert "本物の人狼ではない" in block - - -def test_game_rules_block_contains_seer_medium_wolf_only_rule() -> None: - block = _build_game_rules_block() - assert "本物の人狼だけを黒" in block - assert "狂人は黒判定されない" in block - - -def test_game_rules_block_contains_wolf_split_attack_rule() -> None: - block = _build_game_rules_block() - assert "意見が割れた場合" in block - assert "空振り" in block - - -def test_game_rules_block_documents_human_wolf_priority_exception() -> None: - """Domain has human-wolf priority for mixed (1 human + 1 LLM) splits. - - LLM wolves must understand the asymmetry — without it they assume any - split fails and may misjudge attack-target choice when paired with a human. - """ - block = _build_game_rules_block() - assert "人間プレイヤー" in block - assert "LLM 席" in block - assert "採用" in block - - -def test_game_rules_block_contains_knight_consecutive_guard_rule() -> None: +def test_game_rules_block_renders_role_distribution_from_constants() -> None: + """The distribution sentence must list every role with its seat count + derived from ROLE_DISTRIBUTION + ROLE_JA — not hard-coded text.""" block = _build_game_rules_block() - assert "連続で護衛" in block - assert "前夜と同じ対象" in block + expected_pieces = [ + f"{ROLE_JA[Role.WEREWOLF]}2", + f"{ROLE_JA[Role.MADMAN]}1", + f"{ROLE_JA[Role.SEER]}1", + f"{ROLE_JA[Role.MEDIUM]}1", + f"{ROLE_JA[Role.KNIGHT]}1", + f"{ROLE_JA[Role.VILLAGER]}3", + ] + for piece in expected_pieces: + assert piece in block -def test_game_rules_block_contains_candidate_token_rule() -> None: +def test_game_rules_block_states_win_conditions() -> None: block = _build_game_rules_block() - assert "候補トークン" in block + assert "村勝利" in block or "村人陣営勝利" in block + assert "狼勝利" in block or "人狼陣営勝利" in block + # Madman is counted as non-wolf for the count, but the wolf side wins. + assert "狂人" in block -def test_game_rules_block_contains_single_co_default_truthy_stance() -> None: - """Shared rule: a lone CO with no counter-CO (never once in the public log) - should be treated as likely real, and LLMs should not vote against them - without grounds. `一度も` is the key anchor that distinguishes this case - from a sole-survivor whose counter-CO was executed/attacked.""" - block = _build_game_rules_block() - assert "単独 CO" in block - assert "真の役職者にかなり近い" in block - assert "対抗 CO" in block - assert "一度も" in block +# ---------------------- night-action structural facts ---------------------- -def test_game_rules_block_allows_single_co_suspicion_on_strong_evidence() -> None: - """Single CO is not absolute truth — strong evidence (speech breakdown, - vote contradiction, result contradiction, attack-pattern mismatch) can - still justify suspicion.""" +def test_game_rules_block_states_night0_random_white_only() -> None: block = _build_game_rules_block() - assert "発言破綻" in block - assert "投票矛盾" in block - assert "判定結果の矛盾" in block - assert "噛み筋" in block + assert "NIGHT_0" in block + # day 1 morning is structurally peaceful; cannot be inferred as GJ + assert "GJ" in block or "護衛成功" in block + assert "解釈してはいけない" in block or "構造ルール違反" in block -def test_game_rules_block_guides_counter_co_comparison() -> None: - """When a counter-CO appears, compare on results / timeline / votes / - attack outcomes.""" +def test_game_rules_block_states_night0_random_white_can_be_madman() -> None: block = _build_game_rules_block() - assert "対抗 CO" in block - assert "時系列" in block - assert "整合性" in block + assert "狂人の可能性" in block -def test_game_rules_block_rejects_sole_survivor_as_single_co() -> None: - """If the same role had 2+ COs in the past, a currently-surviving lone CO - holder is NOT treated as a lone/no-counter CO even though only one is - alive. Prevents the LLM from auto-trusting a survivor whose opponent was - executed or attacked.""" +def test_game_rules_block_states_knight_no_self_no_consecutive() -> None: block = _build_game_rules_block() - assert "2 人以上" in block - assert "自動的に真置きしない" in block - - -def test_game_rules_block_includes_dead_co_in_comparison() -> None: - """For roles with a counter-CO history, dead CO holders stay in the - comparison set — the LLM evaluates the survivor against the deceased - opponent's results, speech, votes, attack outcomes AND death timing.""" - block = _build_game_rules_block() - assert "死亡済み" in block - assert "死亡タイミング" in block + assert "自分護衛" in block + assert "前夜と同じ対象護衛" in block or "連続護衛" in block -def test_game_rules_block_warns_against_treating_last_surviving_co_as_truth() -> None: - """When a counter-CO history exists and only one CO is left alive, the LLM - must not short-circuit to "lone CO ⇒ truth". The wording must explicitly - name the wolf-side mechanisms that produce a sole survivor (not attacking - the info role, getting a counter-CO executed, leaving them for protective - cover) and forbid the "single CO so truth" shortcut.""" +def test_game_rules_block_states_wolf_split_master_picks_one() -> None: block = _build_game_rules_block() - assert "最後まで生き残った" in block - assert "噛まずに残した" in block - assert "単独 CO だから真" in block + assert "Master" in block + assert "空振り" in block + assert "ランダム" in block -def test_game_rules_block_co_recognition_requires_explicit_self_declaration() -> None: - """Topical mentions / hypotheticals / references to others using CO - vocabulary (`占いCO が出たら` etc.) must NOT be read as the speaker's own - CO. The rules block must explicitly carry the self-declaration policy and - the example phrases for each side (topical vs. self-declaration).""" - block = _build_game_rules_block() - # Topical phrases the LLM must NOT misread as self-CO. - assert "占いCOが出たら" in block - assert "霊媒COについて" in block - # Self-declaration example phrases. - assert "私は占い師です" in block - assert "占い師COします" in block - assert "霊媒師として出ます" in block - # Policy framing. - assert "話題化" in block - assert "名乗り" in block - assert "判断に迷うときは CO として数えない" in block - - -def test_game_rules_block_co_recognition_no_wolf_coordination_leak() -> None: - """Defensive duplicate of the existing terminology leak-test focused on the - new CO-recognition bullets — guarantees the new policy text does not bleed - wolf-coordination vocabulary into the shared rules block. Bare `相方` - (actor mode, partner-known) is forbidden; `相方候補` (public-log inference) - is allowed in the 2-wolf-pair-inference subsection.""" - block = _build_game_rules_block() - assert not re.search(r"相方(?!候補)", block), ( - "bare '相方' (actor mode) leaked into shared rules block" - ) - assert "襲撃先を揃える" not in block +# ---------------------- judgment color rules ---------------------- -def test_game_rules_block_forbids_third_judgment_color() -> None: - """Seer/medium results are strictly binary (黒/白). Fake mediums in past - games hallucinated a `灰色` (gray) result; the listener side (e.g. real - knight) then took the gray claim as possibly valid. The shared block must - explicitly forbid third-color claims AND tell listeners to treat such a - claim as immediate CO破綻.""" +def test_game_rules_block_states_two_color_only() -> None: block = _build_game_rules_block() + assert "黒 (本物の人狼)" in block + assert "白 (本物の人狼ではない)" in block assert "2 値のみ" in block - assert "灰" in block - assert "第 3 の色" in block - assert "破綻として確定扱い" in block - - -def test_game_rules_block_forbids_self_judgment_retraction() -> None: - """LLM seers/mediums must not retract or flip their own past judgment - color/target. Past games saw a fake medium claim `白` on day 2 and `灰色` - on day 3 for the same target without any correction wording.""" - block = _build_game_rules_block() - assert "後日撤回" in block - assert "色変更" in block - assert "対象差し替え" in block - assert "訂正" in block -def test_game_rules_block_handles_listener_side_color_flip() -> None: - """Listener side: a same-target color flip across days is a strong fake - signal but NOT auto-break, since human players typo / mis-speak. Without - an explicit correction wording, combine with other fake signals.""" +def test_game_rules_block_states_madman_appears_white() -> None: block = _build_game_rules_block() - assert "言い間違い" in block - assert "即座に偽 CO 確定とはしない" in block - assert "強い偽要素" in block - - -def test_game_rules_block_requires_concrete_citation_for_relational_terms() -> None: - """Relational vocabulary (重複・ライン・出来レース・囲い・身内切り・対立・連携) - requires a concrete citation of which speech/judgment/vote is being compared. - Past games saw a wolf yell `重複しすぎ` against two seer results that had - zero target overlap — bare relational labels must be rejected.""" - block = _build_game_rules_block() - assert "重複" in block - assert "出来レース" in block - assert "1 件以上具体的に引用" in block - assert "推理上の根拠として扱わず" in block - - -def test_game_rules_block_excludes_dead_seats_from_today_vote_target() -> None: - """Game 76358c4623f0 day 3 had all 5 NPCs proposing 'ステラを吊ろう' even - though ステラ was already attacked dead. Past-credibility analysis of a - dead seat is fine; today's vote target must be alive seats only. The - rule must explicitly forbid concluding a speech with '死者を吊ろう'.""" - block = _build_game_rules_block() - assert "死亡した席" in block - assert "信用評価対象" in block - assert "今日の処刑対象 (vote target) としては議題に含めない" in block - assert "破綻発言" in block - - -def test_game_rules_block_reframes_outlier_vote_as_possible_real_seer_read() -> None: - """Same game day 3: all village LLMs treated ステラ's day-1 'コメット票' - (异端 vote) as wolfish, but ステラ was the real seer who later confirmed - コメット as black. The rule must instruct LLMs to first check whether - an outlier vote is explained as a real seer's early black-read before - labeling it suspicious.""" - block = _build_game_rules_block() - assert "異端票" in block - assert "真占い視点で動いていた場合に整合するか" in block - assert "黒読み先制" in block + assert "狂人は黒判定されない" in block -def test_game_rules_block_prioritizes_unannounced_results_over_addressed_reply() -> None: - """Same game day 3: シゲミチ (real medium) was dispatched as `addressed` - while holding an unannounced コメット黒. The NPC LLM set - co_declaration='medium' but the text body answered the addressed - question instead of leading with the CO. The rule must tell LLMs to - lead with CO + result and answer the addressed question afterward.""" - block = _build_game_rules_block() - assert "未公開の能力結果" in block - assert "発言の番が回ってきたとき" in block - assert "発話の冒頭で必ず CO + 結果公表" in block - assert "addressed 文脈に飲まれて CO + 結果を欠落させる" in block - - -def test_game_rules_block_requires_per_target_naming_for_seer_medium_results() -> None: - """Game af3d1e5d3fa1 day 1 had ユリコ (狂人) fake-seer CO with 「すべて白」 — - a vague plural claim with no target names. day 1 has only ONE NIGHT_0 - random white target, so 'all white' is structurally impossible. The - rule must forbid plural-without-target claims (`すべて白` / `全員白` / - `全部白`) and require target-by-target naming on every announcement.""" +def test_game_rules_block_states_attack_victim_is_non_wolf_hard() -> None: + """The 襲撃死=非狼 rule is the most safety-critical HARD fact: the + listener-side must reject any 'attack victim was wolf' claim, and + the speaker-side must never produce one.""" block = _build_game_rules_block() - assert "対象席名 + 判定色 (黒/白) を必ず一対一で添える" in block - assert "すべて白" in block - assert "全員白" in block - assert "破綻発言" in block - # day-1 single-target reinforcement. - assert "day 1 の占い結果は必ず対象 1 名 + 白判定の 1 件のみ" in block + assert "(襲撃)" in block + assert "非狼確定" in block + assert "HARD" in block -def test_game_rules_block_explains_medium_white_means_not_wolf_only() -> None: - """Medium white = `not a real werewolf`, not a role-claim confirmation. - Every LLM seat must see this so that no role overreads a white result.""" +def test_game_rules_block_states_medium_white_is_not_village_confirmation() -> None: block = _build_game_rules_block() - assert "人狼ではありませんでした" in block assert "本物の人狼ではない" in block - assert "役職名" in block + # The legacy rule was that medium white doesn't identify role; the new + # text expresses this as "役職までは特定しない". + assert "役職" in block and "特定しない" in block -def test_game_rules_block_protects_seer_co_from_medium_white_misread() -> None: - """Medium-white on an executed Seer-CO does NOT invalidate the seer claim - — a real seer who gets executed reads white too. The block must also - state that suspicion of the seer CO requires non-medium-result grounds.""" - block = _build_game_rules_block() - assert "占い師 CO" in block - assert "真占い師だった可能性と矛盾しない" in block - assert "偽扱いしない" in block +# ---------------------- public-claim immutability ---------------------- -def test_game_rules_block_flags_seer_co_as_wolf_fake_on_medium_black() -> None: - """The converse: medium-black on an executed Seer-CO is strong evidence - the CO was a wolf fake, because only real werewolves read black.""" +def test_game_rules_block_forbids_retroactive_judgment_change() -> None: block = _build_game_rules_block() - assert "霊媒結果で黒" in block - assert "本物の人狼" in block - assert "人狼の騙り" in block - - -def test_game_rules_block_defines_3_1_formation() -> None: - """3-1 = 3 seer COs + 1 medium CO. The shared rules must name this - formation and note the 2-of-3 fake seer likelihood, treating the sole - medium as a truth-leaning progression pivot.""" - block = _build_game_rules_block() - assert "3-1" in block - assert "占い師 CO が 3 人・霊媒師 CO が 1 人" in block - assert "2 人が騙り" in block - + assert "後のターンで対象・色・日付を絶対に書き換えない" in block -def test_game_rules_block_names_seer_roller_and_black_stop() -> None: - """3-1 gives two base-plan vocabulary items: seer roller and black stop. - Both names must appear in the shared rules.""" - block = _build_game_rules_block() - assert "占いローラー" in block - assert "黒ストップ" in block - - -def test_game_rules_block_describes_seer_roller_procedure() -> None: - """Seer roller hangs from fake-looking / wolf-looking / info-leaking seer - COs, then cross-checks post-execution medium results against seer / vote - / attack consistency.""" - block = _build_game_rules_block() - assert "偽っぽい・狼っぽい・視点漏れ" in block - assert "処刑後の霊媒結果" in block - -def test_game_rules_block_describes_black_stop_and_its_limits() -> None: - """Black stop (灰 scrutiny when sole medium reports black on a seer CO) - is the alternative to continuing the roller, but it has explicit - exceptions: 真狼狼, fake medium, breaking remaining seer CO, PP risk.""" - block = _build_game_rules_block() - assert "灰 (役職 CO していない位置) の精査" in block - assert "真狼狼" in block - assert "PP (パワープレイ)" in block - - -def test_game_rules_block_defines_2_2_formation() -> None: - """2-2 = 2 seer COs + 2 medium COs. Neither side is confirmed; medium - roller (or 霊媒切り) is the default progression.""" - block = _build_game_rules_block() - assert "2-2" in block - assert "占い師 CO が 2 人・霊媒師 CO が 2 人" in block - assert "霊媒ローラー" in block - - -def test_game_rules_block_defines_2_1_formation() -> None: - """2-1 = 2 seer COs + 1 medium CO. Sole medium reads as truth-leaning - pivot; branch on black-out vs white-progress with rope / 囲い awareness.""" - block = _build_game_rules_block() - assert "2-1" in block - assert "占い師 CO が 2 人・霊媒師 CO が 1 人" in block - assert "単独霊媒師" in block - assert "グレー吊り" in block - assert "黒吊り" in block - - -def test_game_rules_block_defines_1_2_formation() -> None: - """1-2 = 1 seer CO + 2 medium COs. Seer reads truth-leaning while mediums - are treated as mixed-fake; medium roller / medium-kiri is default, grey - scrutiny only when medium internals read real+madman and grey is - wolf-heavy.""" +def test_game_rules_block_states_per_target_naming_required() -> None: block = _build_game_rules_block() - assert "1-2" in block - assert "占い師 CO が 1 人・霊媒師 CO が 2 人" in block - assert "霊媒ローラー" in block - assert "霊媒切り" in block - - -def test_game_rules_block_requires_completing_medium_roller_by_default() -> None: - """Two medium COs → don't unfoundedly trust either one; once a medium - roller is started, complete it by default, with a high evidentiary bar - to stop halfway.""" - block = _build_game_rules_block() - assert "根拠なく真置きせず" in block - assert "原則として完走" in block - - -# -------------------------- 3 占い CO + 2 非狼確定 で残る 1 人を確定黒級とする消去法 -def test_game_rules_block_describes_three_seer_co_elimination_inference() -> None: - """3-1 で占い CO 3 人のうち 2 人が公開情報上『本物の人狼ではない』と確定したら、 - 残る 1 人を固定配役上の消去法で確定黒級の人狼位置として扱う。""" - block = _build_game_rules_block() - assert "2 人が公開情報上『本物の人狼ではない』と確定" in block - assert "残る 1 人の占い師 CO を固定配役上の消去法" in block - assert "確定黒級" in block - assert "人狼 2 人固定の配役" in block - - -def test_game_rules_block_distinguishes_white_judgement_from_non_wolf_confirmation() -> None: - """『白判定』と『非狼確定』を混同しない。信用未確定の占い CO の白、 - 偽が混じり得る霊媒結果、印象だけの白寄り評価は非狼確定として数えない。""" - block = _build_game_rules_block() - assert "『白判定』と『非狼確定』を混同しない" in block - assert "信用が未確定な占い師 CO が出した白" in block - assert "偽が混じり得る霊媒結果" in block - assert "印象だけの白寄り評価は非狼確定として数えない" in block - assert "単発の白だけで非狼扱いを固定しない" in block - - -def test_game_rules_block_lists_acceptable_non_wolf_confirmation_grounds() -> None: - """非狼確定として数えてよい根拠は公開ログ・霊媒結果・襲撃死・ - 真寄り情報役の判定・CO 破綻整理に限る。""" - block = _build_game_rules_block() - assert "公開ログ・霊媒結果・襲撃死・真寄り情報役の判定" in block - assert "CO 破綻整理" in block - assert "本物の人狼ではないと示す襲撃死" in block - - -def test_game_rules_block_treats_remaining_seer_co_as_fixed_wolf_position() -> None: - """2 人非狼確定が成立したら、残る占い師 CO は『まだ灰の 1 人』ではなく - 固定配役上の狼位置として投票・発言・進行提案へ反映させる。""" - block = _build_game_rules_block() - assert "『まだ灰の 1 人』ではなく" in block - assert "固定配役上の狼位置として投票・発言・進行提案へ反映" in block - assert "残る占い師 CO の処刑提案" in block - - -def test_game_rules_block_releases_fixed_black_when_premise_collapses() -> None: - """村陣営騙り・CO 撤回・霊媒師 CO 偽の浮上・非狼確定根拠の破綻など - 前提が崩れたら、確定黒扱いを解除して時系列から再整理する。""" - block = _build_game_rules_block() - assert "前提が崩れた場合は確定黒扱いを解除して時系列から再整理" in block - assert "村陣営の騙り" in block - assert "CO 撤回" in block - # 既存の line 108 文言と区別するため『の浮上』『後から』語尾の anchor を使う - assert "霊媒師 CO 側が偽だった可能性が後から浮上した状況" in block - assert "非狼確定の根拠が後から破綻した場合" in block - assert "CO 履歴と判定履歴を時系列で再整理" in block - - -# ----------- 3 役職横断: CO 数・対抗 CO 超過分から非 CO 確白を読む消去法 -# 占い師・霊媒師・騎士の各 CO 数を時系列で整理し、各役職について `CO 数 - 1` -# を「対抗 CO 超過分」(騙り最低数) として数える。3 役職分の超過分を合計して 3 に -# 達した場合、人狼 2 + 狂人 1 の狼陣営 3 名が能力役職 CO 群に出切っているため、 -# 能力役職 CO していない位置は配役上の消去法で村陣営の確白級として扱える。 -def test_game_rules_block_defines_co_overflow_term() -> None: - """占い師・霊媒師・騎士の各 CO 数を時系列で整理し、CO 数 - 1 を対抗 CO 超過分 - (騙り最低数) として数える、という用語と式が共通ルールに入っていること。""" - block = _build_game_rules_block() - assert "対抗 CO 超過分" in block - assert "CO 数 - 1" in block - assert "騙り最低数" in block - assert "真役職は各 1 人だけ" in block - - -def test_game_rules_block_overflow_sum_three_marks_non_co_as_village_white() -> None: - """超過分合計が 3 に達した場合、能力役職 CO していない位置を村陣営の確白級 - として扱う。配役上の消去法で狼陣営 3 名が CO 群に出切ったと数える。""" - block = _build_game_rules_block() - assert "超過分合計が 3 に達した場合" in block - assert "人狼 2 + 狂人 1" in block - assert "能力役職 CO していない位置" in block - assert "村陣営の確白級" in block - assert "配役上の消去法" in block - - -def test_game_rules_block_overflow_sum_three_promotes_independent_single_co() -> None: - """超過分合計 3 のとき、対抗のない単独 CO 役職が別にあれば、 - その単独 CO 者も狼陣営ではないため真役職としてかなり強く扱える。""" - block = _build_game_rules_block() - assert "対抗のない単独 CO 役職" in block - assert "狼陣営ではないため真役職としてかなり強く扱える" in block - - -def test_game_rules_block_distinguishes_overflow_inference_from_madman_white() -> None: - """超過分合計 3 による非 CO 確白は、単発の白判定 (狂人白) とは別根拠で、 - 固定配役上の消去法で狼陣営 3 名が出切ったと数える点で村陣営まで強く推せる。""" - block = _build_game_rules_block() - assert "単発の白判定" in block - # 既存の確白語彙ルール (狂人白との整合) と矛盾しない言い回し。 - assert "固定配役上の消去法で狼陣営 3 名が CO 群に出切った" in block - assert "村陣営まで強く推せる" in block - - -def test_game_rules_block_overflow_sum_three_within_group_truth_still_needs_signals() -> None: - """対抗 CO 群の中で誰が真役職かまでは、超過分合計 3 だけでは決まらない。 - 判定結果・霊媒結果・投票・襲撃・死亡タイミング・破綻で詰める。""" - block = _build_game_rules_block() - assert "対抗 CO 群の中で誰が真役職かまでは超過分合計だけでは特定できない" in block - # 詰めに使う材料: 4 軸以上の言及。 - assert "判定結果・霊媒結果・投票・襲撃" in block - assert "死亡タイミング" in block + assert "「全員白」" in block or "「すべて白」" in block assert "破綻" in block -def test_game_rules_block_overflow_sum_low_does_not_confirm_non_co() -> None: - """超過分合計が 0〜2 の段階では、狼陣営が非 CO や単独 CO に残っている - 可能性があるため、非 CO 位置を CO 数だけで確白とは断定しない。""" - block = _build_game_rules_block() - assert "0〜2" in block - assert "断定しない" in block - assert "狼陣営が非 CO や単独 CO に残っている可能性" in block - - -def test_game_rules_block_overflow_sum_high_triggers_recheck() -> None: - """超過分合計が 4 以上に見える場合は固定配役と矛盾する。CO 撤回・ - 同一人物の複数 CO・話題としての CO 語彙の誤読・村騙り・死亡済み CO - 見落とし・ログ見落としを疑い、確白扱いを保留して時系列を再整理する。""" - block = _build_game_rules_block() - assert "4 以上" in block - assert "固定配役と矛盾" in block - assert "CO 撤回" in block - assert "同一人物の複数 CO" in block - assert "村騙り" in block - assert "死亡済み CO 見落とし" in block - assert "確白扱いを保留して時系列を再整理する" in block - - -def test_game_rules_block_includes_co_overflow_examples() -> None: - """LLM が数え方を誤らないよう、3-2-1 / 2-2-2 / 3-1-1 / 4-1-1 の - 短い例が共通ルールに入っていること。""" - block = _build_game_rules_block() - # 数え方の型: 超過分合計 3 / 2 の境界、4-1-1 のような偏ったケース。 - assert "3-2-1" in block - assert "2-2-2" in block - assert "3-1-1" in block - assert "4-1-1" in block - # 計算式が例の中に明示されているか (LLM が暗算を間違えない補助)。 - assert "2 + 1 + 0 = 3" in block - assert "1 + 1 + 1 = 3" in block - assert "2 + 0 + 0 = 2" in block - assert "3 + 0 + 0 = 3" in block - - -def test_game_rules_block_co_overflow_no_wolf_coordination_leak() -> None: - """新規追加した CO 超過分推理ブロックが、wolf-coordination 語彙 - (bare `相方`, `襲撃先を揃える`) を共通ルールに漏らさないこと。 - `相方候補` (公開ログからの推理用語) は許容。""" - block = _build_game_rules_block() - assert not re.search(r"相方(?!候補)", block), ( - "bare '相方' (actor mode) leaked into shared rules block" - ) - assert "襲撃先を揃える" not in block - - -# ------------------------------------- terminology (推理語彙) in rules block -# Advanced jinro vocabulary is shared across every LLM seat via the game-rules -# block (not per-role strategy). These assertions pin the substrings that the -# spec requires and guard against accidental leak of wolf-coordination -# vocabulary (`相方`, `襲撃先を揃える`) into the shared block. -def test_game_rules_block_frames_terminology_as_reading_tool() -> None: - """The terminology section is introduced as a *reading* tool that does not - override the preceding factual rules. The framing sentence anchors this.""" - block = _build_game_rules_block() - assert "推理語彙" in block - assert "最終判断は常に公開情報の整合性" in block - - -def test_game_rules_block_defines_grey_positions() -> None: - """グレー / 灰 means: no role CO AND no settled seer/medium white-black on - the seat. Both kanji and katakana forms must appear.""" - block = _build_game_rules_block() - assert "グレー" in block - assert "灰" in block - assert "白黒も十分ついていない" in block - - -def test_game_rules_block_describes_guran_as_non_random() -> None: - """グレラン is explicitly framed as reasoned grey-voting, not pure random — - the spec calls out this misreading as the #1 failure mode.""" +def test_game_rules_block_states_day1_seer_only_one_white_result() -> None: block = _build_game_rules_block() - assert "グレラン" in block - assert "理由を持って" in block - # The non-randomness must be made explicit; the word `無作為` appears only - # in the negation phrase. - assert "無作為" in block - assert "完全な無作為投票ではなく" in block + # day 1 朝の占い結果: NIGHT_0 ランダム白 1 件のみ + assert "NIGHT_0 ランダム白 1 件" in block + assert "day 1 朝" in block + # Black claim on day 1 is forbidden + assert "黒を主張" in block -def test_game_rules_block_defines_grey_scale_with_reasons() -> None: - """グレスケ / スケール: not just an ordering — each position must carry a - reason grounded in speech/vote/divination/attack consistency.""" +def test_game_rules_block_states_day1_medium_has_no_result() -> None: block = _build_game_rules_block() - assert "グレスケ" in block - assert "スケール" in block - assert "白い順" in block - assert "黒い順" in block - assert "理由" in block + assert "day 1 朝の霊媒結果は存在しない" in block -def test_game_rules_block_contains_rope_calculation_formula() -> None: - """縄計算: remaining executions, with the standard heuristic formula - spelled out. 9-player village starts at 4 縄.""" +def test_game_rules_block_states_seer_count_is_n_plus_one() -> None: block = _build_game_rules_block() - assert "縄計算" in block - assert "floor((生存人数 - 1) / 2)" in block - assert "4縄" in block + assert "通算 N+1 件" in block -def test_game_rules_block_clarifies_white_is_not_village_confirmed() -> None: - """Every LLM seat must see the contract that 白判定 ≠ 村陣営確定 because - the madman reads white. This also cross-references the existing rule on - line ~55 (`狂人は黒判定されない`) — both coexist in the same block.""" - block = _build_game_rules_block() - assert "狂人も白に出る" in block - assert "村陣営確定ではない" in block - # Guard: the pre-existing rule that the new terminology must not override. - assert "狂人は黒判定されない" in block +# ---------------------- candidate-token rule ---------------------- -def test_game_rules_block_defines_kakushiro_with_madman_caveat() -> None: - """確白 = 進行役候補 but never absolute village-confirmation (狂人 reads - white). The caveat must be phrased as `言い切りすぎない` to avoid promoting - a 確白 to full 村陣営 status.""" +def test_game_rules_block_requires_candidate_token_strict_match() -> None: block = _build_game_rules_block() - assert "確白" in block - assert "進行役候補" in block - assert "「村陣営確定」と言い切りすぎない" in block + assert "席3 Alice" in block + assert "完全一致" in block -def test_game_rules_block_rejects_single_fake_black_as_kakukuro() -> None: - """確黒 requires multi-view corroboration. A single lone-black from a - potentially-fake seer is NOT 確黒 — this must be stated verbatim so LLMs - don't overweight single fake-seer blacks during 2-2 / counter-CO.""" - block = _build_game_rules_block() - assert "確黒" in block - assert "単独の偽占い候補から黒を出されただけでは確黒ではない" in block - - -def test_game_rules_block_defines_panda_as_both_white_and_black() -> None: - """パンダ = a seat that received BOTH white and black judgments (from - different COs). The phrase `白判定と黒判定の両方` is the canonical test - anchor.""" - block = _build_game_rules_block() - assert "パンダ" in block - assert "白判定と黒判定の両方" in block +# ---------------------- CO inference ---------------------- -def test_game_rules_block_defines_roller_synonyms_with_completion() -> None: - """Both spellings ローラー and ロラ must appear as common vocabulary, and - the completion rule must be restated here so it cross-references (not - contradicts) the existing 2-2 `原則として完走` rule.""" +def test_game_rules_block_distinguishes_topical_co_from_self_declaration() -> None: + """The classic 騙り誤読 trap — must distinguish someone *talking about* + a role-CO from someone actually *naming themselves* as that role.""" block = _build_game_rules_block() - assert "ローラー" in block - assert "ロラ" in block - assert "開始したら原則完走" in block + assert "「占いCOについて」" in block or "話題化" in block + assert "本人が「私は占い師です」" in block -def test_game_rules_block_defines_kimeuchi_and_hatan() -> None: - """決め打ち and 破綻 must both appear as first-class terminology bullets, - not just as word-in-sentence uses elsewhere in the block.""" +def test_game_rules_block_treats_day1_single_co_as_truthy() -> None: block = _build_game_rules_block() - assert "- 決め打ち:" in block - assert "- 破綻:" in block + assert "単独 CO" in block + assert "day 1 朝" in block + # The "single CO is truth-bias by default" rule + assert "真として扱う" in block or "真寄り" in block -def test_game_rules_block_defines_line_kakoi_minuchigiri() -> None: - """Wolf-pattern vocabulary (ライン / 囲い / 身内切り) reaches every seat as - neutral reading tools, framed as patterns to *recognize*, not execute.""" +def test_game_rules_block_rejects_sole_survivor_as_single_co() -> None: + """対抗 CO 履歴があった役職で残存 CO が 1 人になった時点では + 『単独 CO だから真』とは扱わない (狼が情報役を残した可能性).""" block = _build_game_rules_block() - assert "- ライン:" in block - assert "- 囲い:" in block - assert "- 身内切り:" in block + assert "通算" in block + assert "現在生存中の占い CO は 1 人" in block -def test_game_rules_block_kakoi_does_not_treat_madman_as_known_ally() -> None: - """囲い must not describe the madman as a wolf-known ally. The old phrasing - `仲間の狼 (や狂人)` implied that wolves know the madman's seat, which is - false in this bot.""" +def test_game_rules_block_states_co_count_caps() -> None: block = _build_game_rules_block() - assert "仲間の狼 (や狂人)" not in block - assert "狼は狂人位置を知らない" in block + # 占い 4 / 霊媒 2 / 騎士 2 の上限 + assert "占い 4" in block + assert "霊媒 2" in block + assert "騎士 2" in block -def test_game_rules_block_minuchigiri_does_not_treat_madman_as_known_ally() -> None: - """身内切り must not phrase the madman as a known wolf ally to cut. The - old phrasing `仲間 (別の人狼や狂人)` incorrectly included 狂人 as a known - teammate.""" +def test_game_rules_block_states_overflow_sum_three_marks_non_co_white() -> None: + """Sum of (CO count - 1) across 占/霊/騎 reaching 3 means the wolf + side has filled out the occult-CO slots, so non-CO seats are + village-side white-grade.""" block = _build_game_rules_block() - assert "仲間 (別の人狼や狂人)" not in block - assert "狼が仲間の狼" in block + assert "超過分合計" in block or "対抗超過分" in block + assert "3" in block + assert "確白" in block -def test_game_rules_block_defines_vote_and_attack_traces_and_shiten() -> None: - """Behavioral-signal vocabulary: 票筋 / 噛み筋 / 視点漏れ must each have a - dedicated bullet defining the term, not just appear mid-sentence.""" +def test_game_rules_block_states_dead_seats_excluded_from_today_vote() -> None: block = _build_game_rules_block() - assert "- 票筋:" in block - assert "- 噛み筋:" in block - assert "- 視点漏れ:" in block + assert "死亡席" in block + assert "本日の処刑対象" in block or "vote target" in block -def test_game_rules_block_defines_endgame_vocabulary() -> None: - """Endgame terms: SG (scapegoat), GJ / 平和 (peaceful morning), PP - (power-play), RPP (random / lost PP) must all be defined so LLMs can - recognize and reason about late-game vote dynamics.""" - block = _build_game_rules_block() - assert "- SG" in block - assert "- GJ" in block - assert "平和" in block - assert "- PP" in block - assert "- RPP" in block - - -def test_game_rules_block_defines_advanced_guard_vocabulary() -> None: - """Advanced jinrō guard vocabulary reaches every seat via the shared - rules block so that public-log mentions of 鉄板護衛 / 変態護衛 / 捨て護衛 - / 護衛読み / 護衛誘導 / 連ガ無し / 狩人 are interpretable. Each term gets - its own dedicated bullet.""" - block = _build_game_rules_block() - assert "- 鉄板護衛:" in block - assert "- 変態護衛:" in block - assert "- 捨て護衛:" in block - assert "- 護衛読み:" in block - assert "- 護衛誘導:" in block - # 連ガ無し / 連続護衛不可 vocabulary bullet. - assert "- 連ガ無し" in block - assert "連続護衛不可" in block - # 狩人 / 狩 synonym mapping to 騎士. - assert "狩人" in block - assert "騎士と同じ意味" in block - - -def test_game_rules_block_clarifies_sutekogo_is_legal_target_choice_not_skip() -> None: - """The 捨て護衛 vocabulary bullet must explicitly say it is a - legal-candidate selection in this bot, not a skip / unsubmitted / - no-target action — otherwise an LLM might map 捨て護衛 to `intent=skip`.""" - block = _build_game_rules_block() - assert "捨て護衛" in block - assert "合法護衛候補" in block - assert "1 名を選ぶ行動" in block - # Negation phrasing — captures all three forbidden interpretations. - assert "未提出" in block - assert "skip ではない" in block - - -def test_game_rules_block_advanced_guard_vocab_no_wolf_coordination_leak() -> None: - """Defensive duplicate of the existing leak guard, focused on the new - advanced-guard bullets — they must not bleed wolf-coordination vocab into - the shared rules block. Bare `相方` (actor mode, partner-known) must be - absent; the inference noun `相方候補` (candidate) is allowed.""" - block = _build_game_rules_block() - assert not re.search(r"相方(?!候補)", block), ( - "bare '相方' (actor mode) leaked into shared rules block" - ) - assert "襲撃先を揃える" not in block - - -def test_game_rules_block_terminology_has_no_wolf_coordination_leak() -> None: - """Shared terminology must not bleed wolf-coordination vocabulary into - non-wolf prompts. Bare `相方` (actor mode, partner-known) and the exact - phrase `襲撃先を揃える` are the two anchors. The inference noun `相方候補` - is allowed in the shared 2-wolf-pair-inference subsection.""" +def test_game_rules_block_introduces_two_wolf_pair_inference() -> None: block = _build_game_rules_block() - assert not re.search(r"相方(?!候補)", block), ( - "bare '相方' (actor mode) leaked into shared rules block — would break " - "test_ask_system_prompt_non_wolf_excludes_wolf_strategy" - ) - assert "襲撃先を揃える" not in block, ( - "wolf-coordination '襲撃先を揃える' leaked into shared rules block" - ) + assert "相方候補" in block + assert "推理用語" in block -def test_game_rules_block_states_fake_co_legality() -> None: - """Fake COs (seer/medium/knight) must stay within the bot's legal mechanics. - Shared rules call out illegal knight-guard patterns (self-guard, consecutive - guard, dead-seat guard) and forbid fabricating medium results on no-execution - days or seer results that contradict the public timeline.""" - block = _build_game_rules_block() - assert "実ルール上あり得る内容" in block - assert "過去に自分が出した結果と矛盾しないか" in block - assert "処刑がなかった日に霊媒結果を捏造しない" in block - assert "自分護衛" in block - assert "同一対象連続護衛" in block - assert "死亡済み対象への護衛" in block +# ---------------------- progression playbooks ---------------------- -def test_game_rules_block_states_day1_fake_seer_must_be_white() -> None: - """NIGHT_0 random-white provenance forces day-1 fake-seer first result - to be white. The shared rules must say so explicitly so any fake CO seat - sees it.""" +def test_game_rules_block_defines_3_1_2_2_2_1_1_2_formations() -> None: block = _build_game_rules_block() - assert "NIGHT_0" in block - assert "初回" in block - assert "day 1" in block - assert "必ず白を主張" in block + for formation in ("3-1", "2-2", "2-1", "1-2"): + assert formation in block -def test_game_rules_block_states_day1_black_claim_is_breakdown() -> None: - """A day-1 first-result-black claim contradicts NIGHT_0 timeline and must - be framed as breakdown so wolves/madmen don't try it.""" +def test_game_rules_block_includes_rope_calculation() -> None: block = _build_game_rules_block() - assert "初回結果を黒" in block - assert "NIGHT_0 タイムラインと矛盾" in block - assert "破綻" in block - assert "day 1 で初回黒主張はしない" in block + assert "縄" in block + assert "floor((生存数 - 1) / 2)" in block + assert "9 人村開始時 4 縄" in block -def test_game_rules_block_defers_fake_black_to_day_2_plus() -> None: - """Fake black is allowed only from day 2+, with timeline integrity checks.""" - block = _build_game_rules_block() - assert "偽占い師の黒結果主張は day 2 以降" in block - assert "前夜に占ったという想定" in block - assert "対抗 CO の発表内容と矛盾しない" in block +# ---------------------- forbidden glossary terms ---------------------- -def test_game_rules_block_contains_enthusiast_checklist() -> None: - """Shared rules must carry the 発言の根拠チェックリスト so every seat sees - CO history / divination history / vote history / attack pattern / rope - count / own information scope as the grounding menu, and must cap speech - to 1–2 concrete points rather than long internal monologue.""" +def test_game_rules_block_no_longer_includes_term_glossary_section() -> None: + """The compact rewrite drops the 30-term glossary (グレラン / グレスケ / + 鉄板護衛 / 変態護衛 / 捨て護衛 / 視点漏れ / SG / etc.) because the speech + LLM can't use those terms in `text` anyway and the decision LLM + already gets the structural facts elsewhere. This test catches an + accidental restoration.""" block = _build_game_rules_block() - assert "CO 履歴" in block - assert "判定履歴" in block - assert "投票履歴" in block - assert "噛み筋" in block - assert "縄数" in block - assert "情報範囲" in block - assert "1〜2 点" in block - - -# ------------------------------------------------------- strategy block -# A phrase that must appear in exactly one role's tips — keyed by role. Used -# both to assert per-role content AND to assert no cross-leak into other roles. -_ROLE_UNIQUE_PHRASES: dict[Role, str] = { - Role.WEREWOLF: "相方を露骨に庇いすぎない", - Role.MADMAN: "人狼位置を知っている前提で話してはならない", - Role.SEER: "判定履歴を時系列で一貫", - Role.MEDIUM: "処刑された相手が狂人でも", - Role.KNIGHT: "前夜と違う相手を選ぶ", - Role.VILLAGER: "CO 騙りは村陣営としては行わない", -} - - -@pytest.mark.parametrize(("role", "phrase"), list(_ROLE_UNIQUE_PHRASES.items())) -def test_strategy_block_for_each_role_contains_own_tips(role: Role, phrase: str) -> None: - block = _build_strategy_block(role) - assert phrase in block, f"{role.name}'s strategy missing its own phrase {phrase!r}" - - -@pytest.mark.parametrize("role", list(Role)) -def test_strategy_block_loaded_from_per_role_markdown_file(role: Role) -> None: - """Each role's bullets are stored in - ``src/wolfbot/prompts/templates/strategy/.md`` and pulled in - via the template loader. The loader strips the human-only - ``# Title`` heading + blank line so the body the LLM sees stays - bullet-list-only. - - Locks in the on-disk mapping so a future contributor renaming / - moving the strategy files (for editing convenience) immediately - sees a failing test rather than a silent prompt drift. - """ - from wolfbot.llm.template import TEMPLATES_ROOT, load_template - - expected_path = TEMPLATES_ROOT / "strategy" / f"{role.value.lower()}.md" - assert expected_path.is_file(), f"missing strategy file: {expected_path}" - - raw = load_template(f"strategy/{role.value.lower()}") - # Heading line is intentional and must remain in the on-disk file - # for human readers, even though the loader strips it. - assert raw.startswith("# "), ( - f"strategy file {expected_path.name} should open with a markdown heading" - ) - - block = _build_strategy_block(role) - # Heading is stripped from what the prompt sees. - assert not block.startswith("# "), ( - f"{role.name} strategy block leaked the markdown heading into the prompt" - ) - # No trailing newline so the block concatenates cleanly with the - # surrounding system-prompt template (mirrors the legacy inline - # dict that ended without a trailing newline). - assert not block.endswith("\n") - - -@pytest.mark.parametrize("role", list(Role)) -def test_strategy_block_no_cross_role_leak(role: Role) -> None: - """For a given role, none of the OTHER roles' unique phrases may appear.""" - block = _build_strategy_block(role) - for other_role, other_phrase in _ROLE_UNIQUE_PHRASES.items(): - if other_role is role: - continue - assert other_phrase not in block, ( - f"{other_role.name}'s tip leaked into {role.name}'s strategy block" - ) - - -@pytest.mark.parametrize("role", list(Role)) -def test_wolf_coordination_vocabulary_only_in_wolf_strategy(role: Role) -> None: - """Bare `相方` (actor mode, partner-known) and `襲撃先を揃える` are wolf- - playbook vocabulary and must only appear in the werewolf strategy block — - never in any other role's tips. The inference noun `相方候補` is allowed - in non-wolf strategies as public-log inference language.""" - block = _build_strategy_block(role) - if role is Role.WEREWOLF: - assert "相方" in block - assert "襲撃先を揃える" in block - else: - assert not re.search(r"相方(?!候補)", block), ( - f"bare '相方' (actor mode) leaked into {role.name}" - ) - assert "襲撃先を揃える" not in block, ( - f"wolf coordination '襲撃先を揃える' leaked into {role.name}" - ) - - -def test_madman_strategy_prohibits_not_assumes_wolf_positions() -> None: - """The madman must NOT be told that wolf positions are known — only that - the opposite is true. The strategy text phrases this as a prohibition, so - the full prohibition phrase must be present and wolf-coordination tips - must still be absent (the madman does not get the wolves' playbook). - `相方候補` (inference noun) is allowed since the madman reasons from - public logs about who B might be if A is wolf.""" - block = _build_strategy_block(Role.MADMAN) - assert "人狼位置を知っている前提で話してはならない" in block - # No wolf-coordination playbook leaks: bare 相方 (known partner) absent; - # 相方候補 (public-log inference) allowed. - assert not re.search(r"相方(?!候補)", block) - assert "襲撃先を揃える" not in block - - -def test_medium_strategy_reinforces_madman_is_white_rule() -> None: - block = _build_strategy_block(Role.MEDIUM) - assert "人狼ではありませんでした" in block - assert "白結果だけでは村置き確定にはならない" in block - - -def test_medium_strategy_guards_against_seer_co_white_misread() -> None: - """The Medium must be told explicitly that medium-white on an executed - Seer-CO is not proof of a fake seer — the role-specific analog of the - shared rule in `_build_game_rules_block`.""" - block = _build_strategy_block(Role.MEDIUM) - assert "占い師 CO" in block - assert "霊媒結果が白" in block - assert "占い師 CO 偽の証明ではない" in block - - -def test_medium_strategy_separates_real_seer_from_non_wolf_fake() -> None: - """When medium-white lands on a Seer-CO, the medium should partition the - hypothesis space: real seer vs. non-wolf fake (madman, etc.).""" - block = _build_strategy_block(Role.MEDIUM) - assert "真占い師だった可能性" in block - assert "狂人" in block - assert "非狼" in block - - -def test_medium_strategy_routes_seer_co_suspicion_through_corroboration() -> None: - """To suspect a Seer-CO, medium must cite non-medium-result evidence: - counter CO, divination breakdown, speech timeline, votes, attack result, - death timing — NOT the white medium result itself.""" - block = _build_strategy_block(Role.MEDIUM) - assert "対抗 CO" in block - assert "発言時系列" in block - assert "襲撃結果" in block - assert "死亡タイミング" in block - - -def test_medium_strategy_includes_three_seer_co_elimination_inference() -> None: - """霊媒師は 3占いCO 盤面で 2 人非狼確定にできるかを毎日整理する。 - 霊媒白を非狼確定として数えてよいのは、自分の霊媒 CO 側が真寄りと - 十分読める段階で、霊媒結果以外の整合も併せて説明できる場合に限る。""" - block = _build_strategy_block(Role.MEDIUM) - assert "占い師 CO が 3 人" in block - assert "自分の霊媒 CO 側が真寄りと十分読める段階" in block - assert "霊媒結果以外の整合" in block - assert "残る占い師 CO を確定黒級として処刑提案・投票誘導" in block + # A few tell-tale glossary headers from the old version + assert "グレラン: グレーから各自" not in block + assert "鉄板護衛: 真寄り情報役" not in block + assert "変態護衛: セオリー上の本命" not in block + assert "視点漏れ: ある役職視点" not in block -def test_knight_strategy_advises_protection_success_co() -> None: - """On a peaceful morning (no casualty), the knight should consider CO-ing - with the guard target attached — and must always attach the guard target - when CO-ing on a protection-success claim.""" - block = _build_strategy_block(Role.KNIGHT) - assert "平和な朝" in block - assert "護衛成功" in block - assert "護衛先を添えて" in block - # Existing guidance is preserved. - assert "前夜と違う相手を選ぶ" in block - - -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_prioritizes_seer_fake_on_day1(role: Role) -> None: - """Wolf and madman both get guidance to consider faking seer on day 1.""" - block = _build_strategy_block(role) - assert "day 1" in block - assert "占い師騙り" in block - - -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_switches_to_medium_or_knight_fake_if_countered(role: Role) -> None: - """If a counter-seer CO is already out, both wolf and madman should - consider medium or knight fake on day 2+, with corresponding night-ability - results attached.""" - block = _build_strategy_block(role) - assert "対抗占い師" in block - assert "day 2" in block - assert "霊媒師騙り" in block - assert "騎士騙り" in block - assert "夜に能力を使った想定" in block - - -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_warns_against_over_faking(role: Role) -> None: - """Both wolf and madman are warned that piling on fake COs (6+) confirms - non-CO seats as white — at that point all wolves+madman have CO'd.""" - block = _build_strategy_block(role) - assert "6 人以上" in block - assert "騙りすぎ" in block - - -def test_madman_fake_strategy_has_no_wolf_coordination_vocabulary() -> None: - """The madman's new fake-CO guidance must not introduce wolf-coordination - vocabulary (bare `相方`, `襲撃先を揃える`) — the madman does not know the - real wolf positions. `相方候補` (public-log inference) is allowed.""" - block = _build_strategy_block(Role.MADMAN) - assert not re.search(r"相方(?!候補)", block) - assert "襲撃先を揃える" not in block - # Existing prohibition phrase still present. - assert "人狼位置を知っている前提で話してはならない" in block - - -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_presents_three_way_day1_choice(role: Role) -> None: - """Day-1 strategy must present **先制CO / 対抗CO / 潜伏** as three first-class - candidates, NOT funnel the LLM into one option via a single conditional - trigger. Empirical evidence (recent games) showed wolves never preempt and - almost always go silent — the 3-way framing forces real-time comparison - instead of mechanical defaults.""" - block = _build_strategy_block(role) - # All three options must appear as named tactics. - assert "先制CO" in block, f"{role.name}: 先制CO option missing" - assert "対抗CO" in block, f"{role.name}: 対抗CO option missing" - assert "潜伏" in block, f"{role.name}: 潜伏 option missing" - # Equal-weight framing: explicit 3-way comparison language. - assert "3 択" in block - assert "等しく強い" in block - # Anti-pattern guard: mechanical single-condition decisions called out. - assert "機械的" in block - # Counter the "潜伏 = safe" misconception observed empirically. - assert "潜伏は安全策ではない" in block - if role is Role.MADMAN: - # Madman variant must still avoid wolf-coordination vocabulary. - assert not re.search(r"相方(?!候補)", block) - assert "襲撃先を揃える" not in block - - -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_anchors_day1_first_result_to_white(role: Role) -> None: - """Both wolf and madman fake-seer guidance must anchor the day-1 first - divination result to white per the NIGHT_0 timeline.""" - block = _build_strategy_block(role) - assert "NIGHT_0 ランダム白" in block - assert "必ず白を主張" in block +# ========================================================================= +# strategy block — leakage & role-scope invariants +# ========================================================================= -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_prohibits_day1_black_claim(role: Role) -> None: - """Both wolf and madman must be told day-1 first-result black breaks the - NIGHT_0 timeline and must not be claimed.""" - block = _build_strategy_block(role) - assert "初日に黒を出す主張" in block - assert "破綻" in block - assert "絶対にしない" in block +_ALL_ROLES = ( + Role.WEREWOLF, + Role.MADMAN, + Role.SEER, + Role.MEDIUM, + Role.KNIGHT, + Role.VILLAGER, +) -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_defers_black_call_to_day_2_plus(role: Role) -> None: - """Both wolf and madman defer fake black to day 2+ with night-divination - framing.""" - block = _build_strategy_block(role) - assert "黒出しは day 2 以降" in block - assert "前夜に占ったという想定" in block +def test_strategy_block_renders_for_every_role() -> None: + for role in _ALL_ROLES: + block = _build_strategy_block(role) + assert block + # Each role file starts with a "# 〜 戦略" markdown heading; + # the loader strips it. The body should be plain bullets. + assert not block.startswith("# ") -def test_wolf_day1_white_target_integrates_partner_and_attack_pattern() -> None: - """Wolf-only: day-1 white-target selection must integrate partner position, - framing risk, and attack-pattern coordination.""" +def test_werewolf_strategy_includes_partner_coordination_vocabulary() -> None: block = _build_strategy_block(Role.WEREWOLF) - assert "白先選び" in block - assert "相方の位置" in block - assert "囲いリスク" in block - assert "噛み筋" in block - assert "襲撃計画" in block + # The wolf knows its partner — vocabulary that only makes sense inside + # the wolf seat. + assert "人狼チャット" in block + assert "襲撃先を 1 人に揃える" in block + # GJ rebite rule + assert "GJ" in block and "再噛み" in block -def test_madman_day_2_plus_black_still_carries_misfire_awareness() -> None: - """Madman-only: even when deferred to day 2+, the black-out misfire risk - persists because the madman never knows real wolf positions.""" - block = _build_strategy_block(Role.MADMAN) - assert "誤爆リスクは day 2 以降の黒出しでも常に残る" in block - # Existing misfire guidance must still be present (no regression). - assert "誤爆リスク" in block - assert "白先が本物の狼とは限らない" in block - - -def test_seer_strategy_covers_proactive_and_counter_co() -> None: - """Seer must have explicit guidance on day-1 CO when their own speaking - turn arrives, counter-CO against a fake seer with time-ordered history - disclosure, and the black-pull CO procedure. The on-turn framing - replaces the earlier 'mid-discussion no-seer-CO trigger' because the - NPC cannot self-trigger speech — the rule must fire whenever the - arbiter happens to dispatch them.""" - block = _build_strategy_block(Role.SEER) - assert "発言の番" in block - assert "次の自分のターンに先送りしない" in block - assert "対抗 CO" in block - assert "時系列で公開" in block - assert "黒を引いた場合" in block - assert "単独真として扱わせてしまう" in block - - -def test_seer_strategy_includes_three_seer_co_elimination_for_targeting() -> None: - """占い師は 3占いCO・自分以外の 2 人が非狼確定の盤面で、夜の占い対象選びで - 残る占い師 CO 位置の確認や相方候補ペア仮説を崩す方向を優先する。 - 非狼確定として数える根拠は説明可能なものに限る。""" - block = _build_strategy_block(Role.SEER) - assert "占い師 CO が 3 人" in block - assert "自分以外の 2 人が公開情報上で非狼確定" in block - assert "相方候補ペア仮説を崩す方向" in block - assert "霊媒結果・襲撃死・CO 破綻など説明可能な根拠に限る" in block - - -def test_medium_strategy_validates_seer_co_with_matching_judgment() -> None: - """Medium must use own black result to validate a seer-CO whose past - judgment matches. Game 76358c4623f0 had シゲミチ (medium) holding コメット - 黒 while ステラ-CO had previously claimed コメット黒 — the seer-CO was - therefore real, but シゲミチ joined the flow that called ステラ偽.""" - block = _build_strategy_block(Role.MEDIUM) - assert "占い師 CO の判定が一致した場合" in block - assert "真寄りに置く" in block - assert "流れに乗って" in block - assert "村陣営が真情報を失う" in block - - -def test_seer_strategy_prioritizes_co_over_addressed_reply() -> None: - """Real seer dispatched as `addressed` must lead with CO + result, not - answer the question first. Same root-cause as シゲミチ in game - 76358c4623f0 day 3 (co_declaration field set, body never named the CO).""" - block = _build_strategy_block(Role.SEER) - assert "他席から呼びかけられている (addressed) 状態でも" in block - assert "未公開の能力結果" in block - assert "発話の冒頭で行い" in block - assert "addressed への返答はその後に短く添える" in block - - -def test_medium_strategy_prioritizes_co_over_addressed_reply() -> None: - """Same as seer rule — medium dispatched while addressed must lead with - CO + medium-result and reply to the question afterward. Direct cause - of the シゲミチ day-3 failure in game 76358c4623f0.""" - block = _build_strategy_block(Role.MEDIUM) - assert "他席から呼びかけられている (addressed) 状態でも" in block - assert "未公開の霊媒結果" in block - assert "発話の冒頭で行い" in block - assert "addressed への返答はその後に短く添える" in block - - -def test_medium_strategy_covers_post_execution_publication_and_counter_co() -> None: - """Medium must publish results the day after an execution (when their - speaking turn arrives) and must run counter-CO against a fake medium - with time-ordered history framing while acknowledging self-roller - vulnerability. The on-turn framing replaces the earlier 'day after - execution' anchor because the NPC cannot self-trigger speech — the - rule must fire whenever the arbiter happens to dispatch them.""" - block = _build_strategy_block(Role.MEDIUM) - assert "前日に処刑があった" in block - assert "発言の番" in block - assert "次の自分のターンに先送りしない" in block - assert "対抗霊媒" in block - assert "ローラー" in block - assert "巻き込まれる可能性" in block - - -def test_knight_strategy_covers_endgame_and_legal_guard_history() -> None: - """Knight must cover endgame / about-to-be-hung CO timing AND must - explicitly constrain the guard-diary to the bot's legal guard rules - (no self-guard, no consecutive guard, no dead-seat guard).""" - block = _build_strategy_block(Role.KNIGHT) - assert "終盤" in block - assert "吊られそう" in block - assert "護衛履歴を日付順" in block - assert "自分護衛" in block - assert "同じ相手の連続護衛" in block - assert "死亡済み" in block - - -def test_knight_strategy_covers_multi_axis_guard_evaluation() -> None: - """Knight strategy must teach a 5-axis comparison rather than fixing a - single guard target. The five axis tokens must all appear so the LLM can - weigh candidates rather than always defaulting to the truth-leaning info - role.""" - block = _build_strategy_block(Role.KNIGHT) - assert "護衛価値" in block - assert "襲撃されやすさ" in block - assert "GJ" in block - assert "次夜" in block - assert "連続護衛不可" in block - assert "説明可能性" in block - - -def test_knight_strategy_distinguishes_tetsuban_vs_sutekogo() -> None: - """Knight strategy must define both 鉄板護衛 and 捨て護衛 with their - bot-specific framing: 鉄板護衛 prioritized when key roles are at risk, - 捨て護衛 always a legal-candidate choice (not skip).""" - block = _build_strategy_block(Role.KNIGHT) - assert "鉄板護衛" in block - assert "捨て護衛" in block - # 鉄板護衛 bullet must frame it as the priority when key roles are at risk. - assert "重要役職" in block - assert "優先" in block - # 捨て護衛 bullet must explicitly say it is a legal-candidate, 1-name - # choice — never skip / unsubmitted / no-target. - assert "合法候補" in block - assert "1 名" in block - assert "skip" in block - - -def test_knight_strategy_warns_against_sutekogo_overuse() -> None: - """The knight must not adopt 捨て護衛 as a default. The strategy must - contain a gating sentence that prefers 鉄板護衛 when key roles are at - immediate risk and explicitly forbids 捨て護衛 as a default action.""" - block = _build_strategy_block(Role.KNIGHT) - assert "毎夜の既定行動にしない" in block - - -def test_knight_strategy_includes_three_seer_co_elimination_for_guard_choice() -> None: - """騎士は 3占いCO・2 人非狼確定が成立したら、残る 1 人からの黒判定で - 守るべき真情報役・確白寄りと、次夜の本命護衛余地を比較する。 - 前提が崩れたら護衛方針も再整理する。""" - block = _build_strategy_block(Role.KNIGHT) - assert "占い師 CO が 3 人" in block - assert "次夜の本命護衛余地" in block - assert "前提が崩れたら護衛方針も再整理" in block - - -def test_villager_strategy_anchors_in_checklist() -> None: - """Villager must still forbid CO fakes AND must anchor speech in the - shared enthusiast checklist (CO / divination / vote histories).""" - block = _build_strategy_block(Role.VILLAGER) - assert "CO 騙りは村陣営としては行わない" in block - assert "CO 履歴" in block - assert "判定履歴" in block - assert "1〜2 点" in block - - -def test_villager_strategy_prohibits_villager_co() -> None: - """The villager must be explicitly forbidden from declaring '村人CO' / - '素村CO' / '普通の村人です' / '役職は村人です' as a trust-buy. The block - must also offer the alternative stance: stay non-CO, reason from public - information.""" - block = _build_strategy_block(Role.VILLAGER) - # Forbidden phrases the villager must not say. - assert "村人CO" in block - assert "素村CO" in block - assert "普通の村人です" in block - assert "役職は村人です" in block - # Reason: villagers have no ability result so CO carries no proof. - assert "村人は能力結果を持たない" in block - # Alternative stance the villager should take instead. - assert "非 CO の灰" in block - assert "役職 CO はない" in block - - -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN, Role.SEER, Role.MEDIUM, Role.KNIGHT]) -def test_villager_co_prohibition_does_not_leak_to_other_roles(role: Role) -> None: - """The villager-CO prohibition is scoped to the villager strategy. Other - roles must not see '村人CO' / '素村CO' wording — the wolf and madman fake - seer/medium/knight, never villager; the real seer/medium/knight have their - own CO playbooks. Cross-leak would either confuse fake-CO planning or - suppress legitimate role-CO.""" +@pytest.mark.parametrize( + "role", + [Role.MADMAN, Role.SEER, Role.MEDIUM, Role.KNIGHT, Role.VILLAGER], +) +def test_non_wolf_strategy_excludes_partner_coordination_vocabulary( + role: Role, +) -> None: + """Wolf-only coordination concepts (人狼チャット, 襲撃先を1人に揃える, GJ + rebite, 視点漏れ as a self-imposed rule) must not appear in any non-wolf + role's strategy. The non-wolf side may still use `相方候補` as an + inference-only term — it appears in 公開ログからの 2 人狼仮説 — so we + only exclude vocabulary that names the actual partner relationship.""" block = _build_strategy_block(role) - assert "村人CO" not in block - assert "素村CO" not in block + assert "人狼チャット" not in block + assert "襲撃先を 1 人に揃える" not in block + assert "再噛み" not in block -def test_villager_strategy_includes_three_seer_co_elimination_inference() -> None: - """村人は 3占いCO・2非狼確定の盤面で、残る占い師 CO 位置を投票・発言・ - 進行提案で固定配役上の狼位置として扱う方針を持つ。非狼確定の数え方の - 厳格さと前提崩壊時の再整理も明示。""" - block = _build_strategy_block(Role.VILLAGER) - assert "占い師 CO が 3 人" in block - assert "残る占い師 CO 位置を投票・発言・進行提案" in block - assert "印象白や信用未確定 CO の白判定では数えず" in block - assert "前提が崩れた瞬間" in block +def test_madman_strategy_prohibits_assuming_known_wolf_positions() -> None: + """The madman is wolf-faction but doesn't know who the wolves are. + The strategy must spell that out so the LLM doesn't fabricate + inside knowledge.""" + block = _build_strategy_block(Role.MADMAN) + assert "本物の人狼位置を知っている前提で話してはならない" in block -@pytest.mark.parametrize("role", list(Role)) -def test_three_seer_co_elimination_role_framings_do_not_cross_leak(role: Role) -> None: - """役職別 framing 文言が他 role の strategy block へ流れ込まないこと。 - 共通ルール本体は `_build_game_rules_block` 側にあるため、strategy 側の - 役職別フレーズが横滑りしてはいけない。""" - block = _build_strategy_block(role) - villager_phrase = "残る占い師 CO 位置を投票・発言・進行提案" - seer_phrase = "相方候補ペア仮説を崩す方向" - medium_phrase = "残る占い師 CO を確定黒級として処刑提案・投票誘導" - knight_phrase = "次夜の本命護衛余地" - if role is not Role.VILLAGER: - assert villager_phrase not in block, ( - f"villager-framing of 3-seer-CO elimination leaked into {role.name}" - ) - if role is not Role.SEER: - assert seer_phrase not in block, ( - f"seer-framing of 3-seer-CO elimination leaked into {role.name}" - ) - if role is not Role.MEDIUM: - assert medium_phrase not in block, ( - f"medium-framing of 3-seer-CO elimination leaked into {role.name}" - ) - if role is not Role.KNIGHT: - assert knight_phrase not in block, ( - f"knight-framing of 3-seer-CO elimination leaked into {role.name}" - ) +def test_madman_prohibition_does_not_leak_to_other_roles() -> None: + """The madman-only prohibition phrase must stay in madman.md and + nowhere else — accidental cross-import would re-frame other roles + as 'you don't know who the wolves are' which is misleading for + actual seer/medium players.""" + needle = "本物の人狼位置を知っている前提で話してはならない" + for role in _ALL_ROLES: + if role is Role.MADMAN: + continue + assert needle not in _build_strategy_block(role) -# ----------- 3 役職横断 CO 数・対抗 CO 超過分 推理: role 別運用面の追加 -# 共通ルール側の数学的整理 (CO 数 - 1 を超過分として 3 役職分集計、合計 3 で -# 非 CO 位置が確白級) は `_build_game_rules_block` で全 seat に届く。 -# 各 role strategy には「その役職ならどう使うか」だけを短く追加する。 -def test_villager_strategy_uses_co_overflow_inference() -> None: - """村人は公開ログから占い師・霊媒師・騎士の CO 数と対抗 CO 超過分を - 毎日整理し、超過分合計 3 なら能力役職 CO していない位置を村陣営の - 確白級として扱い、投票先を CO 群に絞る。""" +def test_villager_strategy_forbids_self_role_co() -> None: + """Villagers must not pretend to have private info — including the + common trap of self-claiming '村人CO / 素村CO'.""" block = _build_strategy_block(Role.VILLAGER) - assert "対抗 CO 超過分を毎日整理する" in block - assert "超過分合計が 3 に達したら能力役職 CO していない位置を村陣営の確白級" in block - assert "投票先を CO 群に絞る" in block - # 0〜2 と 4 以上の境界条件も村人 framing に含まれていること。 - assert "超過分合計が 0〜2 のうちは" in block - assert "超過分合計が 4 以上に見えたら" in block + assert "村人CO" in block or "素村CO" in block + assert "信用を取ろうとしない" in block or "CO しても証明にはならない" in block -def test_seer_strategy_avoids_wasting_divination_on_non_co_white() -> None: - """占い師は超過分合計 3 で非 CO 位置が確白級になった場合、そこを - 無駄占いせず、対抗 CO 群やまだ確定しない位置を優先して占う。""" +def test_seer_strategy_requires_co_and_results_at_first_speak() -> None: block = _build_strategy_block(Role.SEER) - assert "対抗 CO 超過分合計が 3 に達して能力役職 CO していない位置が非 CO 確白級" in block - assert "無駄占い" in block - assert "対抗 CO 群やまだ確定しない位置を優先して占う" in block - - -def test_medium_strategy_updates_co_inference_via_medium_result() -> None: - """霊媒師は霊媒結果で CO 数推理を更新する。処刑された CO 者が黒なら - 対抗 CO 群内の狼数を絞り、白なら真役職または狂人の可能性を分け、 - 非 CO 確白の前提が保たれるかを確認する (霊媒白=非狼のみのルール維持)。""" - block = _build_strategy_block(Role.MEDIUM) - assert "霊媒結果は対抗 CO 超過分の CO 数推理を更新する材料" in block - assert "対抗 CO 群内の狼数を絞り" in block - # 霊媒白は非狼だけを示す既存ルールとの整合: 真役職 / 狂人 を分ける。 - assert "白なら真役職または狂人の可能性を分け" in block - assert "非 CO 確白の前提が保たれるか" in block - - -def test_knight_strategy_protects_non_co_certified_white() -> None: - """騎士は超過分合計 3 で生まれた非 CO 確白級や、単独で対抗のない真寄り - 情報役を護衛価値が高い対象として扱う。連続護衛不可・襲撃読み・ - CO 時の説明可能性も合わせて判断。""" - block = _build_strategy_block(Role.KNIGHT) - assert "対抗 CO 超過分合計 3 で生まれた非 CO 確白級" in block - assert "単独で対抗のない真寄り情報役は護衛価値が高い" in block - # 既存制約 (連続護衛不可・襲撃読み・CO 時の説明可能性) との整合。 - assert "連続護衛不可" in block - assert "襲撃読み" in block - assert "CO 時の説明可能性" in block - - -def test_werewolf_strategy_acknowledges_overcounter_risk() -> None: - """人狼は能力役職 CO を増やしすぎると、対抗 CO 超過分合計が 3 に達した - 時点で非 CO 位置が村陣営の確白級として扱われ、処刑候補が CO 群に - 集中するリスクを認識する。騙りに出るか潜伏するかは CO 数と残り縄を - 見て、相方と整合する形で選ぶ (相方語彙は wolf 専用)。""" - block = _build_strategy_block(Role.WEREWOLF) - assert "対抗 CO 超過分" in block - assert "超過分合計が 3 に達した時点で" in block - assert "村陣営の確白級として扱われ" in block - assert "処刑候補が CO 群に集中する" in block - assert "相方と整合する形で選ぶ" in block - - -def test_madman_strategy_acknowledges_overcounter_risk_without_partner_vocab() -> None: - """狂人は同じリスクを公開情報視点で認識する。本物の人狼位置を - 知っている前提や bare `相方` (actor mode) を使ってはいけない。 - `相方候補` (公開ログからの推理用語) は引き続き許容。""" - block = _build_strategy_block(Role.MADMAN) - assert "対抗 CO 超過分" in block - assert "超過分合計が 3 に達した時点で" in block - assert "処刑候補が CO 群に集中するリスクを認識する" in block - # 公開情報視点であることが明示されていること。 - assert "公開情報の各 CO 数と残り縄から判断する" in block - # Wolf-coordination 語彙の漏れがないこと (既存 leak guard と同形)。 - assert not re.search(r"相方(?!候補)", block), ( - "bare '相方' (actor mode) leaked into madman CO-overflow addition" - ) - assert "襲撃先を揃える" not in block - # 既存 prohibition 文言は残ること。 - assert "人狼位置を知っている前提で話してはならない" in block - - -@pytest.mark.parametrize("role", list(Role)) -def test_co_overflow_role_framings_do_not_cross_leak(role: Role) -> None: - """各 role の CO 超過分 framing 文言が他 role の strategy block へ - 流れ込まないこと。共通ルールの数学的整理は `_build_game_rules_block` - 側にあるため、strategy 側の役職別フレーズが横滑りしてはいけない。""" - block = _build_strategy_block(role) - villager_phrase = "投票先を CO 群に絞る" - seer_phrase = "非 CO 確白級になった場合、そこを無駄占いせず" - medium_phrase = "霊媒結果は対抗 CO 超過分の CO 数推理を更新する材料" - knight_phrase = "対抗 CO 超過分合計 3 で生まれた非 CO 確白級" - werewolf_phrase = "相方と整合する形で選ぶ" - madman_phrase = "公開情報の各 CO 数と残り縄から判断する" - if role is not Role.VILLAGER: - assert villager_phrase not in block, ( - f"villager-framing of CO-overflow inference leaked into {role.name}" - ) - if role is not Role.SEER: - assert seer_phrase not in block, ( - f"seer-framing of CO-overflow inference leaked into {role.name}" - ) - if role is not Role.MEDIUM: - assert medium_phrase not in block, ( - f"medium-framing of CO-overflow inference leaked into {role.name}" - ) - if role is not Role.KNIGHT: - assert knight_phrase not in block, ( - f"knight-framing of CO-overflow inference leaked into {role.name}" - ) - if role is not Role.WEREWOLF: - assert werewolf_phrase not in block, ( - f"werewolf-framing of CO-overflow inference leaked into {role.name}" - ) - if role is not Role.MADMAN: - assert madman_phrase not in block, ( - f"madman-framing of CO-overflow inference leaked into {role.name}" - ) - - -# --------------------------------- wolf night-attack guard-aware vocabulary -# Wolf-only tactical phrases for night-attack reasoning. They must appear in -# the werewolf strategy and never leak into another role's strategy or into -# any non-WOLF_ATTACK night-action task. -_WOLF_ATTACK_ONLY_PHRASES = ( - "騎士候補を噛む", - "護衛リスクを読んで噛む", -) - - -def test_werewolf_strategy_includes_attack_evaluation_axes() -> None: - """The wolf gets the 4-axis comparison (value / guard-likelihood / - knight-candidacy / partner-fit) plus a GJ-risk hook. These are the new - anchors the LLM uses to weigh each candidate before locking a target.""" - block = _build_strategy_block(Role.WEREWOLF) - assert "襲撃価値" in block - assert "護衛されやすさ" in block - assert "騎士候補度" in block - assert "GJ" in block or "護衛リスク" in block - - -def test_werewolf_strategy_includes_attack_approach_taxonomy() -> None: - """The wolf must be able to label its kami as one of the five recognized - approaches so the per-day narrative stays consistent.""" - block = _build_strategy_block(Role.WEREWOLF) - for phrase in ("情報役噛み", "白位置噛み", "意見噛み", "騎士探し", "SG 残し"): - assert phrase in block, f"wolf strategy missing approach token {phrase!r}" - - -def test_werewolf_strategy_preserves_partner_convergence() -> None: - """Adding guard-reading content must not erase the existing partner-align - rule. `相方` and the `1 人に揃える` directive must both still be present.""" - block = _build_strategy_block(Role.WEREWOLF) - assert "相方" in block - assert "1 人に揃える" in block - - -def test_werewolf_strategy_disclaims_real_role_inference() -> None: - """Knight-candidate inference must be framed as public-log推定, not as a - claim about the actual role table. Guards against future copy-paste that - would imply the wolf knows the real knight seat.""" - block = _build_strategy_block(Role.WEREWOLF) - assert "実役職を知っている前提で断言してはならない" in block - assert "公開情報からの推定" in block - - -@pytest.mark.parametrize("role", [Role.MADMAN, Role.SEER, Role.MEDIUM, Role.KNIGHT, Role.VILLAGER]) -def test_wolf_attack_only_vocabulary_never_in_non_wolf_strategy(role: Role) -> None: - """The new tactical phrases are wolf-private. Any leak (e.g. someone copies - the wolf bullet into the knight strategy by mistake) must trip this guard.""" - block = _build_strategy_block(role) - for phrase in _WOLF_ATTACK_ONLY_PHRASES: - assert phrase not in block, f"wolf-only attack vocab {phrase!r} leaked into {role.name}" - - -# ---------------------------------------------------- night-action task block -def test_task_night_action_wolf_attack_includes_evaluation_checklist() -> None: - """The WOLF_ATTACK night task hands the LLM the 4-axis checklist inline so - even an LLM that ignored the strategy block sees the rubric on the action - turn.""" - text = task_night_action(SubmissionType.WOLF_ATTACK, ["席1 A", "席2 B"]) - assert "襲撃価値" in text - assert "護衛されやすさ" in text - assert "騎士候補度" in text - assert "翌日の説明しやすさ" in text - assert "騎士探し" in text - # Existing partner-align nudge is preserved. - assert "強い反対理由がなければ" in text - - -@pytest.mark.parametrize("kind", [SubmissionType.SEER_DIVINE, SubmissionType.KNIGHT_GUARD]) -def test_task_night_action_non_wolf_excludes_attack_checklist( - kind: SubmissionType, -) -> None: - """The seer-divine / knight-guard tasks must not inherit the wolf attack - checklist — only WOLF_ATTACK gets it.""" - text = task_night_action(kind, ["席1 A", "席2 B"]) - assert "襲撃価値" not in text - assert "護衛されやすさ" not in text - assert "騎士候補度" not in text - assert "騎士探し" not in text - assert "翌日の説明しやすさ" not in text - - -def test_task_night_action_seer_divine_includes_targeting_checklist() -> None: - """SEER_DIVINE task hands the seer a targeting rubric inline. The new axes - push the LLM beyond random selection from legal candidates and toward - high-information targets (counter-CO whites, vote oddities, gray-narrowing - positions). These tokens must reach the LLM via the task block even if the - role strategy block is somehow ignored.""" - text = task_night_action(SubmissionType.SEER_DIVINE, ["席1 A", "席2 B"]) - # Positive — seer-targeting axes anchors must be present. - assert "占い価値" in text - assert "灰を狭める" in text - assert "対抗 CO" in text - assert "囲い候補" in text - assert "投票" in text - assert "白でも黒でも情報が落ちる" in text - # Negative — none of the wolf-task forbidden substrings may appear here. - # (The parametrized exclusion test below also covers this; this focused - # negative makes the contract explicit at the SEER_DIVINE call site.) - assert "襲撃価値" not in text - assert "護衛されやすさ" not in text - assert "騎士候補度" not in text - assert "翌日の説明しやすさ" not in text - assert "騎士探し" not in text - - -def test_task_night_action_knight_guard_includes_evaluation_checklist() -> None: - """KNIGHT_GUARD task hands the knight a 5-axis checklist inline so even an - LLM that ignored the strategy block sees the rubric on the action turn. - Critical: the knight task must NOT reuse the wolf-attack vocabulary - (襲撃価値 / 護衛されやすさ / 騎士候補度 / 翌日の説明しやすさ / 騎士探し) — - those would trip the existing leak guard. The knight uses its own - parallel vocabulary (鉄板護衛すべき価値 / 今夜実際に噛まれそうか / - 次夜に同じ相手を守れないリスク / 捨て護衛で本命護衛余地を残す価値).""" - text = task_night_action(SubmissionType.KNIGHT_GUARD, ["席1 A", "席2 B"]) - # Positive — knight checklist anchors must reach the LLM via the task block. - assert "鉄板護衛" in text - assert "捨て護衛" in text - assert "次夜" in text - assert "GJ" in text - # 捨て護衛 must be framed as a legal-candidate, 1-name selection (not skip). - assert "1 名" in text - assert "skip" in text - # Negative — none of the wolf-task forbidden substrings may appear here. - assert "襲撃価値" not in text - assert "護衛されやすさ" not in text - assert "騎士候補度" not in text - assert "翌日の説明しやすさ" not in text - assert "騎士探し" not in text - - -# ----------------------------------------------------- wolf-chat task block -def test_task_wolf_chat_includes_guard_and_knight_candidate_reasons() -> None: - """Wolf-chat coordination must elicit *why* (guard risk, knight-candidate, - approach taxonomy, agree/disagree) — not just *who*. The 1人に揃える - directive and 80–150 char budget remain so partners still converge.""" - text = task_wolf_chat(["席3 P"], ["席1 A", "席2 B"]) - assert "護衛リスク" in text - assert "騎士候補" in text - assert "賛否" in text - assert "1 人に揃える" in text - assert "80〜150 字" in text - # All five approach tokens must be offered as labels for the reason. - for phrase in ("情報役噛み", "白位置噛み", "意見噛み", "騎士探し", "SG 残し"): - assert phrase in text, f"wolf-chat task missing approach token {phrase!r}" - - -# ----------------------------------------------------- daytime speech task -def test_task_daytime_speech_default_omits_first_round_rule() -> None: - """Default call (no `discussion_round`, no `role`) — runoff and any - backward-compat caller — must NOT include the day-2+ first-round mandatory - result rule, NOR the day-1 wolf-side fake-CO selection nudge.""" - text = task_daytime_speech(2) - assert "1 巡目" not in text - assert "前夜の能力結果" not in text - assert "占い師騙り・霊媒師騙り・潜伏の 3 択" not in text - - -def test_task_daytime_speech_day1_round1_omits_day2_rule() -> None: - """Day 1 round 1 must NOT include the day-2+ rule. Day 1 first results are - already constrained by NIGHT_0 random white / day-1 first white in the - game rules block; the day-2+ rule does not apply on day 1. The default - (no `role`) call must also not include the wolf-side fake-CO selection - nudge — only WEREWOLF / MADMAN callers see it.""" - text = task_daytime_speech(1, discussion_round=1) - assert "1 巡目" not in text - assert "前夜の能力結果" not in text - assert "占い師騙り・霊媒師騙り・潜伏の 3 択" not in text - - -def test_task_daytime_speech_day2_round2_omits_first_round_rule() -> None: - """Round 2 of day 2 must NOT carry the 'must attach prior-night results' - imperative. Round 2 is for follow-ups; the first-round publication - requirement is round-1 only.""" - text = task_daytime_speech(2, discussion_round=2) - assert "1 巡目" not in text - assert "前夜の能力結果" not in text - - -def test_task_daytime_speech_day2_round1_publishes_prior_night_results() -> None: - """Day 2+ round 1: CO'd or about-to-CO seer/medium/knight LLM must attach - prior-night ability results. The task text must call out all three roles - and their respective result formats so the LLM can match its own role.""" - text = task_daytime_speech(2, discussion_round=1) - # Headline tokens. - assert "day 2 以降" in text - assert "1 巡目" in text - assert "前夜" in text - assert "能力結果" in text - # All three info roles must be addressed. - assert "占い師" in text - assert "霊媒師" in text - assert "騎士" in text - # Result format anchors. - assert "白/黒" in text - assert "前日処刑者" in text - assert "結果なし" in text - assert "合法な護衛履歴" in text - assert "平和な朝" in text - # Trust-pressure framing — withholding lowers credibility. - assert "信用低下" in text or "破綻" in text - # Wolf-coordination vocabulary must NOT appear in this task block (every - # role sees this task text). Bare 相方 (actor mode) absent; 相方候補 - # (public-log inference noun) is allowed in the daytime speech task. - assert not re.search(r"相方(?!候補)", text) - assert "襲撃先を揃える" not in text - - -@pytest.mark.parametrize("day", [3, 5]) -def test_task_daytime_speech_day_n_round1_publishes_prior_night_results(day: int) -> None: - """The 'day 2 以降' rule applies on day 3, 5, etc. as well — verifies the - `day_number >= 2` gate, not a literal day-2 check.""" - text = task_daytime_speech(day, discussion_round=1) - assert "day 2 以降" in text - assert "1 巡目" in text - assert "能力結果" in text - - -def test_task_daytime_speech_base_contract_preserved() -> None: - """Every variant must still surface the base intent/skip + 80–300 char - contract — neither the day-2+ branch nor the default branch may erase it.""" - for kwargs in ( - {"day_number": 1}, - {"day_number": 2}, - {"day_number": 1, "discussion_round": 1}, - {"day_number": 2, "discussion_round": 1}, - {"day_number": 2, "discussion_round": 2}, - ): - text = task_daytime_speech(**kwargs) - assert "intent=speak" in text - assert "intent=skip" in text - assert "80〜300 字" in text - - -# ------------------------------------------------ day 2+ round 1 fake publication -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_day2_round1_publishes_prior_night_results(role: Role) -> None: - """Both wolf and madman, when faking seer/medium/knight on day 2+, must be - instructed to publish the prior-night ability result on the day-2+ round-1 - speech. Headline tokens (1 巡目, 前夜, 能力結果) must appear.""" - block = _build_strategy_block(role) - assert "day 2 以降" in block - assert "1 巡目" in block - assert "前夜" in block - assert "能力結果" in block + assert "発言の番が回ってきたら" in block + assert "CO + 判定結果を発表" in block or "CO + 結果" in block -def test_werewolf_fake_strategy_integrates_with_attack_plan() -> None: - """Wolf-only: the fake-result guidance must integrate with the wolf's - private knowledge (partner position, framing risk, attack pattern) and - cross-checks (medium results, vote history, legal guard history).""" - block = _build_strategy_block(Role.WEREWOLF) - assert "相方" in block - assert "囲い" in block - assert "噛み筋" in block - assert "霊媒結果" in block - assert "合法な護衛履歴" in block - - -def test_madman_fake_strategy_acknowledges_misfire_and_legal_constraints() -> None: - """Madman-only: the fake-result guidance must include misfire awareness - (誤爆リスク, 白先が本物の狼とは限らない), the no-execution-no-result rule, - and the legal-guard-history constraint. Wolf-coordination vocabulary - (bare `相方`, `襲撃先を揃える`) must remain absent; `相方候補` (public-log - inference) is allowed in the new pair-inference bullets.""" - block = _build_strategy_block(Role.MADMAN) - assert "誤爆リスク" in block - assert "白先が本物の狼とは限らない" in block - assert "処刑なしの日は結果なし" in block - assert "合法な護衛履歴" in block - # Existing leak guard: bare 相方 absent; 相方候補 (inference) allowed. - assert not re.search(r"相方(?!候補)", block) - assert "襲撃先を揃える" not in block - - -# ------------------------------------------ day 1 medium fake-CO / 2-2 / 1-2 layout -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_offers_medium_fake_as_day1_option(role: Role) -> None: - """Wolf and madman must teach day-1 medium fake-CO as a real choice - alongside seer fake and 潜伏 — not just a day-2+ post-hoc move. The - existing day-2+ post-hoc framing must remain (no regression).""" - block = _build_strategy_block(role) - assert "霊媒師騙りも day 1 の選択肢" in block - assert "占い師騙り" in block - assert "潜伏" in block - assert "対抗占い師 CO が出ている場合は、day 2 以降に霊媒師騙り" in block +def test_medium_strategy_states_day1_morning_has_no_result() -> None: + block = _build_strategy_block(Role.MEDIUM) + assert "day 1 朝の霊媒結果は構造的に存在しない" in block -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_frames_2_2_and_1_2_as_engineerable_layouts(role: Role) -> None: - """Wolf-side strategy must frame 2-2 and 1-2 as boards the wolf side can - engineer through medium fake-CO, not just descriptive labels. The - creation-mode form `2-2 を作` is the directive anchor.""" - block = _build_strategy_block(role) - assert "1-2" in block - assert "2-2 を作" in block - assert "霊媒ローラー" in block +def test_knight_strategy_forbids_self_and_dead_target_guards() -> None: + block = _build_strategy_block(Role.KNIGHT) + assert "自分護衛" in block + assert "死亡対象護衛" in block -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_day1_medium_co_does_not_publish_result(role: Role) -> None: - """day-1 medium fake-CO must explicitly NOT publish an execution result — - no execution has happened yet. This composes with (does not replace) the - existing day-2+ '前日処刑者だけに結果を出す' rule.""" +@pytest.mark.parametrize("role", _ALL_ROLES) +def test_every_role_strategy_carries_attack_victim_non_wolf_hard_fact( + role: Role, +) -> None: + """The 襲撃死=非狼 rule is so safety-critical it's repeated in every + role-specific block — tests catch accidental deletion. Wolf phrases + it as a self-imposed silence rule (not to claim victims as wolves); + other roles phrase it as a listener-side detection rule.""" block = _build_strategy_block(role) - assert "day 1 に霊媒師騙りで CO する場合" in block - assert "まだ処刑が発生していない" in block - assert "存在しない初日結果を捏造しない" in block + assert "(襲撃)" in block + assert "襲撃死=非狼" in block or "非狼確定" in block -def test_madman_medium_fake_carries_misfire_awareness_for_both_colors() -> None: - """Madman-only: the medium-fake guidance must say even when picking - medium-black or medium-white, the madman never knows real wolf positions, - so 誤爆 / 誤支援 risk persists for both colors. Wolf-coordination - vocabulary stays absent.""" - block = _build_strategy_block(Role.MADMAN) - assert "霊媒黒で本物の狼を切ってしまう" in block - assert "霊媒白で真占いを補強してしまう" in block - assert "誤爆" in block - assert "誤支援" in block - # Leak guard preserved. - assert not re.search(r"相方(?!候補)", block) - assert "襲撃先を揃える" not in block - - -@pytest.mark.parametrize("role", list(Role)) -def test_layout_creation_directives_only_in_wolf_madman_strategy(role: Role) -> None: - """The wolf-side directive form '2-2 を作' / '1-2 を作' / '霊媒師騙りを選' - is creation-mode wolf-side guidance and must appear only in WEREWOLF and - MADMAN strategies — never in SEER / MEDIUM / KNIGHT / VILLAGER. The - descriptive board labels in the shared rules block are not wolf-side - directives and are unaffected by this guard.""" - block = _build_strategy_block(role) - if role in (Role.WEREWOLF, Role.MADMAN): - # Creation directive must reach wolf side. - assert "2-2 を作" in block - else: - assert "2-2 を作" not in block, f"'2-2 を作' (creation directive) leaked into {role.name}" - assert "1-2 を作" not in block, f"'1-2 を作' (creation directive) leaked into {role.name}" - assert "霊媒師騙りを選" not in block, ( - f"'霊媒師騙りを選' (wolf-side selection directive) leaked into {role.name}" - ) +# ========================================================================= +# build_system_prompt — block composition +# ========================================================================= -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_task_daytime_speech_day1_round1_wolf_or_madman_offers_medium_fake_as_option( - role: Role, -) -> None: - """At day-1 round-1 only, the daytime speech task injects a 3-way fake-CO - selection nudge for wolf and madman — a per-call task-level reminder that - re-surfaces the day-1 selection at the moment of speech generation. - Default (no role) and other rounds must not include it (verified by - sibling default-omission tests).""" - text = task_daytime_speech(1, discussion_round=1, role=role) - assert "占い師騙り・霊媒師騙り・潜伏の 3 択" in text - assert "1-2" in text - assert "2-2 を作" in text - # Leak-guard regression — no wolf-coordination vocab in the task block. - assert not re.search(r"相方(?!候補)", text) - assert "襲撃先を揃える" not in text - - -# ------------------------------------------------ seer night-divination axes -def test_seer_strategy_includes_night_divination_targeting_axes() -> None: - """The true seer strategy must teach the LLM to pick high-information - targets rather than randomizing over legal candidates. The axes echo the - SEER_DIVINE task block so the LLM sees them in both places.""" - block = _build_strategy_block(Role.SEER) - assert "占い価値" in block - assert "灰を狭める" in block - assert "対抗 CO" in block - assert "囲い候補" in block - assert "投票" in block - assert "白でも黒でも情報が落ちる" in block - # Existing SEER unique anchor (cross-leak pivot) must still be present. - assert "判定履歴を時系列で一貫" in block - - -# --------------------------------------------------- build_system_prompt -# Sentinels (not real pronouns) so the test persona can't false-positive in -# other tests that grep for `私` / `君` / etc. in the rendered prompt. -_TEST_SPEECH_PROFILE = SpeechProfile( - first_person="TEST_FP", - address_style="TEST_ADDRESS", - sentence_style="TEST_SENTENCE", - pause_style="TEST_PAUSE", -) -_TEST_PERSONA = Persona( - key="_test_persona_", - display_name="TEST_NAME", - style_guide="TEST_STYLE_GUIDE", - speech_profile=_TEST_SPEECH_PROFILE, -) +def _persona() -> Persona: + """A minimal persona for system-prompt composition tests.""" + return Persona( + key="test_p", + display_name="🧪テスト", + style_guide="淡々と理屈で詰める。", + speech_profile=SpeechProfile( + first_person="僕", + self_reference_aliases=(), + address_style="呼び捨て", + sentence_style="短く言い切る", + pause_style="間は置かない", + signature_phrases=("……まあ",), + forbidden_overuse=("無闇な敬語",), + narration_mode=None, + ), + judgment_profile=JudgmentProfile(), + ) -def test_existing_blocks_still_rendered() -> None: - """persona_block / role_block / phase_block / task_block substitutions - still happen after the rules+strategy additions.""" +def test_build_system_prompt_substitutes_every_placeholder() -> None: + persona = _persona() prompt = build_system_prompt( - persona=_TEST_PERSONA, - role=Role.VILLAGER, + persona=persona, + role=Role.SEER, phase=Phase.DAY_DISCUSSION, - day_number=2, - task_text="TASK_BLOCK_MARKER_XYZ", - ) - # persona - assert "TEST_NAME" in prompt - assert "TEST_STYLE_GUIDE" in prompt - # speech profile block reached (sentinels in _TEST_SPEECH_PROFILE) - assert "TEST_ADDRESS" in prompt - # role (via ROLE_JA) - assert ROLE_JA[Role.VILLAGER] in prompt - # phase - assert Phase.DAY_DISCUSSION.value in prompt - assert "day 2" in prompt - # task - assert "TASK_BLOCK_MARKER_XYZ" in prompt - - -@pytest.mark.parametrize("role", list(Role)) -def test_build_system_prompt_has_no_unreplaced_placeholders(role: Role) -> None: - """Every `{placeholder}` token in the template must be substituted for - every role. A leftover `{role_block}` etc. would be a bug.""" - prompt = build_system_prompt( - persona=_TEST_PERSONA, - role=role, + day_number=1, + task_text="<>", + ) + # No placeholder leaks unfilled. + for marker in ( + "{game_rules_block}", + "{persona_block}", + "{judgment_profile_block}", + "{speech_profile_block}", + "{role_block}", + "{strategy_block}", + "{phase_block}", + "{task_block}", + ): + assert marker not in prompt + # Persona surfaces. + assert "🧪テスト" in prompt + assert "淡々と理屈で詰める" in prompt + # Phase + day surfaces. + assert "DAY_DISCUSSION" in prompt + assert "day 1" in prompt + # Task surfaces verbatim. + assert "<>" in prompt + # Role surfaces using ROLE_JA mapping. + assert ROLE_JA[Role.SEER] in prompt + # Strategy + rules blocks both reach the prompt. + assert "(襲撃)" in prompt # rules block + assert "対抗 CO" in prompt # rules + seer strategy share this + + +def test_build_system_prompt_role_scope_only_emits_own_strategy() -> None: + """Composing for one role must not splice in another role's strategy + body — wolf coordination must not appear in seer's prompt etc.""" + persona = _persona() + seer_prompt = build_system_prompt( + persona=persona, + role=Role.SEER, phase=Phase.DAY_DISCUSSION, day_number=1, - task_text="t", + task_text="", ) - leftover = re.findall(r"\{[a-z_]+_block\}", prompt) - assert leftover == [], f"unreplaced placeholders for {role.name}: {leftover}" + # Wolf vocabulary is fenced to werewolf.md + assert "襲撃先を揃える" not in seer_prompt + # Madman-only prohibition is fenced to madman.md + assert "本物の人狼位置を知っている前提で話してはならない" not in seer_prompt -@pytest.mark.parametrize("role", list(Role)) -def test_build_system_prompt_embeds_role_appropriate_strategy(role: Role) -> None: - """The full system prompt for a given role must contain that role's own - unique strategy phrase AND must NOT contain any other role's unique - strategy phrase. This is the integration-level analog of - `test_strategy_block_no_cross_role_leak`.""" - prompt = build_system_prompt( - persona=_TEST_PERSONA, - role=role, - phase=Phase.DAY_DISCUSSION, - day_number=1, - task_text="t", - ) - assert _ROLE_UNIQUE_PHRASES[role] in prompt - for other_role, other_phrase in _ROLE_UNIQUE_PHRASES.items(): - if other_role is role: - continue - assert other_phrase not in prompt, ( - f"{other_role.name}'s tip leaked into full system prompt for {role.name}" - ) +# ========================================================================= +# judgment / speech profile blocks — band rendering & leakage +# ========================================================================= -def test_build_system_prompt_contains_common_rules_for_any_role() -> None: - """The rules block is shared across all roles — every role's system prompt - must contain the canonical rule markers.""" - for role in Role: - prompt = build_system_prompt( - persona=_TEST_PERSONA, - role=role, - phase=Phase.DAY_DISCUSSION, - day_number=1, - task_text="t", - ) - # Role distribution markers (sampled) — derived from ROLE_DISTRIBUTION. - assert "人狼2" in prompt - assert "村人3" in prompt - # Win conditions — mirrors rules.check_victory. - assert "生存人狼数が 0" in prompt - assert "生存人狼数が生存非人狼人数以上" in prompt - - -# --------------------------------------------------- speech profile block -# Structural assertions. Content (specific phrases per persona) is sourced from -# the spec file; these tests pin the shape + a few high-signal anchors so -# regressions in the renderer are caught early. -def test_speech_profile_block_standard_persona_lists_first_person() -> None: - block = _build_speech_profile_block(PERSONAS_BY_KEY["setsu"]) - assert "一人称" in block - assert "『私』" in block - assert "君" in block - # Standard mode must not bleed silent_gesture markers. - assert "叙述モード" not in block +def _persona_with_judgment(profile: JudgmentProfile) -> Persona: + return Persona( + key="band_p", + display_name="バンド", + style_guide="テスト用", + speech_profile=SpeechProfile( + first_person="私", + self_reference_aliases=(), + address_style="さん付け", + sentence_style="標準", + pause_style="標準", + signature_phrases=(), + forbidden_overuse=(), + narration_mode=None, + ), + judgment_profile=profile, + ) -def test_speech_profile_block_sq_lists_self_alias_and_low_frequency_flag() -> None: - block = _build_speech_profile_block(PERSONAS_BY_KEY["sq"]) - assert "『アタシ』" in block - assert "SQちゃん" in block - assert "DEATH" in block - # Rate-limiting advice must appear so the LLM is told DEATH is sparing. - assert "低頻度" in block +def test_judgment_profile_block_extreme_logical_persona() -> None: + persona = _persona_with_judgment( + JudgmentProfile( + trust_hard_facts=1.0, + trust_medium_facts=1.0, + contrarian_bias=0.0, + aggression=0.0, + bandwagon_tendency=0.0, + ) + ) + block = _build_judgment_profile_block(persona) + assert "絶対視" in block + assert "基本受け入れる" in block + assert "多数派にあえて逆らわない" in block + assert "慎重で疑い先を出すのが遅い" in block + assert "単独行動を好み流れに乗らない" in block -def test_speech_profile_block_yuriko_uses_konomi_and_not_kimi() -> None: - """Yuriko's 2nd person is『お前』, not『君』— a common LLM drift hazard.""" - block = _build_speech_profile_block(PERSONAS_BY_KEY["yuriko"]) - assert "『この身』" in block - assert "お前" in block - assert "君" not in block +def test_judgment_profile_block_neutral_persona_renders_mid_bands() -> None: + persona = _persona_with_judgment(JudgmentProfile()) + block = _build_judgment_profile_block(persona) + assert "標準" in block -def test_speech_profile_block_kukrushka_is_silent_gesture() -> None: - """Kukrushka's block is structurally different (no `一人称` line, has - `叙述モード` + gesture examples instead).""" - block = _build_speech_profile_block(PERSONAS_BY_KEY["kukrushka"]) +def test_speech_profile_block_kukrushka_uses_silent_gesture() -> None: + """Kukrushka is the only persona that narrates by gesture (silent_gesture + mode) — the block must structurally differ from chatty personas (no + 一人称 line, gesture examples instead).""" + persona = PERSONAS_BY_KEY["kukrushka"] + block = _build_speech_profile_block(persona) assert "叙述モード" in block - assert "所作" in block - # Structural difference: the normal `一人称` line is absent. assert "一人称" not in block @pytest.mark.parametrize( "key", - [k for k, p in PERSONAS_BY_KEY.items() if p.speech_profile.narration_mode == "standard"], + [k for k in PERSONAS_BY_KEY if k != "kukrushka"], ) -def test_speech_profile_block_standard_personas_share_structure(key: str) -> None: - """All `narration_mode == "standard"` personas share the same scaffold. - Filtering by attribute (not by hard-coded key exclusion) keeps the test - correct if another silent-gesture persona is added later.""" +def test_speech_profile_block_standard_personas_have_first_person( + key: str, +) -> None: block = _build_speech_profile_block(PERSONAS_BY_KEY[key]) assert "一人称" in block - assert "叙述モード" not in block - - -def test_build_system_prompt_includes_speech_profile_block() -> None: - prompt = build_system_prompt( - persona=PERSONAS_BY_KEY["setsu"], - role=Role.VILLAGER, - phase=Phase.DAY_DISCUSSION, - day_number=1, - task_text="t", - ) - assert "## 話法" in prompt - assert "『私』" in prompt - # Common rule from the markdown template (static, once). - assert "1 発話に入れてよい特徴語は多くても 1 個" in prompt - - -def test_build_system_prompt_speech_profile_respects_persona() -> None: - """The `## 話法` section differs per persona — setsu's speech block says - 『私』, yuriko's says『この身』. Neither block leaks the other's first - person.""" - setsu_prompt = build_system_prompt( - persona=PERSONAS_BY_KEY["setsu"], - role=Role.VILLAGER, - phase=Phase.DAY_DISCUSSION, - day_number=1, - task_text="t", - ) - yuriko_prompt = build_system_prompt( - persona=PERSONAS_BY_KEY["yuriko"], - role=Role.VILLAGER, - phase=Phase.DAY_DISCUSSION, - day_number=1, - task_text="t", - ) - assert "『私』" in setsu_prompt - assert "『この身』" not in setsu_prompt - assert "『この身』" in yuriko_prompt - # Everything after `## 話法` (speech block + later sections) must not - # contain『私』 for yuriko — guards against drift in rendering. - assert "『私』" not in yuriko_prompt.split("## 話法")[1] # ========================================================================= @@ -2067,42 +564,30 @@ def _ctx_speech(text: str, *, actor_seat: int, day: int = 1) -> dict[str, object return {"kind": "PLAYER_SPEECH", "text": text, "actor_seat": actor_seat, "day": day} -def test_build_user_context_no_co_parser_sections() -> None: - """The pre-digest CO parser sections (CO list / 盤面分類 / 役職推定メモ) must - not appear in user_context. Even seeded with declarative-looking PLAYER_SPEECH - text, the build no longer attaches machine-summarized CO output — the LLM - is expected to read the raw 公開ログ要約 and judge in context.""" - seats = [_ctx_seat(1, "Alice"), _ctx_seat(2, "Bob"), _ctx_seat(3, "Carol")] - players = [_ctx_player(1), _ctx_player(2), _ctx_player(3)] +def test_build_user_context_no_co_parser_summary_section() -> None: + """The pre-digest CO parser sections (CO list / 盤面分類 / 役職推定メモ) + must not appear in user_context. The LLM is expected to read raw + PLAYER_SPEECH lines and apply CO-detection rules itself.""" + seats = [_ctx_seat(1, "Alice"), _ctx_seat(2, "Bob")] + players = [_ctx_player(1), _ctx_player(2)] out = build_user_context( game=_ctx_game(), me=players[0], my_seat=seats[0], seats=seats, players=players, - public_logs=[ - _ctx_speech("占い師CO", actor_seat=1), - _ctx_speech("霊媒師CO", actor_seat=2), - ], + public_logs=[_ctx_speech("占い師CO", actor_seat=1)], private_logs=[], ) assert "## CO・判定の機械整理" not in out assert "## 盤面分類" not in out - assert "## 役職推定メモ (公開情報ベース)" not in out - # The parser-style "占い師CO: 席X" / "霊媒師CO: 席X" / "公開CO履歴ベース" - # rendering lines are the canonical anti-pattern; not produced anywhere now. assert "占い師CO: 席" not in out - assert "霊媒師CO: 席" not in out - assert "公開CO履歴ベース" not in out -def test_build_user_context_topical_co_phrase_does_not_create_self_co() -> None: - """A speaker who merely raises `占いCO` as a topic / hypothetical must not - be tagged as a self-CO. The raw text still passes through 公開ログ要約 so - the LLM can read it in context.""" +def test_build_user_context_passes_player_speech_through_raw() -> None: seats = [_ctx_seat(1, "Alice"), _ctx_seat(2, "Bob")] players = [_ctx_player(1), _ctx_player(2)] - speech = "占いCOが出たらどう見るか考えたい" + speech = "セツの判定が気になる、人狼っぽい雰囲気がある" out = build_user_context( game=_ctx_game(), me=players[0], @@ -2112,80 +597,12 @@ def test_build_user_context_topical_co_phrase_does_not_create_self_co() -> None: public_logs=[_ctx_speech(speech, actor_seat=2)], private_logs=[], ) - assert "占い師CO: 席2" not in out - # Raw speech survives in the public log dump. - assert speech in out - - -def test_build_user_context_topical_medium_phrase_does_not_create_self_co() -> None: - """Same invariant for medium-CO topical mentions — `霊媒COについて…` is a - discussion prompt, not a self-CO.""" - seats = [_ctx_seat(1, "Alice"), _ctx_seat(2, "Bob"), _ctx_seat(3, "Carol")] - players = [_ctx_player(1), _ctx_player(2), _ctx_player(3)] - speech = "霊媒COについてどう見る?" - out = build_user_context( - game=_ctx_game(), - me=players[0], - my_seat=seats[0], - seats=seats, - players=players, - public_logs=[_ctx_speech(speech, actor_seat=3)], - private_logs=[], - ) - assert "霊媒師CO: 席3" not in out assert speech in out -def test_build_user_context_passes_raw_player_speech_through() -> None: - """The raw PLAYER_SPEECH text reaches user_context with the seat-attributed - `[PLAYER_SPEECH] 席N {name}: …` formatting, so the LLM can spot CO - declarations on its own.""" - seats = [_ctx_seat(1, "Alice"), _ctx_seat(2, "Bob")] - players = [_ctx_player(1), _ctx_player(2)] - out = build_user_context( - game=_ctx_game(), - me=players[0], - my_seat=seats[0], - seats=seats, - players=players, - public_logs=[_ctx_speech("占い師COします。 席2 Bob 白", actor_seat=1)], - private_logs=[], - ) - assert "[PLAYER_SPEECH] 席1 Alice: 占い師COします。 席2 Bob 白" in out - - -def test_build_user_context_rope_count_with_alive_dead_breakdown() -> None: - seats = [_ctx_seat(i, f"S{i}") for i in range(1, 10)] - # 7 alive, 2 dead → 3 ropes. - players = [_ctx_player(i, alive=True) for i in range(1, 8)] + [ - _ctx_player(i, alive=False) for i in range(8, 10) - ] - out = build_user_context( - game=_ctx_game(), - me=players[0], - my_seat=seats[0], - seats=seats, - players=players, - public_logs=[], - private_logs=[], - ) - assert "## 縄数・PP/RPPリスク" in out - assert "生存 7 人 / 死亡 2 人" in out - assert "3 縄" in out - - -@pytest.mark.parametrize( - "alive, dead, ropes", - [(9, 0, 4), (7, 2, 3), (5, 4, 2), (3, 6, 1), (2, 7, 0)], -) -def test_build_user_context_rope_summary_parametrized(alive: int, dead: int, ropes: int) -> None: - """Rope formula coverage migrated from the deleted test_llm_context_analysis.py. - Asserts against the user_context block directly so the helper is exercised - through its only public surface.""" - seats = [_ctx_seat(i, f"S{i}") for i in range(1, alive + dead + 1)] - players = [_ctx_player(i, alive=True) for i in range(1, alive + 1)] + [ - _ctx_player(i, alive=False) for i in range(alive + 1, alive + dead + 1) - ] +def test_build_user_context_includes_rope_block() -> None: + seats = [_ctx_seat(i, f"P{i}") for i in range(1, 10)] + players = [_ctx_player(i) for i in range(1, 10)] out = build_user_context( game=_ctx_game(), me=players[0], @@ -2196,17 +613,21 @@ def test_build_user_context_rope_summary_parametrized(alive: int, dead: int, rop private_logs=[], ) assert "## 縄数・PP/RPPリスク" in out - assert f"生存 {alive} 人 / 死亡 {dead} 人" in out - assert f"{ropes} 縄" in out + assert "生存 9 人" in out + # 9-alive → 4 ropes; village starts at 4 ropes. + assert "4 縄" in out assert "9人村開始時は4縄" in out -def test_build_user_context_rope_endgame_note_at_3_alive() -> None: - seats = [_ctx_seat(i, f"S{i}") for i in range(1, 10)] - players = [_ctx_player(i, alive=True) for i in range(1, 4)] + [ - _ctx_player(i, alive=False) for i in range(4, 10) +def test_build_user_context_wolf_partner_block_only_for_wolves() -> None: + """A wolf seat sees its partner; a non-wolf seat must not.""" + seats = [_ctx_seat(i, f"P{i}") for i in range(1, 4)] + players = [ + _ctx_player(1, role=Role.WEREWOLF), + _ctx_player(2, role=Role.WEREWOLF), + _ctx_player(3, role=Role.SEER), ] - out = build_user_context( + wolf_view = build_user_context( game=_ctx_game(), me=players[0], my_seat=seats[0], @@ -2215,29 +636,23 @@ def test_build_user_context_rope_endgame_note_at_3_alive() -> None: public_logs=[], private_logs=[], ) - assert "最終局面" in out - - -def test_build_user_context_rope_pp_warning_at_5_alive() -> None: - seats = [_ctx_seat(i, f"S{i}") for i in range(1, 10)] - players = [_ctx_player(i, alive=True) for i in range(1, 6)] + [ - _ctx_player(i, alive=False) for i in range(6, 10) - ] - out = build_user_context( + seer_view = build_user_context( game=_ctx_game(), - me=players[0], - my_seat=seats[0], + me=players[2], + my_seat=seats[2], seats=seats, players=players, public_logs=[], private_logs=[], ) - assert "PP/RPP" in out + assert "## 仲間の人狼" in wolf_view + assert "P2" in wolf_view # partner shown + assert "## 仲間の人狼" not in seer_view -def test_build_user_context_rope_normal_note_at_9_alive() -> None: - seats = [_ctx_seat(i, f"S{i}") for i in range(1, 10)] - players = [_ctx_player(i, alive=True) for i in range(1, 10)] +def test_build_user_context_renders_deduced_facts_section_when_provided() -> None: + seats = [_ctx_seat(1, "Alice")] + players = [_ctx_player(1)] out = build_user_context( game=_ctx_game(), me=players[0], @@ -2246,14 +661,13 @@ def test_build_user_context_rope_normal_note_at_9_alive() -> None: players=players, public_logs=[], private_logs=[], + deduced_facts_block="HARD: 席3 は人狼ではない (襲撃死)", ) - assert "通常進行" in out + assert "## 公開情報からの確定/推測事実" in out + assert "HARD: 席3 は人狼ではない" in out -def test_build_user_context_block_order_after_co_parser_removal() -> None: - """After dropping the CO parser, the surviving block order is: - rope block → 私的メモ → 公開ログ要約 → 自分の直近の発言. The deleted - parser headings must be absent.""" +def test_build_user_context_omits_deduced_facts_section_when_none() -> None: seats = [_ctx_seat(1, "Alice")] players = [_ctx_player(1)] out = build_user_context( @@ -2265,516 +679,119 @@ def test_build_user_context_block_order_after_co_parser_removal() -> None: public_logs=[], private_logs=[], ) - rope_idx = out.find("## 縄数・PP/RPPリスク") - priv_idx = out.find("## あなたの私的メモ") - pub_idx = out.find("## 公開ログ要約") - own_idx = out.find("## 自分の直近の発言") - assert -1 < rope_idx < priv_idx < pub_idx < own_idx - assert "## CO・判定の機械整理" not in out - assert "## 盤面分類" not in out - assert "## 役職推定メモ (公開情報ベース)" not in out + assert "## 公開情報からの確定/推測事実" not in out -def test_build_user_context_wolf_partner_block_only_for_wolves() -> None: - """Regression: the wolf-partner block remains gated to werewolves after the - CO-parser analysis section was removed from between it and the rest.""" - seats = [_ctx_seat(1, "Alice"), _ctx_seat(2, "Bob")] - players = [ - _ctx_player(1, role=Role.WEREWOLF), - _ctx_player(2, role=Role.WEREWOLF), - ] - villager_seat = _ctx_seat(3, "Carol") - villager = _ctx_player(3, role=Role.VILLAGER) - seats3 = [*seats, villager_seat] - players3 = [*players, villager] - - wolf_view = build_user_context( - game=_ctx_game(), - me=players[0], - my_seat=seats[0], - seats=seats3, - players=players3, - public_logs=[], - private_logs=[], - ) - villager_view = build_user_context( - game=_ctx_game(), - me=villager, - my_seat=villager_seat, - seats=seats3, - players=players3, - public_logs=[], - private_logs=[], - ) - assert "## 仲間の人狼" in wolf_view - assert "## 仲間の人狼" not in villager_view +# ========================================================================= +# task templates — partner-block scoping, day-2 hint gating +# ========================================================================= -# ----------------------------- werewolf vote-discipline strategy & task_vote +def test_task_vote_baseline_unchanged_for_non_wolf() -> None: + base = task_vote(["席1 Alice", "席2 Bob"], runoff=False) + assert "席1 Alice" in base + assert "仲間の人狼" not in base + assert "決選投票" not in base -def test_wolf_strategy_block_contains_vote_discipline_vocabulary() -> None: - """The werewolf strategy block must teach 投票 discipline: 票筋 reads, - 身内票/ライン切り on the partner when unsavable, 票逸らし risk, and the - 決選投票 PP/RPP comparison. Without these, the LLM wolf falls back to - transparent partner-protection that exposes the wolf line.""" - block = _build_strategy_block(Role.WEREWOLF) - for token in ( - "身内票", - "ライン切り", - "票筋", - "処刑濃厚", - "票を逸ら", - "決選投票", - "PP/RPP", - "透け", - ): - assert token in block, f"wolf strategy missing vote-discipline token {token!r}" - assert "相方を救" in block - assert "相方を切" in block +def test_task_vote_runoff_note_appears_when_runoff_true() -> None: + out = task_vote(["席1 Alice", "席2 Bob"], runoff=True) + assert "決選投票" in out @pytest.mark.parametrize( "role", - [Role.MADMAN, Role.SEER, Role.MEDIUM, Role.KNIGHT, Role.VILLAGER], + [Role.SEER, Role.MEDIUM, Role.KNIGHT, Role.VILLAGER, Role.MADMAN], ) -def test_non_wolf_strategy_excludes_partner_action_vocabulary(role: Role) -> None: - """Partner-name and partner-action vocabulary must stay wolf-only. Bare - `身内票` / `ライン切り` are shared in `_build_game_rules_block`, but the - wolf-specific phrasings (`仲間の人狼`, `相方を救う`, `相方を切る`) must - not appear in any other role's strategy block.""" - block = _build_strategy_block(role) - assert "仲間の人狼" not in block, f"'仲間の人狼' leaked into {role.name}" - assert "相方を救" not in block, f"'相方を救' leaked into {role.name}" - assert "相方を切" not in block, f"'相方を切' leaked into {role.name}" - - -def test_task_vote_baseline_unchanged_without_role() -> None: - """The default `task_vote(candidates, runoff)` call must keep the existing - base text and never inject partner-action vocabulary, so all pre-existing - call sites and non-wolf voters get the same instructions as before. Bare - `相方` (actor mode, partner-known) and `仲間の人狼` must not appear; the - inference noun `相方候補` is allowed in the shared pair-inference bullet - of the common path.""" - text = task_vote(["席1 A", "席2 B"], runoff=False) - assert "投票先として合法な候補は: 席1 A、席2 B" in text - assert "仲間の人狼" not in text - assert not re.search(r"相方(?!候補)", text) - - -def test_task_vote_wolf_path_emits_partner_checklist() -> None: - """A wolf voter with at least one alive partner gets the partner-aware - vote checklist appended after the base candidate text.""" - text = task_vote( - ["席1 A", "席2 B"], +def test_task_vote_non_wolf_role_never_emits_partner_block(role: Role) -> None: + """Even when a partner_tokens kwarg is forced for a non-wolf role + (e.g. madman), the wolf-block must not render — partner identity + must never reach non-wolf prompts.""" + out = task_vote( + ["席1 Alice", "席2 Bob"], runoff=False, - role=Role.WEREWOLF, - wolf_partner_tokens=["席2 B"], + role=role, + wolf_partner_tokens=("席3 Carol",), ) - for token in ( - "仲間の人狼", - "席2 B", - "相方", - "身内票", - "ライン切り", - "票筋", - "処刑濃厚", - "透け", - "合法候補", - ): - assert token in text, f"wolf vote task missing token {token!r}" + assert "仲間の人狼" not in out + assert "席3 Carol" not in out -def test_task_vote_wolf_runoff_adds_runoff_checklist() -> None: - """The wolf runoff vote task must add the 決選投票-specific tradeoff - comparison (救済成功見込み・透けリスク・PP/RPP) on top of the base - wolf checklist.""" - text = task_vote( - ["席1 A", "席2 B"], - runoff=True, +def test_task_vote_wolf_emits_partner_checklist() -> None: + out = task_vote( + ["席1 Alice", "席2 Bob"], + runoff=False, role=Role.WEREWOLF, - wolf_partner_tokens=["席2 B"], + wolf_partner_tokens=("席3 Carol",), ) - for token in ("決選投票", "透け", "PP/RPP"): - assert token in text, f"wolf runoff vote task missing token {token!r}" + assert "仲間の人狼" in out + assert "席3 Carol" in out + assert "身内票" in out or "ライン切り" in out -@pytest.mark.parametrize( - "role", - [Role.VILLAGER, Role.MADMAN, Role.SEER, Role.MEDIUM, Role.KNIGHT], -) -def test_task_vote_non_wolf_role_returns_base_text(role: Role) -> None: - """Passing a non-wolf role to `task_vote` returns the base text — the - wolf-specific block never reaches non-wolf voters even if a future caller - forgets to skip the role argument. Bare `相方` (actor mode) and `仲間の人狼` - must be absent; the inference noun `相方候補` is allowed in the common path.""" - text = task_vote(["席1 A", "席2 B"], runoff=False, role=role) - assert "仲間の人狼" not in text - assert not re.search(r"相方(?!候補)", text) - - -def test_task_vote_madman_with_partner_token_does_not_leak_partner() -> None: - """Even if a caller naively passes a partner token for a madman, the - role gate inside `task_vote` must drop it. The madman never sees real - wolf positions.""" - text = task_vote( - ["席1 A"], - runoff=False, - role=Role.MADMAN, - wolf_partner_tokens=["席3 X"], +def test_task_vote_wolf_runoff_adds_runoff_block() -> None: + out = task_vote( + ["席1 Alice", "席2 Bob"], + runoff=True, + role=Role.WEREWOLF, + wolf_partner_tokens=("席3 Carol",), ) - assert "仲間の人狼" not in text - assert "席3 X" not in text + assert "PP/RPP" in out def test_task_vote_lone_wolf_returns_base_text() -> None: - """A wolf voter with no alive partner (lone-wolf endgame) gets the base - text — the partner-aware checklist requires at least one partner token. - Bare `相方` (actor mode) and `仲間の人狼` must be absent; `相方候補` - (inference) is allowed in the shared pair-inference bullet.""" - text = task_vote( - ["席1 A"], + """Empty partner_tokens → wolf gets the same prompt as villagers.""" + out = task_vote( + ["席1 Alice", "席2 Bob"], runoff=False, role=Role.WEREWOLF, wolf_partner_tokens=(), ) - assert "仲間の人狼" not in text - assert not re.search(r"相方(?!候補)", text) - - -# ----------------------------------- 2 人狼ペア推理 (wolf-pair inference) -# Pair-inference asks every LLM seat to think paired: "if A is wolf, who is -# the natural B?" The vocabulary `相方候補` (inference noun) is shared across -# all roles via the rules block; `相方` (actor mode, partner-known) stays -# strictly in the werewolf strategy and the wolf-only vote/night blocks. - -_PAIR_INFERENCE_RULES_TOKENS = ( - "2 人狼", - "相方候補", - "公開ログからの仮説", - "単体黒要素", - "票筋", - "噛み筋", - "ライン切り", - "身内票", -) - - -def test_game_rules_block_introduces_two_wolf_pair_inference() -> None: - """The shared rules block must teach paired-wolf inference: build an A-B - hypothesis from public logs (vote tracks, attack patterns, line-cuts, - inner-circle votes) and weigh single-wolf suspicion against partner fit.""" - block = _build_game_rules_block() - for token in _PAIR_INFERENCE_RULES_TOKENS: - assert token in block, f"rules block missing pair-inference token {token!r}" - # Soft-cap on speech length must stay; the new bullets must not encourage - # exhaustive pair-listing speeches. - assert "1〜2 点" in block - - -def test_villager_strategy_includes_pair_inference_vocabulary() -> None: - """The villager (public-information-only seat) must reason paired: - candidate + 相方候補 from public logs, plus observation patterns - (身内票, 票逸らし, ライン切り) as wolf-detection signals. Bare `相方` - must not appear; the villager-CO ban must remain intact.""" - block = _build_strategy_block(Role.VILLAGER) - assert "相方候補" in block - assert "2 人狼仮説" in block - assert "身内票" in block - # Either of the observation-pattern phrases is sufficient. - assert "票逸ら" in block or "ライン切り" in block - # Bare `相方` (actor mode) must not appear. - assert not re.search(r"相方(?!候補)", block) - # Existing villager-CO ban remains. - assert "村人 CO 禁止" in block or "村人CO" in block - - -def test_seer_strategy_includes_pair_inference_for_target_selection() -> None: - """Seer divine-target selection must factor in pair-inference: white-or- - black both organize 相方候補 hypotheses, counter-CO comparison drives the - strongest pair to break. Bare `相方` (actor mode) must not appear.""" - block = _build_strategy_block(Role.SEER) - assert "相方候補" in block - assert "2 人狼仮説" in block - assert "対抗" in block - assert not re.search(r"相方(?!候補)", block) - - -def test_medium_strategy_includes_pair_inference_after_execution() -> None: - """Medium pair-inference: medium-black narrows the executed wolf's 相方候補 - via 票筋 / 庇い / ライン切り / 身内票; medium-white examines who pushed the - execution. Bare `相方` (actor mode) must not appear.""" - block = _build_strategy_block(Role.MEDIUM) - assert "相方候補" in block - assert "票筋" in block - assert "身内票" in block - assert "2 人狼仮説" in block - assert not re.search(r"相方(?!候補)", block) - - -def test_knight_strategy_includes_pair_inference_for_guard_selection() -> None: - """Knight pair-inference: guard-target selection considers which 2-wolf - hypothesis benefits from the kami; peaceful mornings + actual attack - targets feed back into the daytime pair-inference. Bare `相方` (actor - mode) must not appear.""" - block = _build_strategy_block(Role.KNIGHT) - assert "相方候補" in block - assert "2 人狼仮説" in block - assert "噛み筋" in block - assert not re.search(r"相方(?!候補)", block) - - -def test_werewolf_strategy_includes_two_wolf_set_framing() -> None: - """The werewolf strategy must explicitly tell the wolf that it and its - partner are read as a 2-wolf set by the village — every save/cut/cover/ - black-out/attack must be defensible as the same A-B line on later days. - `視点漏れ` is the absolute prohibition (don't leak partner-known info).""" - block = _build_strategy_block(Role.WEREWOLF) - assert "2 人狼セット" in block - assert "視点漏れ" in block - assert "ライン" in block - # Bare `相方` is REQUIRED in the wolf strategy (actor mode, partner-known). - assert "相方" in block - - -def test_madman_strategy_includes_pair_inference_with_misfire() -> None: - """The madman, lacking real-wolf-position knowledge, must reason 2-wolf - hypotheses from public logs only — with explicit misfire framing. Bare - `相方` (actor mode) and `襲撃先を揃える` (wolf-only night coordination) - must remain absent.""" - block = _build_strategy_block(Role.MADMAN) - assert "2 人狼仮説" in block - assert "公開ログからの推定" in block - assert "誤爆" in block - assert "相方候補" in block - # Wolf-coordination vocab forbidden. - assert not re.search(r"相方(?!候補)", block) - assert "襲撃先を揃える" not in block - # Existing prohibition phrase still present. - assert "人狼位置を知っている前提で話してはならない" in block - - -def test_task_daytime_speech_default_includes_pair_inference_softly() -> None: - """The daytime speech task's base body teaches paired suspicion as a - soft hint — not every speech must list pairs. The softness modifiers - (`必要に応じて` / `1〜2 点`) guard against speech inflation. The base - contract (80–300 chars, intent=speak/skip) must be preserved.""" - text = task_daytime_speech(2) - assert "相方候補" in text - assert "2 人狼セット" in text - # Softness anchors so every speech doesn't balloon into pair listing. - assert "必要に応じて" in text - assert "1〜2 点" in text - # Existing base contract preserved. - assert "80〜300 字" in text - assert "intent=speak" in text - assert "intent=skip" in text - - -def test_task_vote_base_includes_pair_inference_for_all_roles() -> None: - """The common-path `task_vote` body (no role argument) must teach paired - suspicion as part of the single-vote-decision guidance, since every role - sees this body. Bare `相方` (actor mode) must not appear; only `相方候補`.""" - text = task_vote(["席1 A", "席2 B"], runoff=False) - assert "相方候補" in text - assert "2 人狼セット" in text - # Existing baseline candidate list still present. - assert "投票先として合法な候補は: 席1 A、席2 B" in text - # Wolf-only block must not be appended. - assert "仲間の人狼" not in text - assert not re.search(r"相方(?!候補)", text) - - -def test_task_vote_wolf_block_extends_with_two_wolf_set_framing() -> None: - """The wolf-only vote block must extend the existing partner checklist - with a `2 人狼セット` line so the wolf checks each save/cut/deflect against - next-day reading. Existing wolf-only tokens stay (`相方を救` / `身内票` / - `ライン切り`).""" - text = task_vote( - ["席1 A", "席2 B"], - runoff=False, - role=Role.WEREWOLF, - wolf_partner_tokens=["席2 B"], - ) - assert "2 人狼セット" in text - # Existing wolf-only checklist tokens preserved. - assert "相方を救" in text - assert "身内票" in text - assert "ライン切り" in text - - -def test_task_vote_madman_inference_present_but_no_actor_partner() -> None: - """The madman's vote text contains the common-path pair-inference clause - (`相方候補`) but never the wolf-only actor verbs (`相方を救` / `相方を切`) - or the partner-token block (`仲間の人狼`). Bare `相方` (actor mode) must - not appear even when a caller naively passes a partner token.""" - text = task_vote( - ["席1 A", "席2 B"], - runoff=False, - role=Role.MADMAN, - wolf_partner_tokens=["席3 X"], - ) - # Common-path pair-inference reaches the madman. - assert "相方候補" in text - # Wolf-only actor verbs and partner-token block must not. - assert "仲間の人狼" not in text - assert "相方を救" not in text - assert "相方を切" not in text - # Bare `相方` (actor mode) must be absent in the madman vote text. - assert not re.search(r"相方(?!候補)", text) - - -# ========================================================================= -# JudgmentProfile rendering and integration -# ========================================================================= - - -def _persona_with_judgment(profile: JudgmentProfile) -> Persona: - return Persona( - key="_judgment_test_", - display_name="J_TEST", - style_guide="x", - speech_profile=_TEST_SPEECH_PROFILE, - judgment_profile=profile, - ) - - -def test_judgment_profile_block_renders_extreme_logical_persona() -> None: - """Max trust_hard + low contrarian + low aggression → strong-logic / passive - bands appear in the rendered block.""" - profile = JudgmentProfile( - trust_hard_facts=1.0, - trust_medium_facts=0.85, - contrarian_bias=0.1, - aggression=0.2, - bandwagon_tendency=0.15, - ) - block = _build_judgment_profile_block(_persona_with_judgment(profile)) - assert "絶対視" in block - assert "基本受け入れる" in block - assert "多数派にあえて逆らわない" in block - assert "慎重で疑い先を出すのが遅い" in block - assert "単独行動を好み流れに乗らない" in block - - -def test_judgment_profile_block_renders_chaos_persona() -> None: - """Low trust_hard + low trust_medium + high contrarian + high aggression → - distrustful / disruptive bands.""" - profile = JudgmentProfile( - trust_hard_facts=0.4, - trust_medium_facts=0.3, - contrarian_bias=0.85, - aggression=0.9, - bandwagon_tendency=0.3, - ) - block = _build_judgment_profile_block(_persona_with_judgment(profile)) - assert "やや軽視" in block - assert "懐疑的に扱う" in block - assert "あえて逆張り" in block - assert "即座に処刑候補を名指しする" in block - assert "独自路線を好む" in block - - -def test_judgment_profile_block_default_neutral_persona_renders_mid_bands() -> None: - """A persona constructed without an explicit judgment_profile gets the - neutral defaults; the block renders all axes around 'mid' bands.""" - persona = Persona( - key="_default_judgment_", - display_name="D", - style_guide="x", - speech_profile=_TEST_SPEECH_PROFILE, - ) - block = _build_judgment_profile_block(persona) - # Defaults: trust_hard=1.0, trust_medium=0.7, contrarian=0.0, aggression=0.5, bandwagon=0.5 - assert "絶対視" in block # trust_hard 1.0 → high - assert "やや信用する" in block # trust_medium 0.7 → mid_high - assert "多数派にあえて逆らわない" in block # contrarian 0.0 → low - assert "標準的に疑い先を出す" in block # aggression 0.5 → mid - assert "状況次第" in block # bandwagon 0.5 → mid + assert "仲間の人狼" not in out -def test_build_system_prompt_includes_judgment_profile_block() -> None: - """The integration path renders the judgment profile within the system - prompt under the `## 判断傾向` heading.""" - persona = _persona_with_judgment( - JudgmentProfile( - trust_hard_facts=1.0, - trust_medium_facts=0.85, - contrarian_bias=0.85, - aggression=0.85, - bandwagon_tendency=0.15, - ) - ) - prompt = build_system_prompt( - persona=persona, - role=Role.VILLAGER, - phase=Phase.DAY_DISCUSSION, - day_number=1, - task_text="t", - ) - assert "## 判断傾向" in prompt - # Sentinel band labels reach the final prompt. - assert "絶対視" in prompt - assert "あえて逆張り" in prompt - assert "即座に処刑候補を名指しする" in prompt - - -def test_npc_personas_all_carry_explicit_judgment_profile() -> None: - """Every shipped NPC persona must declare an explicit judgment_profile — - not the neutral default — so the structured judgment axes actually - differentiate seat behaviour. A new persona without explicit values - fails this check.""" - from wolfbot.llm.persona_base import _DEFAULT_JUDGMENT_PROFILE - from wolfbot.npc.personas import NPC_PERSONAS - - for persona in NPC_PERSONAS: - assert persona.judgment_profile is not _DEFAULT_JUDGMENT_PROFILE, ( - f"persona {persona.key!r} is missing an explicit judgment_profile" - ) +def test_task_daytime_speech_day2_round1_includes_results_hint() -> None: + out = task_daytime_speech(day_number=2, discussion_round=1) + assert "前夜の能力結果" in out -# ========================================================================= -# build_user_context — deduced facts injection -# ========================================================================= +def test_task_daytime_speech_day1_round1_wolf_madman_branch() -> None: + out = task_daytime_speech(day_number=1, discussion_round=1, role=Role.WEREWOLF) + assert "占い師騙り・霊媒師騙り・潜伏" in out -def test_build_user_context_includes_deduced_facts_block_when_supplied() -> None: - """When the caller passes `deduced_facts_block`, it appears under the - `## 公開情報からの確定/推測事実 (Master 整理)` header so LLMs can - distinguish Master's structured analysis from raw public log.""" - seats = [_ctx_seat(1, "Alice"), _ctx_seat(2, "Bob")] - players = [_ctx_player(1), _ctx_player(2)] - out = build_user_context( - game=_ctx_game(), - me=players[0], - my_seat=seats[0], - seats=seats, - players=players, - public_logs=[], - private_logs=[], - deduced_facts_block=( - "### HARD (公開情報から論理的に確定)\n" - "- 占い師 の名乗り履歴: 生存=席3 P3 / 死亡済み=(なし)" - ), - ) - assert "公開情報からの確定/推測事実 (Master 整理)" in out - assert "占い師 の名乗り履歴" in out - assert "席3 P3" in out +def test_task_daytime_speech_day1_round1_villager_no_wolf_block() -> None: + out = task_daytime_speech(day_number=1, discussion_round=1, role=Role.VILLAGER) + assert "占い師騙り・霊媒師騙り・潜伏" not in out -def test_build_user_context_omits_deduced_facts_section_when_none() -> None: - """`deduced_facts_block=None` → the entire section is suppressed; no - confusing empty heading slips into the prompt.""" - seats = [_ctx_seat(1, "Alice")] - players = [_ctx_player(1)] - out = build_user_context( - game=_ctx_game(), - me=players[0], - my_seat=seats[0], - seats=seats, - players=players, - public_logs=[], - private_logs=[], - deduced_facts_block=None, - ) - assert "公開情報からの確定/推測事実" not in out +@pytest.mark.parametrize( + "kind,label", + [ + (SubmissionType.WOLF_ATTACK, "襲撃"), + (SubmissionType.SEER_DIVINE, "占い"), + (SubmissionType.KNIGHT_GUARD, "護衛"), + ], +) +def test_task_night_action_uses_kind_specific_label(kind: SubmissionType, label: str) -> None: + out = task_night_action(kind, ["席1 Alice", "席2 Bob"]) + assert label in out + assert "席1 Alice" in out + + +def test_task_night_action_wolf_only_advice_is_kind_scoped() -> None: + """Wolf-attack value scoring lives only in WOLF_ATTACK; knight tradeoffs + only in KNIGHT_GUARD; seer value only in SEER_DIVINE.""" + wolf = task_night_action(SubmissionType.WOLF_ATTACK, ["席1 X"]) + knight = task_night_action(SubmissionType.KNIGHT_GUARD, ["席1 X"]) + seer = task_night_action(SubmissionType.SEER_DIVINE, ["席1 X"]) + assert "騎士探し" in wolf and "騎士探し" not in knight and "騎士探し" not in seer + assert "鉄板護衛" in knight and "鉄板護衛" not in wolf + assert "占い価値" in seer and "占い価値" not in wolf + + +def test_task_wolf_chat_lists_partners_and_candidates() -> None: + out = task_wolf_chat(["席3 Carol"], ["席1 Alice", "席2 Bob"]) + assert "席3 Carol" in out + assert "席1 Alice" in out + assert "襲撃" in out diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index 82f5a98..f32775e 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -139,7 +139,7 @@ async def test_submit_llm_votes_reactive_voice_routes_to_dispatcher( ): pass await repo._db.commit() # type: ignore[attr-defined] - game = (await repo.load_game(game_rounds.id)) + game = await repo.load_game(game_rounds.id) assert game is not None assert game.discussion_mode == "reactive_voice" @@ -147,7 +147,11 @@ async def test_submit_llm_votes_reactive_voice_routes_to_dispatcher( class _StubDispatcher: async def dispatch_votes( - self, *, game_id: str, day: int, round_: int, + self, + *, + game_id: str, + day: int, + round_: int, voters: list[Player], # type: ignore[name-defined] seats: list[Seat], candidate_seats: list[int], @@ -197,7 +201,7 @@ async def test_submit_llm_votes_reactive_voice_falls_back_when_no_dispatcher( ): pass await repo._db.commit() # type: ignore[attr-defined] - game = (await repo.load_game(game_rounds.id)) + game = await repo.load_game(game_rounds.id) assert game is not None gs = _FakeGameService() @@ -1257,18 +1261,6 @@ async def _capture_ask_system_prompt( return system_prompt -async def test_ask_system_prompt_contains_game_rules_for_any_role(repo: SqliteRepo) -> None: - """Every LLM seat, regardless of role, must receive the fixed 9-player - rules block in its system prompt (role distribution, win conditions, - candidate-token rule).""" - system_prompt = await _capture_ask_system_prompt(repo, Role.VILLAGER) - assert "人狼2" in system_prompt - assert "村人3" in system_prompt - assert "生存人狼数が 0" in system_prompt - assert "生存人狼数が生存非人狼人数以上" in system_prompt - assert "候補トークン" in system_prompt - - async def test_ask_system_prompt_contains_3_1_roller_rules_for_any_role( repo: SqliteRepo, ) -> None: @@ -1281,17 +1273,6 @@ async def test_ask_system_prompt_contains_3_1_roller_rules_for_any_role( assert "真狼狼" in system_prompt -async def test_ask_system_prompt_contains_2_2_medium_roller_rules_for_any_role( - repo: SqliteRepo, -) -> None: - """2-2 / medium-roller guidance must reach every seat via the shared rules - block.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MEDIUM) - assert "2-2" in system_prompt - assert "霊媒ローラー" in system_prompt - assert "原則として完走" in system_prompt - - async def test_ask_system_prompt_contains_2_1_and_1_2_formations_for_any_role( repo: SqliteRepo, ) -> None: @@ -1310,102 +1291,6 @@ async def test_ask_system_prompt_contains_2_1_and_1_2_formations_for_any_role( assert "1-2" in system_prompt, f"{role.name} missed 1-2" -async def test_ask_system_prompt_contains_three_seer_co_elimination_for_any_role( - repo: SqliteRepo, -) -> None: - """3占いCO・2非狼確定で残る 1 人を確定黒級とする消去法は共通ルール経由で - 全 role の system prompt に届く。非狼確定の厳格さと前提崩壊時の解除も同様。""" - for role in ( - Role.VILLAGER, - Role.SEER, - Role.MEDIUM, - Role.KNIGHT, - Role.WEREWOLF, - Role.MADMAN, - ): - system_prompt = await _capture_ask_system_prompt(repo, role) - assert "残る 1 人の占い師 CO を固定配役上の消去法" in system_prompt, ( - f"{role.name} missed the elimination rule" - ) - assert "『白判定』と『非狼確定』を混同しない" in system_prompt, ( - f"{role.name} missed the white-vs-confirmation caveat" - ) - assert "前提が崩れた場合は確定黒扱いを解除" in system_prompt, ( - f"{role.name} missed the breakdown / re-organize clause" - ) - - -async def test_ask_system_prompt_villager_seat_includes_three_seer_co_elimination_framing( - repo: SqliteRepo, -) -> None: - """村人席は共通ルールに加え、村人視点の framing - (投票・発言・進行提案で残る占い師 CO 位置を狼として扱う) も system prompt - に届く。""" - system_prompt = await _capture_ask_system_prompt(repo, Role.VILLAGER) - assert "残る占い師 CO 位置を投票・発言・進行提案" in system_prompt - assert "前提が崩れた瞬間" in system_prompt - - -async def test_ask_system_prompt_contains_co_overflow_rule_for_any_role( - repo: SqliteRepo, -) -> None: - """3 役職横断 CO 数・対抗 CO 超過分推理は共通ルール経由で全 role の - system prompt に届く。`CO 数 - 1` の式、超過分合計 3 で非 CO が確白級、 - 超過分合計 0〜2 で非 CO 断定しない、超過分合計 4 以上で再整理という - 境界条件のすべてを LLM が読める状態にする。""" - for role in ( - Role.VILLAGER, - Role.SEER, - Role.MEDIUM, - Role.KNIGHT, - Role.WEREWOLF, - Role.MADMAN, - ): - system_prompt = await _capture_ask_system_prompt(repo, role) - assert "対抗 CO 超過分" in system_prompt, f"{role.name} missed 対抗 CO 超過分" - assert "CO 数 - 1" in system_prompt, f"{role.name} missed CO 数 - 1" - assert "超過分合計が 3 に達した場合" in system_prompt, ( - f"{role.name} missed sum-3 trigger" - ) - assert "村陣営の確白級" in system_prompt, ( - f"{role.name} missed non-CO 確白級 consequence" - ) - assert "0〜2" in system_prompt, f"{role.name} missed sum 0〜2 caveat" - assert "4 以上" in system_prompt, f"{role.name} missed sum 4+ contradiction" - assert "配役上の消去法" in system_prompt, ( - f"{role.name} missed 配役上の消去法 framing" - ) - - -async def test_ask_system_prompt_contains_co_overflow_examples_for_any_role( - repo: SqliteRepo, -) -> None: - """3-2-1 / 2-2-2 / 3-1-1 / 4-1-1 の短い例も共通ルール経由で全 role に届く。""" - for role in ( - Role.VILLAGER, - Role.SEER, - Role.MEDIUM, - Role.KNIGHT, - Role.WEREWOLF, - Role.MADMAN, - ): - system_prompt = await _capture_ask_system_prompt(repo, role) - assert "3-2-1" in system_prompt, f"{role.name} missed 3-2-1 example" - assert "2-2-2" in system_prompt, f"{role.name} missed 2-2-2 example" - assert "3-1-1" in system_prompt, f"{role.name} missed 3-1-1 example" - assert "4-1-1" in system_prompt, f"{role.name} missed 4-1-1 example" - - -async def test_ask_system_prompt_villager_strategy_includes_co_overflow_action( - repo: SqliteRepo, -) -> None: - """村人席の system prompt には、共通ルールに加え、村人視点の運用 - (CO 群と非 CO 確白を整理し投票先を CO 群に絞る) が届く。""" - system_prompt = await _capture_ask_system_prompt(repo, Role.VILLAGER) - assert "対抗 CO 超過分を毎日整理する" in system_prompt - assert "投票先を CO 群に絞る" in system_prompt - - async def test_ask_system_prompt_seer_strategy_avoids_wasting_divination( repo: SqliteRepo, ) -> None: @@ -1417,86 +1302,6 @@ async def test_ask_system_prompt_seer_strategy_avoids_wasting_divination( assert "対抗 CO 群やまだ確定しない位置を優先して占う" in system_prompt -async def test_ask_system_prompt_medium_strategy_updates_co_inference( - repo: SqliteRepo, -) -> None: - """霊媒師席の system prompt には、霊媒結果で CO 数推理を更新する運用が - 届く。霊媒白は非狼だけを示す既存ルールと整合する形 (真役職 / 狂人)。""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MEDIUM) - assert "霊媒結果は対抗 CO 超過分の CO 数推理を更新する材料" in system_prompt - assert "対抗 CO 群内の狼数を絞り" in system_prompt - assert "白なら真役職または狂人の可能性を分け" in system_prompt - assert "非 CO 確白の前提が保たれるか" in system_prompt - - -async def test_ask_system_prompt_knight_strategy_protects_non_co_certified_white( - repo: SqliteRepo, -) -> None: - """騎士席の system prompt には、超過分合計 3 で生まれた非 CO 確白級と - 単独で対抗のない真寄り情報役を護衛価値が高いと扱う運用が届く。""" - system_prompt = await _capture_ask_system_prompt(repo, Role.KNIGHT) - assert "対抗 CO 超過分合計 3 で生まれた非 CO 確白級" in system_prompt - assert "単独で対抗のない真寄り情報役は護衛価値が高い" in system_prompt - - -async def test_ask_system_prompt_werewolf_strategy_acknowledges_overcounter_risk( - repo: SqliteRepo, -) -> None: - """人狼席の system prompt には、騙りすぎで非 CO が確白級になるリスクを - 超過分集計の枠組みで認識する運用が届く。相方語彙は wolf 専用として - ここに含まれてよい。""" - system_prompt = await _capture_ask_system_prompt(repo, Role.WEREWOLF) - assert "対抗 CO 超過分" in system_prompt - assert "超過分合計が 3 に達した時点で" in system_prompt - assert "処刑候補が CO 群に集中する" in system_prompt - assert "相方と整合する形で選ぶ" in system_prompt - - -async def test_ask_system_prompt_madman_co_overflow_addition_keeps_partner_isolation( - repo: SqliteRepo, -) -> None: - """狂人席の system prompt には、同じリスクを公開情報視点で認識する運用が - 届く一方、wolf-coordination 語彙 (bare `相方` / `襲撃先を揃える`) は - 引き続き混入してはならず、本物の人狼位置を知っている前提の禁止文言も - 保持される (既存 leak guard との整合)。""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MADMAN) - # 新規 CO-overflow 文言は届く。 - assert "対抗 CO 超過分" in system_prompt - assert "超過分合計が 3 に達した時点で" in system_prompt - assert "処刑候補が CO 群に集中するリスクを認識する" in system_prompt - assert "公開情報の各 CO 数と残り縄から判断する" in system_prompt - # Wolf-coordination 語彙が漏れていないこと。 - assert not re.search(r"相方(?!候補)", system_prompt), ( - "bare '相方' (actor mode) leaked into madman system prompt via " - "CO-overflow addition" - ) - assert "襲撃先を揃える" not in system_prompt - # 既存 prohibition 文言が残っていること。 - assert "人狼位置を知っている前提で話してはならない" in system_prompt - - -async def test_ask_system_prompt_contains_advanced_guard_vocab_for_any_role( - repo: SqliteRepo, -) -> None: - """Advanced guard vocabulary (鉄板護衛 / 捨て護衛 / 連続護衛不可 / 護衛読み / - 護衛誘導) lives in the shared rules block so every seat — not just the - knight — can interpret these terms when they appear in the public log.""" - for role in ( - Role.VILLAGER, - Role.SEER, - Role.MEDIUM, - Role.KNIGHT, - Role.WEREWOLF, - Role.MADMAN, - ): - system_prompt = await _capture_ask_system_prompt(repo, role) - assert "鉄板護衛" in system_prompt, f"{role.name} missed 鉄板護衛" - assert "捨て護衛" in system_prompt, f"{role.name} missed 捨て護衛" - assert "連続護衛不可" in system_prompt, f"{role.name} missed 連続護衛不可" - assert "護衛読み" in system_prompt, f"{role.name} missed 護衛読み" - assert "護衛誘導" in system_prompt, f"{role.name} missed 護衛誘導" - - async def test_ask_system_prompt_contains_enthusiast_checklist_for_any_role( repo: SqliteRepo, ) -> None: @@ -1511,50 +1316,6 @@ async def test_ask_system_prompt_contains_enthusiast_checklist_for_any_role( assert "1〜2 点" in system_prompt -async def test_ask_system_prompt_contains_fake_co_legality_for_any_role( - repo: SqliteRepo, -) -> None: - """Fake-CO legality constraints live in common rules so both wolf and - madman see them, and the wolf-coordination leak guards still hold when - exercising the madman path.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MADMAN) - assert "実ルール上あり得る内容" in system_prompt - assert "自分護衛" in system_prompt - assert "同一対象連続護衛" in system_prompt - # Leak guards must still hold even with the new common-rules additions. - # Bare 相方 (actor mode, partner-known) absent; 相方候補 (public-log - # inference noun) is allowed in the shared 2-wolf-pair-inference rules. - assert not re.search(r"相方(?!候補)", system_prompt) - assert "襲撃先を揃える" not in system_prompt - - -async def test_ask_system_prompt_contains_day1_fake_seer_must_white_for_any_role( - repo: SqliteRepo, -) -> None: - """The NIGHT_0 day-1 fake-seer-must-white rule lives in the shared rules - block so every seat sees it. Exercising via VILLAGER (no wolf-strategy - leak) doubles as a non-leak guard.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.VILLAGER) - assert "NIGHT_0" in system_prompt - assert "初回" in system_prompt - assert "day 1" in system_prompt - assert "必ず白を主張" in system_prompt - assert "day 1 で初回黒主張はしない" in system_prompt - assert "偽占い師の黒結果主張は day 2 以降" in system_prompt - # Wolf-coordination guard still holds for the villager seat. Bare 相方 - # (actor mode) absent; 相方候補 (inference) allowed. - assert not re.search(r"相方(?!候補)", system_prompt) - assert "襲撃先を揃える" not in system_prompt - - -async def test_ask_system_prompt_wolf_seat_includes_wolf_strategy(repo: SqliteRepo) -> None: - """A werewolf LLM must receive wolf-coordination tips in its system - prompt (`相方`, `襲撃先を揃える`).""" - system_prompt = await _capture_ask_system_prompt(repo, Role.WEREWOLF) - assert "相方" in system_prompt - assert "襲撃先を揃える" in system_prompt - - async def test_ask_system_prompt_non_wolf_excludes_wolf_strategy(repo: SqliteRepo) -> None: """A non-wolf LLM must NOT receive wolf-coordination tips. This guards against strategy leakage through `build_system_prompt`. Bare `相方` @@ -1585,19 +1346,6 @@ async def test_ask_system_prompt_madman_excludes_wolf_positions_assumption( assert "襲撃先を揃える" not in system_prompt -async def test_ask_system_prompt_wolf_seat_includes_attack_evaluation_axes( - repo: SqliteRepo, -) -> None: - """End-to-end: a werewolf LLM's system prompt carries the new 4-axis attack - rubric (襲撃価値 / 護衛されやすさ / 騎士候補度) and the 騎士探し approach - label via `_build_strategy_block(Role.WEREWOLF)`.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.WEREWOLF) - assert "襲撃価値" in system_prompt - assert "護衛されやすさ" in system_prompt - assert "騎士候補度" in system_prompt - assert "騎士探し" in system_prompt - - async def test_ask_system_prompt_wolf_attack_task_includes_checklist( repo: SqliteRepo, ) -> None: @@ -1703,33 +1451,6 @@ async def test_ask_system_prompt_madman_vote_task_drops_partner_token( assert "席3 X" not in system_prompt -async def test_ask_system_prompt_role_strategy_isolated_between_roles( - repo: SqliteRepo, -) -> None: - """A role's own strategy phrase appears in its system prompt; other roles' - unique phrases must not. Integration-level analog of - `test_strategy_block_no_cross_role_leak` in test_llm_prompt_builder.py.""" - unique_phrases = { - Role.WEREWOLF: "相方を露骨に庇いすぎない", - Role.MADMAN: "人狼位置を知っている前提で話してはならない", - Role.SEER: "判定履歴を時系列で一貫", - Role.MEDIUM: "処刑された相手が狂人でも", - Role.KNIGHT: "前夜と違う相手を選ぶ", - Role.VILLAGER: "CO 騙りは村陣営としては行わない", - } - for role, own_phrase in unique_phrases.items(): - system_prompt = await _capture_ask_system_prompt(repo, role) - assert own_phrase in system_prompt, ( - f"{role.name} did not see its own phrase in system prompt" - ) - for other_role, other_phrase in unique_phrases.items(): - if other_role is role: - continue - assert other_phrase not in system_prompt, ( - f"{other_role.name}'s tip leaked into {role.name}'s system prompt" - ) - - async def test_ask_system_prompt_includes_speech_profile_section(repo: SqliteRepo) -> None: """Every LLM seat's system prompt carries the new `## 話法` section with the persona's first-person and the static common rule from the template.""" @@ -1786,67 +1507,6 @@ async def test_ask_system_prompt_non_wolf_excludes_wolf_strategy_even_with_speec ) -async def test_ask_system_prompt_contains_co_evaluation_rules_for_any_role( - repo: SqliteRepo, -) -> None: - """The shared CO evaluation guidance (single-CO default-truthy, conditions - to suspect, counter-CO comparison axes) must reach every LLM seat via the - rules block. `_build_game_rules_block` is role-independent so one role - suffices to exercise the production path.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.VILLAGER) - assert "単独 CO" in system_prompt - assert "真の役職者にかなり近い" in system_prompt - assert "対抗 CO" in system_prompt - assert "噛み筋" in system_prompt - - -async def test_ask_system_prompt_distinguishes_sole_survivor_from_lone_co( - repo: SqliteRepo, -) -> None: - """Every LLM seat must receive the refinement that distinguishes a truly- - lone CO (no counter ever appeared) from a sole-survivor CO (counter was - executed or attacked). The rules block enforces: 2+ historical COs → do - not auto-trust the survivor, and dead CO holders stay in the comparison - via death-timing integrity.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.VILLAGER) - assert "2 人以上" in system_prompt - assert "自動的に真置きしない" in system_prompt - assert "死亡済み" in system_prompt - assert "死亡タイミング" in system_prompt - - -async def test_ask_system_prompt_warns_against_last_surviving_co_for_any_role( - repo: SqliteRepo, -) -> None: - """Every LLM seat must receive the conclusion-side refinement: a - last-surviving CO is not automatically true. Wolves can leave an info role - unattacked, get the counter executed, or keep a CO around for protective - cover. Sample multiple roles to exercise both wolf-faction and village- - faction paths through the shared rules block.""" - for role in (Role.VILLAGER, Role.SEER, Role.WEREWOLF, Role.MADMAN, Role.KNIGHT): - system_prompt = await _capture_ask_system_prompt(repo, role) - assert "最後まで生き残った" in system_prompt, f"{role.name} missed last-survivor warning" - assert "噛まずに残した" in system_prompt, f"{role.name} missed wolf-skip framing" - assert "単独 CO だから真" in system_prompt, f"{role.name} missed shortcut prohibition" - - -async def test_ask_system_prompt_villager_seat_prohibits_villager_co( - repo: SqliteRepo, -) -> None: - """A villager LLM's system prompt must explicitly forbid declaring - '村人CO' / '素村CO' / '普通の村人です' / '役職は村人です' as a trust-buy and - must point at the alternative '非 CO の灰' stance. The guidance reaches the - villager via `_ROLE_STRATEGIES[Role.VILLAGER]`, so this test pins the - end-to-end composition path through `build_system_prompt`.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.VILLAGER) - assert "村人CO" in system_prompt - assert "素村CO" in system_prompt - assert "普通の村人です" in system_prompt - assert "役職は村人です" in system_prompt - assert "村人は能力結果を持たない" in system_prompt - assert "非 CO の灰" in system_prompt - - @pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN, Role.SEER, Role.MEDIUM, Role.KNIGHT]) async def test_ask_system_prompt_villager_co_prohibition_isolated( repo: SqliteRepo, role: Role @@ -1860,109 +1520,6 @@ async def test_ask_system_prompt_villager_co_prohibition_isolated( assert "素村CO" not in system_prompt, f"{role.name} saw '素村CO' in system prompt" -async def test_ask_system_prompt_explains_medium_white_semantics_for_any_role( - repo: SqliteRepo, -) -> None: - """Every LLM seat must receive the shared rule that medium-white means - `not a real werewolf` only — not a role-claim refutation. Sampling one - role suffices because `_build_game_rules_block` is role-independent.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.VILLAGER) - assert "本物の人狼ではない" in system_prompt - assert "真占い師だった可能性と矛盾しない" in system_prompt - assert "偽扱いしない" in system_prompt - - -async def test_ask_system_prompt_contains_advanced_terminology_for_any_role( - repo: SqliteRepo, -) -> None: - """Every LLM seat (wolf, madman, villager, info role) must receive the - advanced jinro terminology (グレラン / 縄計算 / スケール / 確白 / 確黒 / - パンダ / PP / RPP) via the shared rules block. Sampling 4 representative - roles exercises `build_system_prompt` for both wolf-faction and village- - faction paths, since the terminology is emitted independent of role.""" - for role in (Role.VILLAGER, Role.SEER, Role.WEREWOLF, Role.MADMAN): - system_prompt = await _capture_ask_system_prompt(repo, role) - assert "グレラン" in system_prompt, f"{role.name} missed グレラン" - assert "縄計算" in system_prompt, f"{role.name} missed 縄計算" - assert "スケール" in system_prompt, f"{role.name} missed スケール" - assert "確白" in system_prompt, f"{role.name} missed 確白" - assert "確黒" in system_prompt, f"{role.name} missed 確黒" - assert "パンダ" in system_prompt, f"{role.name} missed パンダ" - assert "PP" in system_prompt, f"{role.name} missed PP" - assert "RPP" in system_prompt, f"{role.name} missed RPP" - - -async def test_ask_system_prompt_medium_guards_against_seer_co_white_misread( - repo: SqliteRepo, -) -> None: - """The Medium LLM's system prompt must carry the role-specific guidance: - medium-white on an executed Seer-CO is NOT proof of a fake seer; the - Medium should separate real-seer from non-wolf-fake hypotheses.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MEDIUM) - assert "占い師 CO 偽の証明ではない" in system_prompt - assert "真占い師だった可能性" in system_prompt - assert "狂人" in system_prompt - - -async def test_ask_system_prompt_knight_includes_protection_success_co_strategy( - repo: SqliteRepo, -) -> None: - """The knight LLM's system prompt must cover the peaceful-morning / - protection-success CO pathway with the guard target disclosure rule.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.KNIGHT) - assert "平和な朝" in system_prompt - assert "護衛成功" in system_prompt - assert "護衛先を添えて" in system_prompt - - -async def test_ask_system_prompt_seer_includes_counter_co_strategy( - repo: SqliteRepo, -) -> None: - """A true seer LLM must receive on-turn CO guidance (when their day-1 - speaking turn arrives), counter-CO (when a fake seer appears), and - black-pull CO guidance so the true seer doesn't stay silent and cede - single-truth treatment to a fake. The on-turn framing replaces the - 'mid-discussion no-seer-CO' trigger because the NPC cannot self- - trigger speech — the rule must fire whenever the arbiter dispatches - them.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.SEER) - assert "発言の番" in system_prompt - assert "次の自分のターンに先送りしない" in system_prompt - assert "対抗 CO" in system_prompt - assert "時系列で公開" in system_prompt - assert "黒を引いた場合" in system_prompt - - -async def test_ask_system_prompt_medium_includes_counter_co_strategy( - repo: SqliteRepo, -) -> None: - """A true medium LLM must receive on-turn result-publication guidance - (when their post-execution speaking turn arrives) and the counter-CO - pathway, with explicit self-roller vulnerability framing so the medium - doesn't stay silent against a fake medium. The on-turn framing replaces - the 'day after execution' anchor for the same reason as seer.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MEDIUM) - assert "前日に処刑があった" in system_prompt - assert "発言の番" in system_prompt - assert "次の自分のターンに先送りしない" in system_prompt - assert "対抗霊媒" in system_prompt - assert "ローラー" in system_prompt - - -async def test_ask_system_prompt_knight_includes_legal_guard_history_and_endgame_co( - repo: SqliteRepo, -) -> None: - """A knight LLM's system prompt must cover endgame / about-to-be-hung CO - timing AND must constrain the guard-diary to the bot's legal guard rules - (no self-guard, no consecutive guard of the same seat, no dead-seat guard).""" - system_prompt = await _capture_ask_system_prompt(repo, Role.KNIGHT) - assert "終盤" in system_prompt - assert "吊られそう" in system_prompt - assert "護衛履歴を日付順" in system_prompt - assert "自分護衛" in system_prompt - assert "同じ相手の連続護衛" in system_prompt - - async def test_ask_system_prompt_knight_includes_advanced_guard_strategy( repo: SqliteRepo, ) -> None: @@ -2010,89 +1567,6 @@ async def test_ask_system_prompt_knight_guard_task_includes_checklist( assert "騎士探し" not in system_prompt -async def test_ask_system_prompt_wolf_seat_includes_fake_strategy(repo: SqliteRepo) -> None: - """The werewolf LLM's system prompt must carry the fake-CO playbook: - day-1 戦術 is presented as a 3-way choice (先制CO / 対抗CO / 潜伏) without - a single mechanical trigger that funnels the LLM into one option, - day-2+ medium/knight fake, the over-fake warning, and medium-roller / - knight-legal-guard-history caveats.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.WEREWOLF) - assert "day 1" in system_prompt - assert "占い師騙り" in system_prompt - assert "霊媒師騙り" in system_prompt - assert "騎士騙り" in system_prompt - assert "6 人以上" in system_prompt - assert "騙りすぎ" in system_prompt - # 3-way framing — 先制CO / 対抗CO / 潜伏 each presented as a first-class option. - assert "先制CO" in system_prompt - assert "対抗CO" in system_prompt - assert "潜伏" in system_prompt - assert "3 択" in system_prompt - assert "等しく強い" in system_prompt - assert "機械的" in system_prompt - assert "潜伏は安全策ではない" in system_prompt - # Day-1 first-result-white anchor + day-1 black prohibition + day-2+ deferral. - assert "NIGHT_0 ランダム白" in system_prompt - assert "必ず白を主張" in system_prompt - assert "初日に黒を出す主張" in system_prompt - assert "黒出しは day 2 以降" in system_prompt - assert "前夜に占ったという想定" in system_prompt - - -async def test_ask_system_prompt_madman_includes_fake_strategy_without_wolf_coordination( - repo: SqliteRepo, -) -> None: - """The madman LLM's system prompt must carry the fake-CO playbook - (day-1 3-way choice 先制CO / 対抗CO / 潜伏, day-2+ medium/knight fake, - over-fake warning) with misfire caveats — and no wolf-coordination - vocabulary (`相方` / `襲撃先を揃える`).""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MADMAN) - assert "day 1" in system_prompt - assert "占い師騙り" in system_prompt - assert "霊媒師騙り" in system_prompt - assert "騎士騙り" in system_prompt - assert "6 人以上" in system_prompt - assert "騙りすぎ" in system_prompt - # Wolf-coordination vocabulary must not appear for the madman. Bare 相方 - # (actor mode, partner-known) absent; 相方候補 (public-log inference) allowed. - assert not re.search(r"相方(?!候補)", system_prompt) - assert "襲撃先を揃える" not in system_prompt - # Existing prohibition phrase must also still be present. - assert "人狼位置を知っている前提で話してはならない" in system_prompt - # 3-way framing + anti-mechanical-default + misfire / white-out caveats. - assert "先制CO" in system_prompt - assert "対抗CO" in system_prompt - assert "潜伏" in system_prompt - assert "3 択" in system_prompt - assert "等しく強い" in system_prompt - assert "機械的" in system_prompt - assert "潜伏は安全策ではない" in system_prompt - assert "誤爆リスク" in system_prompt - assert "白先が本物の狼とは限らない" in system_prompt - # Day-1 first-result-white anchor + day-1 black prohibition + day-2+ deferral. - assert "NIGHT_0 ランダム白" in system_prompt - assert "必ず白を主張" in system_prompt - assert "初日に黒を出す主張" in system_prompt - assert "黒出しは day 2 以降" in system_prompt - assert "誤爆リスクは day 2 以降の黒出しでも常に残る" in system_prompt - - -async def test_ask_system_prompt_seer_includes_night_targeting_axes( - repo: SqliteRepo, -) -> None: - """The true seer's system prompt must carry the night-divination targeting - axes (占い価値 / 灰を狭める / 対抗 CO / 囲い候補 / 投票 / - 白でも黒でも情報が落ちる) via `_ROLE_STRATEGIES[Role.SEER]`. End-to-end - analog of the unit-level `test_seer_strategy_includes_night_divination_targeting_axes`.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.SEER) - assert "占い価値" in system_prompt - assert "灰を狭める" in system_prompt - assert "対抗 CO" in system_prompt - assert "囲い候補" in system_prompt - assert "投票" in system_prompt - assert "白でも黒でも情報が落ちる" in system_prompt - - async def test_ask_system_prompt_seer_divine_task_includes_targeting_checklist( repo: SqliteRepo, ) -> None: @@ -2117,46 +1591,6 @@ async def test_ask_system_prompt_seer_divine_task_includes_targeting_checklist( assert "騎士探し" not in task_text -async def test_ask_system_prompt_wolf_seat_includes_day2_round1_fake_publication( - repo: SqliteRepo, -) -> None: - """The werewolf LLM's system prompt must carry the day-2+ round-1 fake- - result publication imperative for seer/medium/knight fakes, with the - integration cues (相方 / 囲い / 噛み筋 / 霊媒結果 / 合法な護衛履歴).""" - system_prompt = await _capture_ask_system_prompt(repo, Role.WEREWOLF) - assert "day 2 以降" in system_prompt - assert "1 巡目" in system_prompt - assert "前夜" in system_prompt - assert "能力結果" in system_prompt - assert "合法な護衛履歴" in system_prompt - # Wolf-private integration cues must be present. - assert "相方" in system_prompt - assert "囲い" in system_prompt - assert "噛み筋" in system_prompt - assert "霊媒結果" in system_prompt - - -async def test_ask_system_prompt_madman_includes_day2_round1_fake_publication( - repo: SqliteRepo, -) -> None: - """The madman LLM's system prompt must carry the day-2+ round-1 fake- - result publication imperative with explicit misfire framing — and must - NOT carry wolf-coordination vocabulary.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MADMAN) - assert "day 2 以降" in system_prompt - assert "1 巡目" in system_prompt - assert "前夜" in system_prompt - assert "能力結果" in system_prompt - assert "誤爆リスク" in system_prompt - assert "白先が本物の狼とは限らない" in system_prompt - assert "処刑なしの日は結果なし" in system_prompt - assert "合法な護衛履歴" in system_prompt - # Wolf-coordination vocabulary must remain absent. Bare 相方 (actor mode) - # forbidden; 相方候補 (public-log inference) allowed in pair-inference. - assert not re.search(r"相方(?!候補)", system_prompt) - assert "襲撃先を揃える" not in system_prompt - - async def test_discussion_speech_day2_round1_passes_first_round_rule( repo: SqliteRepo, ) -> None: @@ -2527,23 +1961,6 @@ async def test_ask_system_prompt_wolf_seat_includes_two_wolf_set_framing( assert "視点漏れ" in system_prompt -async def test_ask_system_prompt_madman_pair_inference_carries_misfire( - repo: SqliteRepo, -) -> None: - """The madman system prompt must carry the public-log pair-inference - framing with explicit misfire awareness — without leaking wolf-coordination - vocabulary. Bare `相方` (actor mode) must remain absent; `相方候補` is the - only allowed form. `襲撃先を揃える` and `仲間の人狼` must be absent.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MADMAN) - assert "相方候補" in system_prompt - assert "公開ログからの推定" in system_prompt - assert "誤爆" in system_prompt - # Wolf-coordination vocab forbidden for the madman. - assert not re.search(r"相方(?!候補)", system_prompt) - assert "襲撃先を揃える" not in system_prompt - assert "仲間の人狼" not in system_prompt - - async def test_ask_system_prompt_wolf_vote_task_extends_with_two_wolf_set( repo: SqliteRepo, ) -> None: @@ -2779,9 +2196,7 @@ def __init__(self, **kwargs: object) -> None: GAMEPLAY_LLM_PROVIDER="xai", GAMEPLAY_LLM_API_KEY=SecretStr("x"), ) - assert isinstance( - make_llm_decider(s_xai.gameplay_decider_config()), XAILLMActionDecider - ) + assert isinstance(make_llm_decider(s_xai.gameplay_decider_config()), XAILLMActionDecider) s_ds = MasterSettings( # type: ignore[arg-type] _env_file=None, @@ -2789,9 +2204,7 @@ def __init__(self, **kwargs: object) -> None: GAMEPLAY_LLM_PROVIDER="deepseek", GAMEPLAY_LLM_API_KEY=SecretStr("d"), ) - assert isinstance( - make_llm_decider(s_ds.gameplay_decider_config()), DeepSeekLLMActionDecider - ) + assert isinstance(make_llm_decider(s_ds.gameplay_decider_config()), DeepSeekLLMActionDecider) s_gem = MasterSettings( # type: ignore[arg-type] _env_file=None, @@ -2799,9 +2212,7 @@ def __init__(self, **kwargs: object) -> None: GAMEPLAY_LLM_PROVIDER="gemini", GAMEPLAY_LLM_VERTEX_PROJECT="my-project", ) - assert isinstance( - make_llm_decider(s_gem.gameplay_decider_config()), GeminiLLMActionDecider - ) + assert isinstance(make_llm_decider(s_gem.gameplay_decider_config()), GeminiLLMActionDecider) def test_make_gemini_decider_constructs_vertex_ai_client( @@ -2859,9 +2270,7 @@ class _FakeChatCompletionsWithUsage: """Like ``_FakeChatCompletions`` but the response carries ``.usage`` so the OpenAI-shaped token extractor returns real integers.""" - def __init__( - self, content: str, usage: tuple[int, int, int] = (1500, 200, 1700) - ) -> None: + def __init__(self, content: str, usage: tuple[int, int, int] = (1500, 200, 1700)) -> None: self._content = content self._usage = usage self.calls: list[dict[str, object]] = [] @@ -2872,9 +2281,7 @@ async def create(self, **kwargs: object) -> object: self.calls.append(kwargs) prompt, completion, total = self._usage return SimpleNamespace( - choices=[ - SimpleNamespace(message=SimpleNamespace(content=self._content)) - ], + choices=[SimpleNamespace(message=SimpleNamespace(content=self._content))], usage=SimpleNamespace( prompt_tokens=prompt, completion_tokens=completion, @@ -2889,9 +2296,7 @@ class _FakeAsyncOpenAIWithUsage: def __init__(self, content: str, usage: tuple[int, int, int] = (1500, 200, 1700)) -> None: from types import SimpleNamespace - self.chat = SimpleNamespace( - completions=_FakeChatCompletionsWithUsage(content, usage) - ) + self.chat = SimpleNamespace(completions=_FakeChatCompletionsWithUsage(content, usage)) class _FakeGenAIModelsWithUsage: diff --git a/tests/test_npc_decision_service.py b/tests/test_npc_decision_service.py index f38dbfe..baae993 100644 --- a/tests/test_npc_decision_service.py +++ b/tests/test_npc_decision_service.py @@ -26,7 +26,10 @@ def _state(role: str = "WEREWOLF") -> NpcGameState: return NpcGameState( - game_id="g1", seat_no=3, persona_key="setsu", role=role, + game_id="g1", + seat_no=3, + persona_key="setsu", + role=role, day_number=1, alive_seats=[(1, "Alice"), (3, "セツ"), (5, "Bob")], partner_wolves=[(5, "Bob")] if role == "WEREWOLF" else [], @@ -36,9 +39,14 @@ def _state(role: str = "WEREWOLF") -> NpcGameState: def test_build_vote_prompt_includes_state_and_candidates() -> None: persona = NPC_PERSONAS_BY_KEY["setsu"] req = DecideVoteRequest( - ts=1, trace_id="t", request_id="rv1", - npc_id="npc_setsu", seat_no=3, game_id="g1", - phase_id="g1::day1::DAY_VOTE::1", round_=0, + ts=1, + trace_id="t", + request_id="rv1", + npc_id="npc_setsu", + seat_no=3, + game_id="g1", + phase_id="g1::day1::DAY_VOTE::1", + round_=0, candidate_seats=((1, "Alice"), (5, "Bob")), public_state_summary="phase=DAY_VOTE day=1 co_claims=[(none)]", expires_at_ms=10_000, @@ -64,9 +72,14 @@ def test_build_vote_prompt_includes_state_and_candidates() -> None: def test_build_vote_prompt_runoff_label() -> None: persona = NPC_PERSONAS_BY_KEY["jonas"] req = DecideVoteRequest( - ts=1, trace_id="t", request_id="rv2", - npc_id="npc_jonas", seat_no=4, game_id="g1", - phase_id="g1::day1::DAY_RUNOFF::1", round_=1, + ts=1, + trace_id="t", + request_id="rv2", + npc_id="npc_jonas", + seat_no=4, + game_id="g1", + phase_id="g1::day1::DAY_RUNOFF::1", + round_=1, candidate_seats=((1, "Alice"),), expires_at_ms=10_000, ) @@ -77,8 +90,12 @@ def test_build_vote_prompt_runoff_label() -> None: def test_build_night_prompt_per_action_label() -> None: persona = NPC_PERSONAS_BY_KEY["setsu"] base_kwargs: dict = dict( # type: ignore[type-arg] - ts=1, trace_id="t", request_id="rn", - npc_id="npc_setsu", seat_no=3, game_id="g1", + ts=1, + trace_id="t", + request_id="rn", + npc_id="npc_setsu", + seat_no=3, + game_id="g1", phase_id="g1::day1::NIGHT::1", candidate_seats=((1, "Alice"),), expires_at_ms=10_000, @@ -159,6 +176,6 @@ def test_build_wolf_chat_prompt_includes_role_strategy_block() -> None: assert "## 役職別の戦術ヒント" in user # Spot-check that the multi-CO attack-avoidance line from # `_ROLE_STRATEGIES[WEREWOLF]` made it through. - assert "対抗 CO が出ている役職" in user + assert "対抗 CO ありの役職" in user # And the no-4th-seer-CO rule too (same block). - assert "4 件目を出してはならない" in user + assert "占 3 / 霊 2 / 騎 2 の上限を超える追加 CO は出さない" in user From ec28b67ce6b99faedea91c6bf0f4350980275557 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 17:11:26 +0900 Subject: [PATCH 116/133] fix(viewer): retag morning/day-start logs from text instead of bumping day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/services/game_export.py | 249 ++++++++++++++---------- tests/test_game_export.py | 289 +++++++++++++++++++++++++--- 2 files changed, 408 insertions(+), 130 deletions(-) diff --git a/src/wolfbot/services/game_export.py b/src/wolfbot/services/game_export.py index f30b70a..9facfa5 100644 --- a/src/wolfbot/services/game_export.py +++ b/src/wolfbot/services/game_export.py @@ -26,6 +26,7 @@ import json import logging +import re from datetime import datetime from pathlib import Path from typing import Any, cast @@ -117,9 +118,7 @@ async def export_game( return out_path.resolve() -async def _build_payload( - game_id: str, db_path: Path, trace_root: Path -) -> GameExport: +async def _build_payload(game_id: str, db_path: Path, trace_root: Path) -> GameExport: async with aiosqlite.connect(str(db_path)) as db: db.row_factory = aiosqlite.Row await db.execute("PRAGMA foreign_keys = ON") @@ -132,11 +131,14 @@ async def _build_payload( if game_row is None: raise ValueError(f"game not found: {game_id}") - seat_rows = [dict(r) for r in await _fetch_all( - db, - "SELECT * FROM seats WHERE game_id = ? ORDER BY seat_no", - (game_id,), - )] + seat_rows = [ + dict(r) + for r in await _fetch_all( + db, + "SELECT * FROM seats WHERE game_id = ? ORDER BY seat_no", + (game_id,), + ) + ] # PLAYER_SPEECH log rows duplicate the canonical speech_events rows # (DiscussionService.record() inserts both at write time so live LLM # context-builder prompts can keep reading from logs_public). For @@ -144,27 +146,36 @@ async def _build_payload( # speaker_seat + summary, which the viewer attributes to the player. # Excluding the duplicates here keeps the timeline clean and avoids # the "NPC text shown twice (PLAYER_SPEECH log + speech_event)" issue. - public_log_rows = [dict(r) for r in await _fetch_all( - db, - "SELECT day, phase, kind, actor_seat, text, created_at " - "FROM logs_public WHERE game_id = ? AND kind != 'PLAYER_SPEECH' " - "ORDER BY id ASC", - (game_id,), - )] - vote_rows = [dict(r) for r in await _fetch_all( - db, - "SELECT day, round, voter_seat, target_seat, submitted_at " - "FROM votes WHERE game_id = ? " - "ORDER BY day, round, submitted_at", - (game_id,), - )] - night_action_rows = [dict(r) for r in await _fetch_all( - db, - "SELECT day, actor_seat, kind, target_seat, submitted_at " - "FROM night_actions WHERE game_id = ? " - "ORDER BY day, submitted_at", - (game_id,), - )] + public_log_rows = [ + dict(r) + for r in await _fetch_all( + db, + "SELECT day, phase, kind, actor_seat, text, created_at " + "FROM logs_public WHERE game_id = ? AND kind != 'PLAYER_SPEECH' " + "ORDER BY id ASC", + (game_id,), + ) + ] + vote_rows = [ + dict(r) + for r in await _fetch_all( + db, + "SELECT day, round, voter_seat, target_seat, submitted_at " + "FROM votes WHERE game_id = ? " + "ORDER BY day, round, submitted_at", + (game_id,), + ) + ] + night_action_rows = [ + dict(r) + for r in await _fetch_all( + db, + "SELECT day, actor_seat, kind, target_seat, submitted_at " + "FROM night_actions WHERE game_id = ? " + "ORDER BY day, submitted_at", + (game_id,), + ) + ] # Wolf chat is fan-out-stored in `logs_private` (one row per # audience seat) so the same utterance shows up N times where N # = alive wolves at the moment. Dedupe on @@ -173,37 +184,45 @@ async def _build_payload( # always 'NIGHT' (or 'NIGHT_0' on day 0) by construction; we # carry it through so the per-phase grouping in `_build_phases` # picks it up without extra logic. - wolf_chat_rows = [dict(r) for r in await _fetch_all( - db, - "SELECT DISTINCT day, phase, actor_seat, text, created_at " - "FROM logs_private " - "WHERE game_id = ? AND kind = 'WOLF_CHAT' AND actor_seat IS NOT NULL " - "ORDER BY created_at ASC", - (game_id,), - )] + wolf_chat_rows = [ + dict(r) + for r in await _fetch_all( + db, + "SELECT DISTINCT day, phase, actor_seat, text, created_at " + "FROM logs_private " + "WHERE game_id = ? AND kind = 'WOLF_CHAT' AND actor_seat IS NOT NULL " + "ORDER BY created_at ASC", + (game_id,), + ) + ] # `phase_baseline` rows are an internal sentinel used by # PublicDiscussionState to seed alive-seat baselines; they have # empty text and are explicitly excluded from public-log emission # in the live system. Filter at the SQL level so the viewer never # sees them. - speech_event_rows = [dict(r) for r in await _fetch_all( - db, - "SELECT event_id, day, phase, source, speaker_seat, text, " - "stt_confidence, summary, co_declaration, addressed_seat_no, " - "claimed_seer_target_seat, claimed_seer_is_wolf, " - "claimed_medium_target_seat, claimed_medium_is_wolf, " - "created_at_ms " - "FROM speech_events WHERE game_id = ? " - "AND source != 'phase_baseline' " - "ORDER BY created_at_ms ASC", - (game_id,), - )] + speech_event_rows = [ + dict(r) + for r in await _fetch_all( + db, + "SELECT event_id, day, phase, source, speaker_seat, text, " + "stt_confidence, summary, co_declaration, addressed_seat_no, " + "claimed_seer_target_seat, claimed_seer_is_wolf, " + "claimed_medium_target_seat, claimed_medium_is_wolf, " + "created_at_ms " + "FROM speech_events WHERE game_id = ? " + "AND source != 'phase_baseline' " + "ORDER BY created_at_ms ASC", + (game_id,), + ) + ] # Arbiter decision timeline — joined LEFT-OUTER from requests so # in-flight or rejected dispatches still appear (results / # playback may legitimately be missing). - arbiter_rows = [dict(r) for r in await _fetch_all( - db, - """ + arbiter_rows = [ + dict(r) + for r in await _fetch_all( + db, + """ SELECT req.request_id, req.phase_id, req.npc_id, req.seat_no, req.suggested_intent, req.selection_reason, @@ -225,15 +244,16 @@ async def _build_payload( WHERE req.game_id = ? ORDER BY req.created_at_ms ASC """, - (game_id,), - )] + (game_id,), + ) + ] seat_lookup = {s["seat_no"]: s["display_name"] for s in seat_rows} return GameExport( game=_build_game_meta(game_row, public_log_rows), seats=[_build_seat(s) for s in seat_rows], phases=_build_phases( - _rebucket_morning_logs(public_log_rows), + _retag_morning_logs_from_text(public_log_rows), speech_event_rows, vote_rows, night_action_rows, @@ -261,9 +281,7 @@ async def _fetch_all( # ---------------------------------------------------------------- shape builders -def _build_game_meta( - row: aiosqlite.Row, public_logs: list[dict[str, Any]] -) -> GameMeta: +def _build_game_meta(row: aiosqlite.Row, public_logs: list[dict[str, Any]]) -> GameMeta: discussion_mode = cast(DiscussionMode, row["discussion_mode"]) return GameMeta( id=row["id"], @@ -341,52 +359,83 @@ def _build_seat(row: dict[str, Any]) -> SeatExport: ) -_DAY_START_TEXT_PREFIX = ("夜が明けました。1 日目の議論を開始", "日目の議論を開始") +_DAY_START_TEXT_PATTERN = re.compile(r"^(?:夜が明けました。)?(\d+) 日目の議論を開始") -def _rebucket_morning_logs( +def _retag_morning_logs_from_text( public_logs: list[dict[str, Any]], ) -> list[dict[str, Any]]: - """Defensive rebucketing for legacy games whose MORNING + day-start - PHASE_CHANGE logs were written with the *prior* day_number. - - Pre-fix (state_machine.py before 2026-05-02), ``plan_night_resolve`` - and ``plan_night0`` emitted these logs through the ``_public_log`` - helper which defaulted ``day=game.day_number`` — and that's the - OLD day at the moment the transition is computed. So a NIGHT 1 - resolution wrote "朝になりました" / "2 日目の議論を開始" with - ``day=1`` instead of ``day=2``, and the viewer's per-(day, phase) - bucketing put day-2's morning into day-1's DAY_DISCUSSION section. - The visible symptom: timeline reads - "DAY_DISCUSSION → DAY_VOTE → NIGHT → DAY_DISCUSSION" with the - second discussion's started_at_ms timestamped *after* its own - speeches. - - The state machine now tags these logs explicitly with - ``day=next_day`` (commit ``state_machine.py``). Existing games - re-exported through this path still need the fix without a DB - rewrite — hence this normalisation step. Returns a *new* list - with day-fields adjusted; the input is not mutated so other - consumers (e.g. ``_build_game_meta``) keep their original view. + """Use the day number embedded in PHASE_CHANGE / MORNING text as the + source of truth for those logs' ``day`` field. + + Pre-2026-05-02 (commit ``cab2b92``), ``plan_night_resolve`` and + ``plan_night0`` emitted the next-day MORNING + "N 日目の議論を開始" + PHASE_CHANGE through ``_public_log`` which defaulted ``day = + game.day_number`` — i.e. the *prior* day at the moment of the + transition. So a NIGHT 1 → DAY 2 resolve wrote those logs with + ``day=1``. The viewer then bucketed day-2's morning into day-1's + DAY_DISCUSSION section, and the timeline read out of order. + + State machine now tags these logs explicitly with the next day, so + new games store correct values. But ~80 already-played games carry + the old offset in their DB rows. Reading the day number out of the + PHASE_CHANGE text ("2 日目の議論を開始") gives us a deterministic + fix that works for both eras: post-fix games are already aligned + (no-op), pre-fix games get rewritten on the fly without a DB + migration. + + MORNING rows ("朝になりました。犠牲者: 〜" / "平和な朝です") have + no day in their text. They are emitted in the same Transition as + the day-start PHASE_CHANGE and share its ``created_at`` epoch + second; we look up the sibling PHASE_CHANGE within the same + second and copy its corrected day. If no sibling is found (game + aborted mid-resolution before PHASE_CHANGE landed) the row is + passed through unchanged. + + The earlier ``_rebucket_morning_logs`` helper indiscriminately did + ``day += 1`` on every MORNING/PHASE_CHANGE row — that broke + post-fix games where the day was already correct. Trust the text, + not a blanket offset. """ + # First pass: build a map of (created_at_epoch_s) → corrected_day, + # populated from PHASE_CHANGE rows whose text encodes the day. + corrected_day_by_ts: dict[int, int] = {} + for row in public_logs: + if row.get("phase") != "DAY_DISCUSSION" or row.get("kind") != "PHASE_CHANGE": + continue + text = row.get("text") + if not isinstance(text, str): + continue + m = _DAY_START_TEXT_PATTERN.match(text) + if m is None: + continue + corrected_day_by_ts[int(row["created_at"])] = int(m.group(1)) + fixed: list[dict[str, Any]] = [] for row in public_logs: - if ( - row.get("phase") == "DAY_DISCUSSION" - and ( - row.get("kind") == "MORNING" - or ( - row.get("kind") == "PHASE_CHANGE" - and isinstance(row.get("text"), str) - and "日目の議論を開始" in row["text"] - ) - ) - ): - patched = dict(row) - patched["day"] = (row.get("day") or 0) + 1 - fixed.append(patched) - else: + if row.get("phase") != "DAY_DISCUSSION": fixed.append(row) + continue + kind = row.get("kind") + if kind == "PHASE_CHANGE": + text = row.get("text") + if isinstance(text, str): + m = _DAY_START_TEXT_PATTERN.match(text) + if m is not None: + expected = int(m.group(1)) + if row.get("day") != expected: + patched = dict(row) + patched["day"] = expected + fixed.append(patched) + continue + elif kind == "MORNING": + sibling_day = corrected_day_by_ts.get(int(row["created_at"])) + if sibling_day is not None and row.get("day") != sibling_day: + patched = dict(row) + patched["day"] = sibling_day + fixed.append(patched) + continue + fixed.append(row) return fixed @@ -617,9 +666,7 @@ def _name(seat: int) -> str: medium_target = ev.get("claimed_medium_target_seat") if medium_target is not None: raw_verdict = ev.get("claimed_medium_is_wolf") - verdict = ( - bool(raw_verdict) if raw_verdict is not None else None - ) + verdict = bool(raw_verdict) if raw_verdict is not None else None medium_by_seat.setdefault(speaker_seat, []).append( ClaimedMediumHistoryEntry( day=int(ev["day"]), @@ -686,9 +733,7 @@ def _load_trace(trace_root: Path, game_id: str) -> list[TraceEntry]: try: obj = json.loads(line) except json.JSONDecodeError: - log.warning( - "skipping malformed trace line in %s", jsonl_path - ) + log.warning("skipping malformed trace line in %s", jsonl_path) continue obj.setdefault("file_stem", stem) if obj.get("day") is None: diff --git a/tests/test_game_export.py b/tests/test_game_export.py index 3209563..45fb379 100644 --- a/tests/test_game_export.py +++ b/tests/test_game_export.py @@ -58,10 +58,8 @@ async def _seed_minimal_game(repo: SqliteRepo) -> None: ) await repo.create_game(game) seats = [ - Seat(seat_no=1, display_name="Alice", is_llm=False, persona_key=None, - discord_user_id="u1"), - Seat(seat_no=2, display_name="Bot", is_llm=True, persona_key="setsu", - discord_user_id="u2"), + Seat(seat_no=1, display_name="Alice", is_llm=False, persona_key=None, discord_user_id="u1"), + Seat(seat_no=2, display_name="Bot", is_llm=True, persona_key="setsu", discord_user_id="u2"), ] for seat in seats: await repo.insert_seat(GAME_ID, seat) @@ -71,8 +69,7 @@ async def _seed_minimal_game(repo: SqliteRepo) -> None: await repo.set_player_role(GAME_ID, 2, Role.WEREWOLF) async with repo._tx() as db: await db.execute( - "UPDATE seats SET alive=0, death_cause=?, death_day=? " - "WHERE game_id=? AND seat_no=2", + "UPDATE seats SET alive=0, death_cause=?, death_day=? WHERE game_id=? AND seat_no=2", (DeathCause.EXECUTION.value, 1, GAME_ID), ) @@ -220,7 +217,10 @@ async def test_export_game_emits_wolf_chat_logs_per_phase( # Add a second wolf so the fan-out (one row per audience) is # exercised — the exporter must collapse N audience rows back to 1. seat3 = Seat( - seat_no=3, display_name="Wolf2", is_llm=True, persona_key="raqio", + seat_no=3, + display_name="Wolf2", + is_llm=True, + persona_key="raqio", discord_user_id="u3", ) await repo.insert_seat(GAME_ID, seat3) @@ -267,9 +267,7 @@ async def test_export_game_emits_wolf_chat_logs_per_phase( # NIGHT phase appears in the timeline because of the wolf chat # rows (no NIGHT_1 actions or public logs were seeded for day 1). - night1 = next( - p for p in payload["phases"] if p["day"] == 1 and p["phase"] == "NIGHT" - ) + night1 = next(p for p in payload["phases"] if p["day"] == 1 and p["phase"] == "NIGHT") assert len(night1["wolf_chat_logs"]) == 2 # deduped from 4 rows first, second = night1["wolf_chat_logs"] assert first["actor_seat"] == 2 @@ -294,22 +292,45 @@ async def test_export_game_inlines_jsonl_trace( game_trace = trace_root / GAME_ID game_trace.mkdir(parents=True) (game_trace / "gameplay.jsonl").write_text( - json.dumps({"ts": "2026-04-28T08:00:01+00:00", "role": "gameplay", - "model": "grok-4-1-fast", "system_prompt": "s", - "user_prompt": "u", "response": "r", "latency_ms": 100, - "tokens": {"prompt": 5, "completion": 1, "total": 6}, - "phase": "DAY_DISCUSSION", "day": 1, - "actor": "seat=2 persona=setsu", - "provider": "xai", "error": None}) + "\n", + json.dumps( + { + "ts": "2026-04-28T08:00:01+00:00", + "role": "gameplay", + "model": "grok-4-1-fast", + "system_prompt": "s", + "user_prompt": "u", + "response": "r", + "latency_ms": 100, + "tokens": {"prompt": 5, "completion": 1, "total": 6}, + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=2 persona=setsu", + "provider": "xai", + "error": None, + } + ) + + "\n", encoding="utf-8", ) (game_trace / "voice_stt.jsonl").write_text( - json.dumps({"ts": "2026-04-28T08:00:00+00:00", "role": "voice_stt", - "provider": "gemini", "model": "gemini-2.0-flash-lite", - "system_prompt": "s", "user_prompt": "[audio]", - "response": "{}", "latency_ms": 800, "tokens": None, - "phase": None, "day": None, "actor": None, - "error": None}) + "\n", + json.dumps( + { + "ts": "2026-04-28T08:00:00+00:00", + "role": "voice_stt", + "provider": "gemini", + "model": "gemini-2.0-flash-lite", + "system_prompt": "s", + "user_prompt": "[audio]", + "response": "{}", + "latency_ms": 800, + "tokens": None, + "phase": None, + "day": None, + "actor": None, + "error": None, + } + ) + + "\n", encoding="utf-8", ) @@ -363,11 +384,7 @@ async def test_export_game_filters_player_speech_logs( output_dir=tmp_path / "out", ) payload = json.loads(out.read_text(encoding="utf-8")) - all_log_kinds = { - log["kind"] - for phase in payload["phases"] - for log in phase["public_logs"] - } + all_log_kinds = {log["kind"] for phase in payload["phases"] for log in phase["public_logs"]} assert "PLAYER_SPEECH" not in all_log_kinds @@ -529,6 +546,7 @@ async def test_export_game_filename_uses_timestamp_prefix( # 2023-11-15 in UTC. Local-time conversion may shift the date but the # YYYY-MM-DD_HH-MM-SS shape is invariant. import re + assert re.match(r"^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:_\d+)?\.json$", out.name), ( f"unexpected filename shape: {out.name}" ) @@ -574,3 +592,218 @@ async def test_export_game_filename_disambiguates_same_second_collision( ) assert third != first assert third.name.endswith("_1.json") + + +async def test_export_game_phases_chronological_with_correctly_tagged_logs( + fixture_repo: tuple[SqliteRepo, Path], + tmp_path: Path, +) -> None: + """A post-fix game whose MORNING + day-start PHASE_CHANGE logs are + already tagged with the right ``day`` must export phases in + chronological order and not duplicate buckets one day off. + + Reproduces the bug fixed by replacing ``_rebucket_morning_logs`` + with ``_retag_morning_logs_from_text``: the old helper bumped every + matching row's day by +1 unconditionally, so a correctly-tagged + game emitted phantom (day+1, DAY_DISCUSSION) buckets containing + only the morning logs while the actual same-day speeches stayed in + the original bucket. + """ + repo, db_path = fixture_repo + base = 1_777_700_000 # epoch s, arbitrary + game = Game( + id="g_phase_order", + guild_id="g", + host_user_id="h", + phase=Phase.GAME_OVER, + day_number=2, + deadline_epoch=None, + main_text_channel_id="c", + main_vc_channel_id="v", + heaven_channel_id=None, + wolves_channel_id=None, + created_at=base, + ended_at=base + 1000, + force_skip_pending=False, + discussion_mode="rounds", + ) + await repo.create_game(game) + await repo.insert_seat( + game.id, + Seat( + seat_no=1, + display_name="A", + discord_user_id="u1", + is_llm=False, + persona_key=None, + ), + ) + + async def _log(*, day: int, phase: Phase, kind: str, text: str, t: int) -> None: + await repo.insert_log_public( + LogEntry( + game_id=game.id, + day=day, + phase=phase, + kind=kind, + actor_seat=None, + visibility="PUBLIC", + text=text, + created_at=t, + ) + ) + + # day-1 morning + first speech, vote, then day-2 morning + speech. + await _log( + day=1, + phase=Phase.DAY_DISCUSSION, + kind="PHASE_CHANGE", + text="夜が明けました。1 日目の議論を開始します。", + t=base + 10, + ) + await _log( + day=1, + phase=Phase.DAY_VOTE, + kind="PHASE_CHANGE", + text="議論時間終了。投票フェイズを開始します。", + t=base + 100, + ) + await _log( + day=2, phase=Phase.DAY_DISCUSSION, kind="MORNING", text="平和な朝です。", t=base + 200 + ) + await _log( + day=2, + phase=Phase.DAY_DISCUSSION, + kind="PHASE_CHANGE", + text="2 日目の議論を開始します。", + t=base + 200, + ) + await _log( + day=2, + phase=Phase.DAY_VOTE, + kind="PHASE_CHANGE", + text="議論時間終了。投票フェイズを開始します。", + t=base + 300, + ) + + out_path = await export_game( + game_id=game.id, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=tmp_path / "out", + ) + payload = json.loads(out_path.read_text()) + # No phantom day-3 bucket from morning rebumping. + assert {(p["day"], p["phase"]) for p in payload["phases"]} == { + (1, "DAY_DISCUSSION"), + (1, "DAY_VOTE"), + (2, "DAY_DISCUSSION"), + (2, "DAY_VOTE"), + } + # And they appear in chronological order. + starts = [p["started_at_ms"] for p in payload["phases"]] + assert starts == sorted(starts) + + +async def test_export_game_retags_legacy_morning_logs_from_text( + fixture_repo: tuple[SqliteRepo, Path], + tmp_path: Path, +) -> None: + """A pre-fix game whose MORNING + day-start PHASE_CHANGE logs are + tagged with the *prior* day must still produce a correctly-ordered + timeline. The retagger reads the day number out of the PHASE_CHANGE + text ("2 日目の議論を開始") and uses it as the source of truth. + """ + repo, db_path = fixture_repo + base = 1_777_700_000 + game = Game( + id="g_phase_order_legacy", + guild_id="g2", + host_user_id="h", + phase=Phase.GAME_OVER, + day_number=2, + deadline_epoch=None, + main_text_channel_id="c", + main_vc_channel_id="v", + heaven_channel_id=None, + wolves_channel_id=None, + created_at=base, + ended_at=base + 1000, + force_skip_pending=False, + discussion_mode="rounds", + ) + await repo.create_game(game) + await repo.insert_seat( + game.id, + Seat( + seat_no=1, + display_name="A", + discord_user_id="u1", + is_llm=False, + persona_key=None, + ), + ) + + async def _log(*, day: int, phase: Phase, kind: str, text: str, t: int) -> None: + await repo.insert_log_public( + LogEntry( + game_id=game.id, + day=day, + phase=phase, + kind=kind, + actor_seat=None, + visibility="PUBLIC", + text=text, + created_at=t, + ) + ) + + # Legacy tagging: NIGHT_0 → DAY_1 morning was emitted with day=0, + # NIGHT_1 → DAY_2 morning was emitted with day=1. Text carries the + # correct N (1, 2) so the retagger can fix it on read. + await _log( + day=0, + phase=Phase.DAY_DISCUSSION, + kind="PHASE_CHANGE", + text="夜が明けました。1 日目の議論を開始します。", + t=base + 10, + ) + await _log( + day=1, + phase=Phase.DAY_VOTE, + kind="PHASE_CHANGE", + text="議論時間終了。投票フェイズを開始します。", + t=base + 100, + ) + await _log( + day=1, phase=Phase.DAY_DISCUSSION, kind="MORNING", text="平和な朝です。", t=base + 200 + ) + await _log( + day=1, + phase=Phase.DAY_DISCUSSION, + kind="PHASE_CHANGE", + text="2 日目の議論を開始します。", + t=base + 200, + ) + + out_path = await export_game( + game_id=game.id, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=tmp_path / "out", + ) + payload = json.loads(out_path.read_text()) + pairs = {(p["day"], p["phase"]) for p in payload["phases"]} + # The 2 日目 PHASE_CHANGE + MORNING were tagged day=1 in DB but + # belong to day=2. After retagging they land in (2, DAY_DISCUSSION), + # not (1, DAY_DISCUSSION). + assert (2, "DAY_DISCUSSION") in pairs + # And no leftover-from-misbucket (1, DAY_DISCUSSION) with only the + # PHASE_CHANGE row from day-1's start logs. + day1_disc = next( + p for p in payload["phases"] if p["day"] == 1 and p["phase"] == "DAY_DISCUSSION" + ) + # day-1's discussion only contains the "1 日目の議論を開始" row, + # which the retagger leaves as day=1 (its text says so). + assert len(day1_disc["public_logs"]) == 1 + assert "1 日目の議論を開始" in day1_disc["public_logs"][0]["text"] From cf2c673785ae3c62ba28f2d29998e8d60a4992e4 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 17:34:24 +0900 Subject: [PATCH 117/133] feat(prompts): inject game rules into NPC decision prompts + grey-seat protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/npc/decision/decision_service.py | 4 ++++ .../prompts/templates/npc/decision_night_user.md | 7 ++++++- .../prompts/templates/npc/decision_vote_user.md | 13 ++++++++++++- .../templates/npc/decision_wolf_chat_user.md | 5 ++++- .../prompts/templates/shared/game_rules_9p.md | 8 ++++++++ 5 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/wolfbot/npc/decision/decision_service.py b/src/wolfbot/npc/decision/decision_service.py index 9bc8144..613c891 100644 --- a/src/wolfbot/npc/decision/decision_service.py +++ b/src/wolfbot/npc/decision/decision_service.py @@ -32,6 +32,7 @@ ) from wolfbot.llm.persona_base import Persona from wolfbot.llm.prompt_builder import ( + _build_game_rules_block, build_judgment_profile_block, build_strategy_block, ) @@ -251,6 +252,7 @@ def build_vote_prompt( _DECISION_VOTE_USER_TEMPLATE, round_label=_VOTE_ACT_TEXT_BY_ROUND.get(request.round_, f"round={request.round_}"), day_number=state.day_number, + game_rules_block=_build_game_rules_block(), persona_block=_build_persona_block(persona), role_block=_build_role_block(state.role), state_block=_build_state_block(state), @@ -322,6 +324,7 @@ def build_wolf_chat_prompt( render_template( _DECISION_WOLF_CHAT_USER_TEMPLATE, day_number=state.day_number, + game_rules_block=_build_game_rules_block(), persona_block=_build_persona_block(persona), role_block=_build_role_block(state.role), state_block=_build_state_block(state), @@ -369,6 +372,7 @@ def build_night_prompt( _DECISION_NIGHT_USER_TEMPLATE, action_label=_NIGHT_ACT_TEXT.get(request.action_kind, request.action_kind), day_number=state.day_number, + game_rules_block=_build_game_rules_block(), persona_block=_build_persona_block(persona), role_block=_build_role_block(state.role), state_block=_build_state_block(state), diff --git a/src/wolfbot/prompts/templates/npc/decision_night_user.md b/src/wolfbot/prompts/templates/npc/decision_night_user.md index ebfd363..e868346 100644 --- a/src/wolfbot/prompts/templates/npc/decision_night_user.md +++ b/src/wolfbot/prompts/templates/npc/decision_night_user.md @@ -1,5 +1,8 @@ ## フェイズ: {{action_label}} (day {{day_number}}) +## 共通ルール +{{game_rules_block}} + {{persona_block}} {{role_block}} @@ -13,4 +16,6 @@ ## 行動候補席 {{candidates_str}} -上記すべてを踏まえ、夜の行動対象を決めてください。**スキップ禁止**: 必ず候補席の中から1人を選んで `target_seat` に入れる。情報が薄くても、相対的に最も対象として価値がある1人を選ぶこと (占い: 情報を取りたい灰、人狼: 噛み価値の高い位置、騎士: 守るべき情報役/重要位置)。「捨て護衛」のような戦術選択をしたい場合も、null ではなく合法候補から1人を選ぶ。JSON は {"target_seat": <候補席番号>, "reason": "<短い理由>"} の形 (`target_seat` は必ず整数、null 不可)。 \ No newline at end of file +上記すべてを踏まえ、夜の行動対象を決めてください。**スキップ禁止**: 必ず候補席の中から1人を選ぶ (`target_seat` に整数、null 不可)。情報が薄くても、相対的に最も対象として価値がある 1 人を選ぶこと (占い: 情報を取りたい灰、人狼: 噛み価値の高い位置、騎士: 守るべき情報役/重要位置)。「捨て護衛」のような戦術選択でも null ではなく合法候補から 1 人を選ぶ。 + +JSON は {"target_seat": <候補席番号>, "reason": "<短い理由>"} の形で返す。 diff --git a/src/wolfbot/prompts/templates/npc/decision_vote_user.md b/src/wolfbot/prompts/templates/npc/decision_vote_user.md index 700d3fc..8147758 100644 --- a/src/wolfbot/prompts/templates/npc/decision_vote_user.md +++ b/src/wolfbot/prompts/templates/npc/decision_vote_user.md @@ -1,5 +1,8 @@ ## フェイズ: {{round_label}} (day {{day_number}}) +## 共通ルール +{{game_rules_block}} + {{persona_block}} {{role_block}} @@ -13,4 +16,12 @@ ## 投票候補席 {{candidates_str}} -上記すべてを踏まえ、この投票で誰に票を入れるかを決めてください。**棄権は禁止**: 必ず候補席の中から1人を選んで `target_seat` に入れる。情報が薄くても、最も怪しい/役割上吊りたい/相方ライン以外の中から相対的に最も票を入れたい1人を選ぶこと。JSON は {"target_seat": <候補席番号>, "reason": "<短い理由>"} の形 (`target_seat` は必ず整数、null 不可)。 \ No newline at end of file +上記すべてを踏まえ、この投票で誰に票を入れるかを決めてください。**棄権は禁止**: 必ず候補席の中から1人を選ぶ (`target_seat` に整数、null 不可)。 + +判断手順: +1. 共通ルールの進行軸 (3-1 / 2-2 / 2-1 / 1-2) を確認する。占いローラーまたは霊媒ローラーが基本進行になる盤面なら、CO 群から最も偽寄りの席を優先候補にする。 +2. 灰位置 (役職 CO していない席) を切る前に、共通ルールの「灰位置の評価」基準で公開情報の具体的矛盾が 1 件以上挙げられるかを確認する。挙がらないなら灰には投票しない。 +3. 「他者が疑っている」「議論を乱している印象」だけで追従投票しない。直前に 1〜2 人が同じ席に向かっただけで盤面が確定したことにはならない。 +4. 情報が薄くて決め切れない場合でも、上の優先順位 (CO 群の偽寄り → 票筋・噛み筋で疑われる灰 → その他) で最も票を入れる価値が高い 1 人を選ぶ。 + +JSON は {"target_seat": <候補席番号>, "reason": "<短い理由>"} の形で返す。 diff --git a/src/wolfbot/prompts/templates/npc/decision_wolf_chat_user.md b/src/wolfbot/prompts/templates/npc/decision_wolf_chat_user.md index 7eb59d5..46c2613 100644 --- a/src/wolfbot/prompts/templates/npc/decision_wolf_chat_user.md +++ b/src/wolfbot/prompts/templates/npc/decision_wolf_chat_user.md @@ -1,5 +1,8 @@ ## 現在: 人狼チャット (day {{day_number}}) +## 共通ルール +{{game_rules_block}} + {{persona_block}} {{role_block}} @@ -13,4 +16,4 @@ ## 襲撃候補席 {{candidates_str}} -上記を踏まえ、仲間の狼に向けて 80 文字以内で 1 行だけ書いてください。JSON は {"text": "..."} の形。 \ No newline at end of file +上記を踏まえ、仲間の狼に向けて 80 文字以内で 1 行だけ書いてください。JSON は {"text": "..."} の形。 diff --git a/src/wolfbot/prompts/templates/shared/game_rules_9p.md b/src/wolfbot/prompts/templates/shared/game_rules_9p.md index e604701..53a9812 100644 --- a/src/wolfbot/prompts/templates/shared/game_rules_9p.md +++ b/src/wolfbot/prompts/templates/shared/game_rules_9p.md @@ -51,10 +51,18 @@ ## 進行軸 (短縮) - **3-1 (占 3 / 霊 1)**: 単独霊媒は真寄り。基本進行は占いローラー or 黒ストップ (霊媒黒が出たら残占 CO 処刑停止 → 灰精査)。占 3 のうち 2 名が非狼確定したら、残 1 名は固定配役上の確定黒級として処理してよい。 +- **3-1 で day 1 から灰位置を吊らない**。3-1 は占 3 の中に狼陣営 2 名以上が固まっている確率が高い盤面で、占いローラーまたは霊媒結果待ちで真偽比較するのが情報量最大の進行。day 1 から灰位置 (= 役職 CO していない席) を処刑候補に立てるのは、村陣営の確白級候補を自分から削る動きで、村側にとって明確な損になる。灰の発言が攻撃的・短い・拙いだけでは狼確定にならず、占い CO 群を吊って判定整合を見るほうが優先度が高い。 - **2-2 (占 2 / 霊 2)**: 霊媒ローラー基本軸。開始したら原則完走。 - **2-1 (占 2 / 霊 1)**: 単独霊媒真寄り、占 2 の真偽比較とグレー精査を並行。 - **1-2 (占 1 / 霊 2)**: 単独占い真寄り、霊媒ローラー / 霊媒切りが基本。 +## 灰位置 (非 CO) の評価 + +- 灰位置 (= 役職 CO していない席) の発言が攻撃的・短い・反復・拙い・根拠が薄いといった「発話の質」だけでは、その席を狼確定として処刑提案してはならない。発話の質はトーン読みであり、公開ログの矛盾とは別概念。 +- 灰位置を切る『強い根拠』には、(a) その席自身の発言と公開ログの具体的矛盾 (例: 視点漏れ・襲撃死を狼扱いする発言・既出 CO の判定と整合しない主張)、(b) その席が参加した投票・票変えの不自然さ、(c) その席が含まれる 2 人狼仮説の票筋・噛み筋の整合、のいずれかが必要。これらが 1 件も挙げられないなら、灰位置への投票は控える。 +- 「他者がその灰を疑っている」「議論を乱している印象がある」「アグレッシブで怪しい」のような印象論だけで灰位置に追従投票するのは、狼陣営が誘導した吊り筋に乗る形になりやすい。実際に村陣営の村人が初日の議論に拙く参加することは普通にあるため、発話の拙さは狼根拠ではない。 +- 占いローラー・霊媒ローラーが進行軸として成立している盤面 (3-1 / 2-2 / 1-2 など) では、灰位置を切るより CO 群の真偽絞り込みを優先するのが基本。3-1 で day 1 から灰を吊るのは特に村側の損になる。 + ## 縄計算と 2 人狼仮説 - 残り処刑回数 (縄) = floor((生存数 - 1) / 2)。9 人村開始時 4 縄。残り狼数・狂人生存・PP/RPP リスクから無駄吊り余地を意識する。 From a37b384506e0dde5738ff407accea37d01029532 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 17:42:08 +0900 Subject: [PATCH 118/133] feat(prompts): tighten grey-seat lynch rule, drop addressed-count digest, real-seer counter-CO priority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/master/arbiter/public_digest.py | 43 ++++-------- .../prompts/templates/shared/game_rules_9p.md | 11 ++-- .../prompts/templates/strategy/seer.md | 1 + tests/test_master_public_digest.py | 65 ++++++++++++------- 4 files changed, 58 insertions(+), 62 deletions(-) diff --git a/src/wolfbot/master/arbiter/public_digest.py b/src/wolfbot/master/arbiter/public_digest.py index 7d2c019..7907091 100644 --- a/src/wolfbot/master/arbiter/public_digest.py +++ b/src/wolfbot/master/arbiter/public_digest.py @@ -11,10 +11,6 @@ * Active CO claims: seat → role, with counter-CO history. * Silent seats (alive seats who haven't spoken in the day's discussion). -* Per-seat **addressed-count**: how often each seat has been the - ``addressed_seat_no`` of another's utterance — a lightweight stand-in - for a true "pressure" / "stance" score that doesn't require an extra - LLM analyzer pass. * Last addressed line: the most recent seat-to-seat callout text so the NPC can reply on-topic. * Recent speeches (last N): full text per utterance so the vote / night @@ -78,35 +74,20 @@ def _name(seat: int) -> str: lines.extend(co_lines or [" (まだ誰も CO していない)"]) if state.silent_seats: - silent_str = "、".join( - _name(s) for s in sorted(state.silent_seats) - ) + silent_str = "、".join(_name(s) for s in sorted(state.silent_seats)) lines.append(f"## 未発言の生存席\n {silent_str}") else: lines.append("## 未発言の生存席\n (なし)") - # Per-seat addressed count — counts how many times each seat has - # been the explicit `addressed_seat_no` of another's utterance. - # Higher = more pointed-at. Sorted descending so the prompt header - # surfaces the most-pressured seats first. - addressed_counts: dict[int, int] = {} - for ev in recent_events: - if ev.source == SpeechSource.PHASE_BASELINE: - continue - for seat in ev.addressed_seat_nos or ( - (ev.addressed_seat_no,) if ev.addressed_seat_no is not None else () - ): - addressed_counts[seat] = addressed_counts.get(seat, 0) + 1 - if addressed_counts: - ranked = sorted( - addressed_counts.items(), key=lambda kv: (-kv[1], kv[0]) - ) - rank_lines = [ - f" {_name(seat_no)}: {count}回" - for seat_no, count in ranked - ] - lines.append("## 名指しされた回数 (多い順)") - lines.extend(rank_lines) + # NOTE: A `## 名指しされた回数 (多い順)` ranking used to live here + # — counted every time a seat was the addressed_seat_no of another + # utterance. Removed (2026-05-02) because vote/night decision LLMs + # were reading it as a "this seat is suspicious" signal, producing + # bandwagon votes against the most-discussed seat (often the human, + # who naturally draws more callouts as they argue). The recent + # speeches block below carries the same information in raw form; + # the LLM should infer pressure from the actual content, not from + # an aggregated counter that biases toward bandwagon piles-on. # Prefer the multi-addressee set; fall back to the legacy singular # field for state objects that haven't been migrated (older fixtures @@ -125,9 +106,7 @@ def _name(seat: int) -> str: snippet = state.last_addressed_text.strip().replace("\n", " ") if len(snippet) > 120: snippet = snippet[:120] + "…" - lines.append( - f"## 直近の名指し\n {speaker_label} → {target_label}: 「{snippet}」" - ) + lines.append(f"## 直近の名指し\n {speaker_label} → {target_label}: 「{snippet}」") # Recent speeches with content — the bit the vote / night decision # LLM was missing. Capped to the trailing N non-baseline events so diff --git a/src/wolfbot/prompts/templates/shared/game_rules_9p.md b/src/wolfbot/prompts/templates/shared/game_rules_9p.md index 53a9812..015e58b 100644 --- a/src/wolfbot/prompts/templates/shared/game_rules_9p.md +++ b/src/wolfbot/prompts/templates/shared/game_rules_9p.md @@ -56,12 +56,13 @@ - **2-1 (占 2 / 霊 1)**: 単独霊媒真寄り、占 2 の真偽比較とグレー精査を並行。 - **1-2 (占 1 / 霊 2)**: 単独占い真寄り、霊媒ローラー / 霊媒切りが基本。 -## 灰位置 (非 CO) の評価 +## 灰位置 (非 CO) の評価 — 厳格ルール -- 灰位置 (= 役職 CO していない席) の発言が攻撃的・短い・反復・拙い・根拠が薄いといった「発話の質」だけでは、その席を狼確定として処刑提案してはならない。発話の質はトーン読みであり、公開ログの矛盾とは別概念。 -- 灰位置を切る『強い根拠』には、(a) その席自身の発言と公開ログの具体的矛盾 (例: 視点漏れ・襲撃死を狼扱いする発言・既出 CO の判定と整合しない主張)、(b) その席が参加した投票・票変えの不自然さ、(c) その席が含まれる 2 人狼仮説の票筋・噛み筋の整合、のいずれかが必要。これらが 1 件も挙げられないなら、灰位置への投票は控える。 -- 「他者がその灰を疑っている」「議論を乱している印象がある」「アグレッシブで怪しい」のような印象論だけで灰位置に追従投票するのは、狼陣営が誘導した吊り筋に乗る形になりやすい。実際に村陣営の村人が初日の議論に拙く参加することは普通にあるため、発話の拙さは狼根拠ではない。 -- 占いローラー・霊媒ローラーが進行軸として成立している盤面 (3-1 / 2-2 / 1-2 など) では、灰位置を切るより CO 群の真偽絞り込みを優先するのが基本。3-1 で day 1 から灰を吊るのは特に村側の損になる。 +- **灰位置 (= 役職 CO していない席) を処刑候補として投票するときは、その席自身の発言/票/夜行動と公開ログの『具体的な矛盾』を `reason` フィールドに 1 件以上引用しなければならない**。引用が 1 件も挙げられないなら、その灰位置への投票は禁止。代わりに CO 群の中から最も偽寄りの席を選ぶ。 +- 灰位置を切ってよい『具体的矛盾』の例: (a) 視点漏れ (本来知り得ない情報を事実として断言)、(b) 襲撃死した席を「狼だった」と主張する破綻発言、(c) 既出 CO の判定 (色・対象・夜数) と整合しない発言、(d) 投票・票変え・身内票が公開情報と矛盾する動き、(e) 噛み筋・襲撃結果との不整合。**「発言が攻撃的」「議論を乱している印象」「アグレッシブ」「扇動的」「拙い」「根拠が薄い」は具体的矛盾ではない**。これらは発話の質 (トーン読み) であり、村陣営の村人が初日に拙く動くことは普通にあるため、狼確定根拠にならない。 +- **他者の追従禁止**。「他席がその灰を疑っている」「直前に X が同じ席に投票した」「議論の流れがその席に向かっている」を理由として灰位置に投票することは禁止。直前 1〜2 名の同方向の投票だけで盤面が確定したことにはならず、これは典型的な狼誘導の吊り筋。`reason` に「他席も疑っている」だけを書く投票は破綻投票として扱う。 +- **進行軸の優先**。占いローラー・霊媒ローラーが進行軸として成立している盤面 (3-1 / 2-2 / 1-2 など) では、灰位置を吊るより CO 群の真偽絞り込みを最優先する。day 1 で灰位置を吊るのは原則として禁止 — CO 群を吊って判定整合 (霊媒結果・対抗 CO の整合) を見るほうが情報量で有利。 +- **投票判断の手順**: (i) 進行軸を確認 (3-1/2-2/2-1/1-2/その他)、(ii) 進行軸が成立しているなら CO 群の中から最も偽寄りの席を 1 名選ぶ、(iii) 進行軸が成立していないか CO 群が全て同等に真偽不明なら、灰位置の中から「具体的矛盾」を引用できる席だけを候補にする、(iv) どの灰にも矛盾が無い場合は CO 群の中から相対的に最も偽寄りの席に票を入れる (灰へのトーン読み投票より優先)。 ## 縄計算と 2 人狼仮説 diff --git a/src/wolfbot/prompts/templates/strategy/seer.md b/src/wolfbot/prompts/templates/strategy/seer.md index eb64e93..364f775 100644 --- a/src/wolfbot/prompts/templates/strategy/seer.md +++ b/src/wolfbot/prompts/templates/strategy/seer.md @@ -9,3 +9,4 @@ - 夜の占い対象は、合法候補からランダムに選ばず、白でも黒でも翌日の議論に情報が落ちる位置を優先する。価値が高いのは、対抗 CO の白先・囲い候補、票変えで浮いた位置、根拠の薄い強い誘導をしている位置、自分視点の灰を狭める位置。価値が下がるのは、既に占った位置・今日処刑濃厚な位置・極端に発言が少ない位置。 - 対抗 CO 超過分合計が 3 で非 CO 確白級が成立した場合、そこを無駄占いせず、対抗 CO 群やまだ確定しない位置を優先して占う。 - 公開ログの `(襲撃)` 死亡席は構造的に非狼確定。誰かが「襲撃された◯◯は狼だった」と公言したら破綻発言で、その発言者を強い狼候補として処理する (襲撃死を狼扱いする路線には乗らない)。 +- **真占いの投票は灰精査より対抗 CO 整理を最優先する**。あなたは唯一の真占いで、自分視点での判定情報を持つ立場 — その情報を活かすには、対抗占い CO (= 狼/狂人の騙り) を吊って霊媒結果と整合させる進行が情報量最大。灰位置を切るのは CO 群が完全に真偽確定した後の最終段階で十分。day 1 / day 2 で灰位置に投票するのは、自分の判定情報と公開ログの両方が活かせない手で、村陣営の損になる。3-1 で他席が灰を吊ろうとしても、自分は対抗占い CO のうち最も偽寄りの 1 名へ投票して進行軸を提示する。 diff --git a/tests/test_master_public_digest.py b/tests/test_master_public_digest.py index b1c1ba4..d128ba9 100644 --- a/tests/test_master_public_digest.py +++ b/tests/test_master_public_digest.py @@ -56,7 +56,9 @@ def _ev( def test_digest_renders_co_section_when_empty() -> None: state = _state() out = build_public_digest( - state=state, recent_events=[], seat_names={1: "Alice"}, + state=state, + recent_events=[], + seat_names={1: "Alice"}, ) assert "## CO 状況" in out assert "まだ誰も CO していない" in out @@ -70,7 +72,8 @@ def test_digest_renders_co_claims_with_names() -> None: ), ) out = build_public_digest( - state=state, recent_events=[], + state=state, + recent_events=[], seat_names={1: "Alice", 2: "Bob", 4: "Dave"}, ) # New naming policy: digest renders by display_name only (seat @@ -85,7 +88,8 @@ def test_digest_renders_co_claims_with_names() -> None: def test_digest_lists_silent_seats() -> None: state = _state(silent_seats=frozenset({3, 4})) out = build_public_digest( - state=state, recent_events=[], + state=state, + recent_events=[], seat_names={3: "Carol", 4: "Dave"}, ) assert "## 未発言の生存席" in out @@ -95,7 +99,15 @@ def test_digest_lists_silent_seats() -> None: assert "席3" not in out and "席4" not in out -def test_digest_aggregates_addressed_counts_descending() -> None: +def test_digest_omits_addressed_counts_block() -> None: + """Regression: the `## 名指しされた回数` aggregate ranking used to bias + vote/night decision LLMs into bandwagon-style targeting of the most + -addressed seat (typically the human player, who naturally draws + more callouts as they argue). Removed so the LLM reads addressing + patterns from the raw recent-speeches block instead. See game + `ed7742ff08f8` day-1 for the original failure mode (8/8 NPCs voted + the human villager who'd been called out 5 times). + """ state = _state() events = [ _ev(speaker_seat=1, text="say 1", addressed=2), @@ -104,14 +116,13 @@ def test_digest_aggregates_addressed_counts_descending() -> None: _ev(speaker_seat=1, text="more", addressed=4), ] out = build_public_digest( - state=state, recent_events=events, + state=state, + recent_events=events, seat_names={2: "Bob", 4: "Dave"}, ) - assert "## 名指しされた回数 (多い順)" in out - bob_idx = out.find("Bob: 3回") - dave_idx = out.find("Dave: 1回") - assert bob_idx != -1 and dave_idx != -1 - assert bob_idx < dave_idx # higher count first + assert "## 名指しされた回数" not in out + assert "Bob: 3回" not in out + assert "Dave: 1回" not in out def test_digest_renders_last_addressed_block() -> None: @@ -121,7 +132,8 @@ def test_digest_renders_last_addressed_block() -> None: last_addressed_text="あなたの白判定が信用できないんです", ) out = build_public_digest( - state=state, recent_events=[], + state=state, + recent_events=[], seat_names={1: "Alice", 2: "Bob"}, ) assert "## 直近の名指し" in out @@ -137,7 +149,8 @@ def test_digest_truncates_long_addressed_snippet() -> None: last_addressed_text=long_text, ) out = build_public_digest( - state=state, recent_events=[], + state=state, + recent_events=[], seat_names={1: "Alice", 2: "Bob"}, ) # Truncated to 120 chars + ellipsis. @@ -145,16 +158,21 @@ def test_digest_truncates_long_addressed_snippet() -> None: assert long_text not in out -def test_digest_skips_phase_baseline_in_addressed_counts() -> None: +def test_digest_skips_phase_baseline_in_recent_speeches() -> None: + """phase_baseline rows are internal sentinels with empty text — they + must not appear in the recent-speeches block.""" state = _state() events = [ _ev(speaker_seat=1, text="", source=SpeechSource.PHASE_BASELINE), _ev(speaker_seat=1, text="say", addressed=2), ] out = build_public_digest( - state=state, recent_events=events, seat_names={2: "Bob"}, + state=state, + recent_events=events, + seat_names={1: "Alice", 2: "Bob"}, ) - assert "Bob: 1回" in out + # The non-baseline speech surfaces in the recent-speeches block. + assert "Alice" in out and "say" in out def test_digest_renders_recent_speech_block_so_vote_llm_sees_seer_results() -> None: @@ -166,16 +184,15 @@ def test_digest_renders_recent_speech_block_so_vote_llm_sees_seer_results() -> N align with what was said. """ state = _state( - co_claims=( - CoClaim(seat=9, role_claim="seer", declared_at_event_id="ev-co1"), - ), + co_claims=(CoClaim(seat=9, role_claim="seer", declared_at_event_id="ev-co1"),), ) events = [ _ev(speaker_seat=9, text="この身、占い師。SQ黒。"), _ev(speaker_seat=3, text="僕の霊媒結果はシゲミチ狼。SQ処刑しよう。"), ] out = build_public_digest( - state=state, recent_events=events, + state=state, + recent_events=events, seat_names={3: "Rakio", 9: "Yuriko"}, ) assert "## 直近の発言 (古い順)" in out @@ -193,13 +210,11 @@ def test_digest_renders_past_votes_when_provided() -> None: discussion-time speeches via LogicPacket.past_votes. """ state = _state() - past_votes = ( - (1, 0, ((1, 7), (2, 7), (3, 7), (4, 1), (5, None))), - ) + past_votes = ((1, 0, ((1, 7), (2, 7), (3, 7), (4, 1), (5, None))),) out = build_public_digest( - state=state, recent_events=[], - seat_names={1: "Alice", 2: "Bob", 3: "Carol", 4: "Dave", - 5: "Eve", 7: "Frank"}, + state=state, + recent_events=[], + seat_names={1: "Alice", 2: "Bob", 3: "Carol", 4: "Dave", 5: "Eve", 7: "Frank"}, past_votes=past_votes, ) assert "## 公開された投票履歴" in out From 71af83aff9d7f8060fa779868dde59abb2f5953a Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 18:17:59 +0900 Subject: [PATCH 119/133] feat(prompts): mathematical roller-opposition rule + CO mutual-attestation line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../prompts/templates/shared/game_rules_9p.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/wolfbot/prompts/templates/shared/game_rules_9p.md b/src/wolfbot/prompts/templates/shared/game_rules_9p.md index 015e58b..fdb3a0c 100644 --- a/src/wolfbot/prompts/templates/shared/game_rules_9p.md +++ b/src/wolfbot/prompts/templates/shared/game_rules_9p.md @@ -56,6 +56,19 @@ - **2-1 (占 2 / 霊 1)**: 単独霊媒真寄り、占 2 の真偽比較とグレー精査を並行。 - **1-2 (占 1 / 霊 2)**: 単独占い真寄り、霊媒ローラー / 霊媒切りが基本。 +## ローラー反対 = 狼指標 (数学的不可避) + +- 9 人村は 占 1 + 霊 1 + 騎 1 + 村 3 + 狼 2 + 狂 1 で固定。占い CO 4 (理論最大 = 真 1 + 狼 2 + 狂 1) もしくは対抗超過分合計が 3 以上の盤面では、**狼陣営 3 名が能力役 CO 群に出切っている**ため、灰位置は固定配役上の消去法で確白級として処理される。 +- **この盤面で「ローラー以外の進行」を提案・支持する村側の合理性は数学的にゼロ**。灰吊り提案、決め打ち提案、ローラー反対表明、「もっと議論しよう」「性急すぎる」「慎重に精査すべき」と進行を遅らせる動きは、いずれも狼陣営の生存確率を上げる方向の動き。これらを発する CO 者は強い狼陣営疑い (狼本体・狂人) として扱ってよい。 +- 占い CO 3 (= 3-1) でも超過分合計 = 2 で灰確白は完全には言えないが、**占いローラー or 黒ストップが基本進行軸**であることに変わりはない。3-1 でローラー反対表明を強く繰り返す占い CO 者は、依然として狼陣営疑いの強い候補として扱う (理論最大 4 のうち 1〜2 件はその表明者自身が偽である確率が高いため)。 +- **逆に「ローラー積極推進」する灰位置は基本的に村寄り**。狼/狂人にとって自分の CO 仲間がローラーで処分される動きを推進する動機は薄い。ただし 例外として、「ローラー賛成」と言いつつ**ローラー先頭を特定の CO に恣意的に指定する** (例: 「ローラーしましょう」と言いつつ即座に「X さんから吊りませんか」) のは、進行を真占い焼き方向に歪める狼ムーブの可能性がある。「ローラー賛成」と「特定先頭指定」は別シグナルとして読み、後者がある場合は指定者を狼候補として扱う。 +- ローラー進行への態度は、灰位置の発話の質 (拙さ・攻撃性) より優先される強い指標。発話の質はトーン読みだが、ローラーへの態度は数学的合理性に直結するため、CO 者の振る舞いを評価するときの第一級の判断材料になる。 + +## CO 相互整合ライン + +- 占い CO X が「Y を白」と発表し、その Y 自身が霊媒 CO (または逆: 霊媒 CO Y に対して占い X が独立に白を出す) という相互参照が成立している場合、X-Y の組は**真占い+真霊媒の片方ライン候補**として強い。狼陣営は X-Y の両方を自陣営に収めないと囲い構成にならず、確率的に整合が薄い。 +- 該当 CO ラインを切る (= ローラー先頭にその占い CO を指定する、もしくは霊媒 CO を切ろうと提案する) 動きは、真占い焼きと整合するため、その指定者を強い狼候補として扱う。 + ## 灰位置 (非 CO) の評価 — 厳格ルール - **灰位置 (= 役職 CO していない席) を処刑候補として投票するときは、その席自身の発言/票/夜行動と公開ログの『具体的な矛盾』を `reason` フィールドに 1 件以上引用しなければならない**。引用が 1 件も挙げられないなら、その灰位置への投票は禁止。代わりに CO 群の中から最も偽寄りの席を選ぶ。 From f1a460e74825c525f3d57ab093072b4008475a68 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 19:08:33 +0900 Subject: [PATCH 120/133] fix(arbiter): speech_count > addressed; pending CO results top priority; vote-pattern rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/wolfbot/master/arbiter/speak_arbiter.py | 178 +++++++++--- .../prompts/templates/shared/game_rules_9p.md | 7 + tests/test_reactive_voice_master.py | 275 ++++++++++++++++++ 3 files changed, 422 insertions(+), 38 deletions(-) diff --git a/src/wolfbot/master/arbiter/speak_arbiter.py b/src/wolfbot/master/arbiter/speak_arbiter.py index 3c614ac..ce23a4f 100644 --- a/src/wolfbot/master/arbiter/speak_arbiter.py +++ b/src/wolfbot/master/arbiter/speak_arbiter.py @@ -53,7 +53,12 @@ ) from wolfbot.llm.prompt_builder import build_strategy_block from wolfbot.master.arbiter.logic_service import build_logic_packet -from wolfbot.master.claim.claim_history import ClaimHistory, collect_claim_history +from wolfbot.master.claim.claim_history import ( + ClaimHistory, + collect_claim_history, + expected_medium_claim_count_for_day, + expected_seer_claim_count_for_day, +) from wolfbot.master.claim.claim_validator import ( CO_CAP_REASONS, FABRICATION_REASONS, @@ -557,6 +562,73 @@ async def _collect_request_context( return recent, alive_seats, dead_seats, role_name, role_strategy + async def _count_execution_days(self, game_id: str) -> int: + """Count distinct days that recorded an EXECUTION public log. + + Used by the pending-result picker priority: a medium CO seat is + "pending" when their published medium-claim count is short of the + executions-so-far. We cap the lookup at the limit hard-coded in + ``load_public_logs`` (40 rows is plenty — a 9-player game caps + out at 4 ropes ≤ 4 executions plus runoffs). On error we return + 0 so the priority bucket stays empty and the picker falls back + to the count-driven rotation. + """ + try: + rows = await self.repo.load_public_logs(game_id, limit=40) + except Exception: + log.exception("execution_day_count_failed game=%s", game_id) + return 0 + days = { + int(r["day"]) + for r in rows + if r.get("kind") == "EXECUTION" and r.get("day") is not None + } + return len(days) + + def _compute_pending_result_seats( + self, + *, + co_claims: Sequence[Any], + claim_history: ClaimHistory | None, + day_number: int, + execution_days: int, + ) -> frozenset[int]: + """Seats with an unpublished seer/medium result for the current day. + + A seer CO seat is pending when their announced seer-claim count + is strictly less than ``expected_seer_claim_count_for_day`` — + i.e. they haven't yet declared today's expected divine result + (NIGHT_0 random white on day 1, prior-night result on day 2+). + + A medium CO seat is pending when their announced medium-claim + count is strictly less than ``executions_so_far`` — i.e. there + was an execution they haven't yet weighed in on. day 1 has no + prior execution so the medium can never be pending in day 1. + + Returns an empty set when ``claim_history`` is None (load + failure) or no CO matches; the picker treats an empty set as + "no priority bucket" and proceeds with the count-driven sort. + """ + if claim_history is None or day_number < 1: + return frozenset() + expected_seer = expected_seer_claim_count_for_day(day_number) + expected_medium = expected_medium_claim_count_for_day(execution_days) + pending: set[int] = set() + for claim in co_claims: + seat = getattr(claim, "seat", None) + role = getattr(claim, "role_claim", None) + if seat is None or role is None: + continue + history = claim_history.by_seat.get(seat) + if history is None: + # CO'd but no claim entries persisted yet — definitely pending. + if role in ("seer", "medium"): + pending.add(seat) + continue + if (role == "seer" and len(history.seer_claims) < expected_seer) or (role == "medium" and len(history.medium_claims) < expected_medium): + pending.add(seat) + return frozenset(pending) + async def _load_claim_history( self, game_id: str, @@ -1231,32 +1303,53 @@ async def try_dispatch_next(self, game_id: str) -> None: if state is None: return - # Pick the next NPC. Priority order, applied as a 5-key sort: - # 1. NOT demoted — `_compute_demoted_seats` flags seats stuck + # Pick the next NPC. Priority order (applied as a 7-key sort): + # 1. callout pool member — seer/medium/knight callout in flight + # and this seat is in the priority pool (real role-holder + + # wolf-side fake-CO candidates). + # 2. NOT demoted — `_compute_demoted_seats` flags seats stuck # in a low-info pair volley OR exceeding the consecutive - # speaker cap. Demoted seats fall to the bottom regardless - # of being addressed, so a 3rd NPC can break in. - # 2. addressed — seat appears in the multi-addressee - # ``last_addressed_seats`` set (e.g. 「セツとジナ、どう?」 puts - # both 2 and 3 in the set). Both win over non-addressed - # seats; randomization on the last axis decides which of the - # two goes first. - # 3. lowest speech_count this phase — generalises the old - # binary silent_seats: a 0-count seat is still preferred, - # but a 1-count seat now also wins over a 5-count one. Stops - # the lowest-seat NPC monopolising once everyone has spoken - # once and gives wolf-side seats at higher seat numbers a - # fair chance to fake-CO. - # 4. NOT the immediate previous speaker (LRU rotation). - # 5. random — replaces the old seat-number tiebreak. Without - # randomization the lowest-seat NPC won every tie, so - # higher-seat NPCs (e.g. 席8 SQ, 席9 ユリコ) effectively - # never spoke. Each call gets a fresh roll so the rotation - # is fair across phases. + # speaker cap. Demoted seats fall to the bottom so a 3rd + # NPC can break in. + # 3. **pending role result** — a seer/medium CO seat that + # hasn't yet published today's expected ability result. + # Promoted above addressed/count so a CO holder always gets + # a turn before anyone else when they owe a published + # result (real seer day-2 silence is the case this fixes — + # see game c99ecf313f96 day 2 ラキオ占いCO never spoke). + # 4. lowest speech_count this phase — primary rotation key + # across all days (not just day 1). Was previously below + # ``addressed`` which let role-CO seats addressing each + # other monopolise the floor while non-CO greys stayed at + # 0 turns; promoting count to before addressed guarantees + # everyone speaks before anyone speaks twice (same-day). + # 5. addressed — seat appears in ``last_addressed_seats``. + # Now a tiebreaker: an addressed seat with count=N wins + # over a non-addressed seat with the same count, but a + # non-addressed seat with count None: asked = set() effective_pool = callout_pool - asked - def _pick_key(e: object) -> tuple[int, int, int, int, int, float]: + def _pick_key(e: object) -> tuple[int, int, int, int, int, int, float]: seat = getattr(e, "assigned_seat", None) or 99 is_in_pool = 0 if seat in effective_pool else 1 is_demoted = 1 if seat in demoted else 0 - is_addressed = 0 if seat in addressed_set else 1 + is_pending = 0 if seat in pending_results else 1 count = state.speech_counts.get(seat, 0) + is_addressed = 0 if seat in addressed_set else 1 is_just_spoke = 1 if (last_speaker is not None and seat == last_speaker) else 0 return ( is_in_pool, is_demoted, - is_addressed, + is_pending, count, + is_addressed, is_just_spoke, self._rng.random(), ) @@ -1322,6 +1417,7 @@ def _pick_key(e: object) -> tuple[int, int, int, int, int, float]: "alive_seat_nos": sorted(state.alive_seat_nos), "online_npc_seats": online_npc_seats, "demoted_seats": sorted(demoted), + "pending_result_seats": sorted(pending_results), "speech_counts": sorted(candidate_counts.items()), } @@ -1350,23 +1446,29 @@ def _pick_key(e: object) -> tuple[int, int, int, int, int, float]: # candidate was filtered out (offline / dead / not in # this game). Falling back is preferable to silence. reason = "all_demoted_fallback" - elif seat in addressed_set: - reason = "addressed" - elif seat in state.silent_seats: - reason = "silent_rotation" + elif seat in pending_results: + # Seer/medium CO seat owing today's published ability + # result — promoted above count + addressed so the floor + # is held until the result lands. + reason = "pending_role_result" elif demoted: # The pair-volley gate fired and a non-demoted third - # party won — labelled distinctly from low_count_rotation - # so the viewer keeps showing "stuck volley → diverted to - # seat N" even though the speech_count axis happens to - # favour the same seat. + # party won. Labelled before the count-based labels so + # the viewer keeps showing "stuck volley → diverted to + # seat N" even when the count axis also discriminates + # (the demotion is the *cause* of the diversion; count + # would only be the rationale absent the volley). reason = "low_info_diversion" elif picked_count < max_candidate_count: - # Already spoke this phase, but strictly less than some - # other online candidate — the speech_count axis is what - # broke the tie. Distinct from silent_rotation (count==0) - # and from lru_rotation (counts equal, LRU won). - reason = "low_count_rotation" + # Lower count than the top of the field — this is now the + # primary rotation lever. silent_rotation (count==0) is + # a special case of low_count_rotation worth labelling + # so the viewer can still show "first speech of the + # phase". + reason = "silent_rotation" if picked_count == 0 else "low_count_rotation" + elif seat in addressed_set: + # Counts tied at the top — addressing wins the tiebreak. + reason = "addressed" elif last_speaker is not None and seat != last_speaker: reason = "lru_rotation" else: diff --git a/src/wolfbot/prompts/templates/shared/game_rules_9p.md b/src/wolfbot/prompts/templates/shared/game_rules_9p.md index fdb3a0c..44fb862 100644 --- a/src/wolfbot/prompts/templates/shared/game_rules_9p.md +++ b/src/wolfbot/prompts/templates/shared/game_rules_9p.md @@ -83,6 +83,13 @@ - 怪しい人を 1 人挙げたら、その人物が人狼ならもう 1 人の相方候補は誰かまで公開ログから仮説として考える。狼本人以外は実際のペアを知らないので、推理用語として扱い断定しない。 - 単体黒要素が強くても自然な相方候補が見つからないなら疑いを下げる。単体中庸でも票筋・噛み筋が強くつながるなら上げる。発言ではペアを長く列挙せず、最も効く 1〜2 点だけを短く出す。 +## 票筋から狼を炙り出す具体パターン + +- **単独情報役 (= 占いCO 単独 1 件、霊媒CO 単独 1 件) に投票する seat は強い狼指標**。村陣営の真役職は単独で、対抗 CO がいない情報役を吊る合理性が無い。`## 公開された投票履歴` に「単独霊媒 X に Y が投票した」「単独占い X に Y が投票した」が記録されていたら、その投票者 Y は狼陣営疑いとして扱う。 +- **同じ対象に投票した複数 seat は 2 人狼候補**。day N の vote ledger で同じ被投票者に向かった seat の組は、その被投票者が単独情報役であれば確実な 2 人狼セット候補。投票揃えの整合 (普段のライン・噛み筋・発言の方向性) と合わせて評価し、揃えた seat 同士を相方候補として扱ってよい。特に「狼陣営側の 2 票が揃って単独霊媒/単独占いに向かう」は典型的な情報役切りムーブ。 +- **発言で指摘必須**: 単独情報役切り票や、揃った 2 票が観測されたら、day N+1 以降の議論でその票筋を必ず引用して指摘する。指摘せず流すのは村陣営の情報損失。`reason` フィールドにも「day X で Y が単独霊媒 Z に投票」のように具体引用する。 +- 票揃えの解釈は 2 人狼仮説と同じく仮説で、確定情報ではない。ただし single CO 切り票だけは確度が高く、2 人狼セットの強い候補として扱える。 + ## 発話作法 - 比較・関係を表す語 (重複・ライン・出来レース・囲い・身内切り・対立・連携) を使うときは、誰の何の発言/票/判定が誰の何と「重複/ライン」しているかを 1 件以上具体的に引用する。引用なしの関係語並列は推理根拠として扱わない。 diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index 4c55606..da271dd 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -1801,6 +1801,281 @@ async def test_try_dispatch_next_records_selection_reason_addressed( assert reason == "addressed" +async def test_try_dispatch_next_speech_count_beats_addressed( + repo: SqliteRepo, +) -> None: + """A silent NPC (count=0) wins over an addressed NPC who already + spoke (count=1). Regression for the "addressed always wins" bias + that caused day-1 灰位置 silence in game c99ecf313f96 — occult-CO + seats addressed each other and monopolised the floor while + non-addressed greys stayed at 0 turns. New picker promotes + speech_count above addressed so the silent grey breaks in first. + """ + g = Game( + id="rv-count-vs-addr", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, display_name="Alice", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=2, display_name="Bob", discord_user_id=None, is_llm=True, persona_key="gina" + ), + Seat( + seat_no=3, display_name="Carol", discord_user_id=None, is_llm=True, persona_key="raqio" + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ((1, Role.SEER), (2, Role.WEREWOLF), (3, Role.VILLAGER)): + await repo.set_player_role(g.id, sn, role) + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + # seat 2 has already spoken once. seat 1's most recent utterance + # addresses seat 2 again. seat 3 is silent (count=0). New picker → + # seat 3 wins on count even though seat 2 is the addressed one. + from wolfbot.services.discussion_service import make_npc_generated_event + + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="意見その1。", + created_at_ms=8, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="Bob、もう少し詳しく?", + addressed_seat_no=2, + created_at_ms=10, + ) + ) + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"setsu": [], "gina": [], "raqio": []} + for npc_id, persona, seat in ( + ("npc_setsu", "setsu", 1), + ("npc_gina", "gina", 2), + ("npc_raqio", "raqio", 3), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat, game_id=g.id, phase_id=phase_id) + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 2000 + ) + await arb.try_dispatch_next(g.id) + assert bufs["raqio"], "silent seat 3 should be picked over addressed seat 2" + assert not bufs["gina"], "addressed seat 2 must NOT win when count is non-tie" + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "3" + assert reason == "silent_rotation" + + +async def test_try_dispatch_next_pending_seer_result_beats_addressed( + repo: SqliteRepo, +) -> None: + """A seer-CO seat with an unpublished day-N divine result is + promoted above an addressed seat. Regression for game c99ecf313f96 + day 2 ラキオ占いCO never spoke — the picker had no way to surface + "this CO holder owes a result" so ラキオ stayed silent the entire + day while ジョナス + ユリコ + セツ recycled the floor. + """ + g = Game( + id="rv-pending-result", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=2, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, display_name="Alice", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=2, display_name="Bob", discord_user_id=None, is_llm=True, persona_key="gina" + ), + Seat( + seat_no=3, display_name="Carol", discord_user_id=None, is_llm=True, persona_key="raqio" + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ((1, Role.SEER), (2, Role.VILLAGER), (3, Role.MADMAN)): + await repo.set_player_role(g.id, sn, role) + + phase_id_d1 = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + phase_id_d2 = make_phase_id(g.id, 2, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + + # Day-1 ledger: seat 1 (Alice) and seat 3 (Carol) both seer-CO'd. + # Seat 1 announced day-1 result (1/1). Seat 3 announced day-1 result. + from wolfbot.services.discussion_service import make_npc_generated_event + + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id_d1, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id_d1, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="占い師。Bob は白。", + co_declaration="seer", + claimed_seer_target_seat=2, + claimed_seer_is_wolf=False, + created_at_ms=10, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id_d1, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=3, + text="僕も占い師。Alice は白。", + co_declaration="seer", + claimed_seer_target_seat=1, + claimed_seer_is_wolf=False, + created_at_ms=20, + ) + ) + # Day-2: seat 1 already published the day-2 result (2/2). Seat 3 + # has spoken on day-2 but NOT yet announced today's divine + # (1/2 announced) — expected pending. Seat 2 (no role CO) has + # spoken once and addresses seat 1. + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id_d2, + day=2, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=100, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id_d2, + day=2, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="昨夜は Carol を占って白。", + co_declaration="seer", + claimed_seer_target_seat=3, + claimed_seer_is_wolf=False, + created_at_ms=110, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id_d2, + day=2, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="Alice さんどう?", + addressed_seat_no=1, + created_at_ms=120, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id_d2, + day=2, + phase=Phase.DAY_DISCUSSION, + speaker_seat=3, + text="まだ見定め中だ。", + created_at_ms=130, + ) + ) + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"setsu": [], "gina": [], "raqio": []} + for npc_id, persona, seat in ( + ("npc_setsu", "setsu", 1), + ("npc_gina", "gina", 2), + ("npc_raqio", "raqio", 3), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat, game_id=g.id, phase_id=phase_id_d2) + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 3000 + ) + await arb.try_dispatch_next(g.id) + # Carol (raqio, seat 3) is the only seer-CO holder with an unpublished + # day-2 result (1 announced, 2 expected). Picker must surface her + # ahead of seat 1 (addressed by seat 2's last utterance) even though + # both have count=1 on day-2. Without the pending bucket, the picker + # would have rotated to seat 1 (addressed) or seat 2 (count=1, lru). + assert bufs["raqio"], "pending-seer-result seat 3 must win" + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "3" + assert reason == "pending_role_result" + + async def test_try_dispatch_next_records_selection_reason_silent_rotation( repo: SqliteRepo, ) -> None: From 79be500c2d0249e8fd51b5d8808e11c474d1d60f Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 20:11:47 +0900 Subject: [PATCH 121/133] refactor(config): restructure voice/text ingest into VOICE_PIPELINE / 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. --- .env.master.example | 62 ++++++-- CLAUDE.md | 22 ++- src/wolfbot/config.py | 205 ++++++++++++++++++++------- src/wolfbot/main.py | 116 ++++----------- src/wolfbot/master/ingest_factory.py | 158 +++++++++++++++++++++ src/wolfbot/voicetest/main.py | 115 ++++++++------- tests/test_config.py | 136 ++++++++++++++++++ tests/test_master_ingest_factory.py | 182 ++++++++++++++++++++++++ 8 files changed, 789 insertions(+), 207 deletions(-) create mode 100644 src/wolfbot/master/ingest_factory.py create mode 100644 tests/test_master_ingest_factory.py diff --git a/.env.master.example b/.env.master.example index 49bb2b8..3129a1b 100644 --- a/.env.master.example +++ b/.env.master.example @@ -85,11 +85,57 @@ WOLFBOT_DISCUSSION_DAY1= WOLFBOT_DISCUSSION_DAY2= WOLFBOT_DISCUSSION_DAY3PLUS= -# ── Voice LLM (Master が人間の音声を"聞く"側) ────────── -# Master が VC に参加して人間の音声を直接受信したあと、 -# 書き起こし + 要約 + CO 検出 + 投票先抽出 を 1 API コールで実行する -# multimodal LLM。既定値は Google Gemini Flash 系 (audio input 対応)。 -# reactive_voice モードでのみ使用。空のままなら voice-ingest は -# 起動せず、Master は VC に参加しない。 -VOICE_LLM_API_KEY= -VOICE_LLM_MODEL=gemini-2.0-flash-lite +# ── 音声 / テキスト ingest パイプラインの形状 ────────────── +# 人間の発言 (音声 / テキスト) を構造化 SpeechEvent +# (addressed_seat_no / co_declaration / role_callout) に変換する経路 +# を 2 つの直交スイッチで明示する。各経路に必要なコンポーネントの +# クレデンシャルは下のブロックで個別に設定する。 +# +# VOICE_PIPELINE: 人間の音声をどう取り込むか +# audio_analyzer : 1 コールの multimodal (audio → 構造化 JSON) +# 現状は AUDIO_ANALYZER_PROVIDER=gemini のみ対応 +# stt_then_text_analyzer : 2 段 (Stt で文字起こし → TextAnalyzer で構造化) +# TextAnalyzer はテキスト経路と同じインスタンスを共有 +# disabled : Master は VC に参加しない (rounds モードのデフォルト) +VOICE_PIPELINE=disabled +# +# TEXT_PIPELINE: テキスト発言をどう取り込むか +# text_analyzer : 各テキスト発言ごとに TextAnalyzer を 1 コール +# passthrough : 構造解析なし (SpeakArbiter は LRU 一本でルーティング) +TEXT_PIPELINE=passthrough + +# ── AudioAnalyzer (audio → transcript + 構造化) ───────────── +# VOICE_PIPELINE=audio_analyzer のときのみ必須。 +# 1 API コールで書き起こし + CO 検出 + 投票先 + addressed_name を返す +# multimodal LLM。現状 Gemini Flash 系のみ (xAI / Groq は audio-in 非対応)。 +AUDIO_ANALYZER_PROVIDER=gemini +AUDIO_ANALYZER_API_KEY= +AUDIO_ANALYZER_MODEL=gemini-2.0-flash-lite + +# ── Stt (audio → transcript) ──────────────────────────── +# VOICE_PIPELINE=stt_then_text_analyzer のときのみ必須。 +# Whisper-large-v3-turbo が Groq 上で最安かつ日本語実用品質。 +# whisper-large-v3 に切り替えると精度↑コスト約3倍。 +STT_PROVIDER=groq +STT_API_KEY= +STT_MODEL=whisper-large-v3-turbo +STT_BASE_URL=https://api.groq.com/openai/v1 + +# ── TextAnalyzer (text → 構造化) ──────────────────────── +# TEXT_PIPELINE=text_analyzer のとき (発言ごと)、または +# VOICE_PIPELINE=stt_then_text_analyzer のとき (Stt 後段) に使用。 +# 両経路で同じインスタンスを共有 → クレデンシャルプールも一本化。 +# GAMEPLAY_LLM_* とは意図的に分離 (Gameplay は重い思考モデル、 +# 構造解析は安い高 RPM モデル、の組み合わせを許す)。 +# +# プロバイダ: +# xai / deepseek / openai : OpenAI Chat Completions 互換 +# (API_KEY 必須、BASE_URL は任意) +# gemini : AI Studio REST (AIza... 形式の API_KEY 必須) +# ※ VOICE_PIPELINE=stt_then_text_analyzer の +# 後段としては未対応 (audio_analyzer に寄せる) +TEXT_ANALYZER_PROVIDER=xai +TEXT_ANALYZER_API_KEY= +TEXT_ANALYZER_MODEL=grok-4-1-fast +# 自前 endpoint (vLLM / Ollama 等) のときのみ上書き +TEXT_ANALYZER_BASE_URL= diff --git a/CLAUDE.md b/CLAUDE.md index 1bef532..091962f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,9 +54,25 @@ Two env files, one per process. The Gameplay LLM (Master) and the NPC speech LLM - `GAMEPLAY_LLM_VERTEX_PROJECT` — GCP project ID. **Required when provider is `gemini`**. Credentials come from ADC, not from this var. Empty string is rejected at boot. - `GAMEPLAY_LLM_VERTEX_LOCATION` — default `global`. **Gemini-only.** - `GAMEPLAY_LLM_THINKING_LEVEL` — `minimal` / `low` / `medium` / `high` (default `high`). **Gemini-only.** -- **Voice LLM** — separate role. The multimodal LLM that *understands human voice* — transcription + summary + CO detection + vote target extraction in one call. Default targets Google Gemini Flash via the AI Studio REST API; needed only in reactive_voice mode when voice-ingest is active. - - `VOICE_LLM_API_KEY` — required when `MASTER_NPC_PSK` is set and you want voice-ingest active. (SecretStr) - - `VOICE_LLM_MODEL` — default `gemini-2.0-flash-lite`. +- **Voice / Text ingest pipelines** — two orthogonal switches define how human speech (voice in VC, typed in main text channel) becomes a structured `SpeechEvent` with `addressed_seat_no` / `co_declaration` / `role_callout`. The pipelines are composed from three independent components — **AudioAnalyzer** (audio → transcript + structured), **Stt** (audio → transcript), **TextAnalyzer** (text → structured) — each configured with its own credential block so changing the voice backend can never accidentally drag the text path along (the bug fixed in commit pre-2026-05-02 where `VOICE_STT_PROVIDER=gemini` silently routed all typed messages through the Gemini key and 429-throttled them). Validators on `MasterSettings` only require credentials for components the chosen pipeline actually instantiates, so a rounds-mode boot needs none of these set. + - `VOICE_PIPELINE` — `audio_analyzer` (one multimodal call) / `stt_then_text_analyzer` (Stt + TextAnalyzer composed) / `disabled` (default; Master does not join VC). Only the multimodal path is currently implemented for `AUDIO_ANALYZER_PROVIDER=gemini`; the split path requires an OpenAI-compatible TextAnalyzer. + - `TEXT_PIPELINE` — `text_analyzer` (one TextAnalyzer call per typed message) / `passthrough` (default; structured fields stay null, SpeakArbiter falls back to LRU rotation). + - **AudioAnalyzer block** (used iff `VOICE_PIPELINE=audio_analyzer`): + - `AUDIO_ANALYZER_PROVIDER` — `gemini` (only supported value today; xAI/Groq lack audio-in chat-completion endpoints). + - `AUDIO_ANALYZER_API_KEY` (SecretStr) — AI Studio key. + - `AUDIO_ANALYZER_MODEL` — default `gemini-2.0-flash-lite`. + - **Stt block** (used iff `VOICE_PIPELINE=stt_then_text_analyzer`): + - `STT_PROVIDER` — `groq` (only supported value today). + - `STT_API_KEY` (SecretStr). + - `STT_MODEL` — default `whisper-large-v3-turbo`. + - `STT_BASE_URL` — default `https://api.groq.com/openai/v1`. + - **TextAnalyzer block** (used iff `TEXT_PIPELINE=text_analyzer` OR `VOICE_PIPELINE=stt_then_text_analyzer`; one instance shared across both paths): + - `TEXT_ANALYZER_PROVIDER` — `xai` (default) / `deepseek` / `openai` / `gemini`. The first three share the OpenAI Chat Completions surface; `gemini` uses the AI Studio REST API. The split voice path rejects `TEXT_ANALYZER_PROVIDER=gemini` at boot because `GroqWhisperAudioAnalyzer` only composes with the OpenAI-compatible analyzer shape. + - `TEXT_ANALYZER_API_KEY` (SecretStr). + - `TEXT_ANALYZER_MODEL` — default `grok-4-1-fast`. + - `TEXT_ANALYZER_BASE_URL` — optional override for vLLM / Ollama / etc.; empty = provider default. + + Component construction lives in `src/wolfbot/master/ingest_factory.py` (`build_text_analyzer` / `build_voice_ingest_provider`); `main.py` calls each helper exactly once and the returned instances are wired into `WolfCog.text_analyzer` and `VoiceIngestService.stt` respectively. Decoupling from `GAMEPLAY_LLM_*` is intentional — it lets Gameplay target a heavyweight reasoning model while text/audio analysis hits a cheap high-RPM one. **NPC bot** (`envs/npc/.env.`, one file per persona — see committed `envs/npc/.env..example` templates plus [envs/npc/README.md](envs/npc/README.md)) — loaded by `src/wolfbot/npc/config.py::NpcSettings`, instantiated once per worker in `wolfbot.npc.main`. **Each NPC bot process is bound to exactly one persona at startup** (`NPC_PERSONA_KEY`); NPC bots are not interchangeable. Required fields: diff --git a/src/wolfbot/config.py b/src/wolfbot/config.py index 786af4b..e65fff3 100644 --- a/src/wolfbot/config.py +++ b/src/wolfbot/config.py @@ -14,6 +14,49 @@ from wolfbot.llm.decider_config import LLMDeciderConfig, LLMProvider +# Pipeline-shape literals ──────────────────────────────────────────────── +# Kept at module scope because they participate in cross-field validators +# and the test suite imports them directly when constructing minimal +# Settings instances. + +VoicePipeline = Literal["audio_analyzer", "stt_then_text_analyzer", "disabled"] +"""How human voice is converted into a structured ``SpeechEvent``: + +* ``audio_analyzer`` — single multimodal call (audio → transcript + + structured fields). Currently only Gemini Flash supports this shape. +* ``stt_then_text_analyzer`` — two-step pipeline. ``Stt`` transcribes + audio (Groq Whisper). ``TextAnalyzer`` extracts structured fields from + the transcript. The same ``TextAnalyzer`` instance is shared with the + text channel path so they hit one credential pool. +* ``disabled`` — Master does not join VC, no voice ingest. Used by + ``rounds`` mode and by reactive_voice setups that only have NPCs + speaking (no human voice). +""" + +TextPipeline = Literal["text_analyzer", "passthrough"] +"""How a typed text-channel utterance becomes a ``SpeechEvent``: + +* ``text_analyzer`` — one ``TextAnalyzer`` call per typed message + extracts ``addressed_name`` / ``co_claim`` / ``role_callout`` so + ``SpeakArbiter`` can route NPC responses to the addressed seat. +* ``passthrough`` — typed messages produce ``SpeechEvent`` rows with + all structured fields ``None``. ``SpeakArbiter`` falls back to LRU + rotation only. +""" + +AudioAnalyzerProvider = Literal["gemini"] +"""Backends that accept audio input and return transcript + structured +analysis in one hop. Only Gemini Flash for now (xAI / Groq don't expose +audio-in chat-completion endpoints).""" + +SttProvider = Literal["groq"] +"""Backends that transcribe audio to text only. Groq Whisper for now.""" + +TextAnalyzerProvider = Literal["xai", "deepseek", "openai", "gemini"] +"""Backends that accept text input and return structured analysis JSON. +``xai``/``deepseek``/``openai`` use the shared OpenAI Chat Completions +schema; ``gemini`` uses the AI Studio REST API.""" + class MasterSettings(BaseSettings): model_config = SettingsConfigDict( @@ -42,12 +85,6 @@ class MasterSettings(BaseSettings): # the discussion turns are produced by NPC bot processes via # `wolfbot.npc.*` and use NPC_LLM_* there — but votes / night # actions still flow through this Gameplay LLM. - # - # The provider switch is shared with NPC bots (same three providers, - # same field semantics, just a different env-var prefix). The two - # roles can target completely different providers / models — e.g. - # Gameplay on Vertex Gemini for deeper reasoning, NPC on xAI Grok - # for cheap fast utterances. GAMEPLAY_LLM_PROVIDER: LLMProvider = "xai" # xAI / DeepSeek / any OpenAI-compatible endpoint. @@ -72,46 +109,62 @@ class MasterSettings(BaseSettings): MASTER_WS_LISTEN: str = "127.0.0.1:8800" MASTER_NPC_PSK: SecretStr | None = None - # ── Voice STT provider switch ───────────────────────────────────── - # ``gemini`` (default, legacy) — single multimodal call to Gemini - # Flash that does transcription + structured analysis in one hop. - # AI Studio's free-tier RPM is tight enough that a typical game - # exhausts it almost immediately (observed 2026-04-28: every - # segment 429'd). - # - # ``groq`` — two-step pipeline: Groq Whisper transcribes audio; - # the analyzer LLM (xAI Grok by default, reusing - # ``GAMEPLAY_LLM_*``) extracts CO claim, vote target, - # ``addressed_name``, summary from the transcript. Whisper-large-v3 - # on Groq is ~$0.04-0.111/audio-hour with much higher RPM headroom. - VOICE_STT_PROVIDER: Literal["gemini", "groq"] = "gemini" - - # ── Voice LLM (Gemini path) ─────────────────────────────────────── - # The multimodal LLM that *understands human voice* in VC — single - # API call returns transcription + summary + CO detection + vote - # target extraction. This is a separate role from the Gameplay LLM - # because it needs audio input (Gemini Flash via the AI Studio REST - # API; not the OpenAI-compatible chat-completions surface). - VOICE_LLM_API_KEY: SecretStr | None = None - VOICE_LLM_MODEL: str = "gemini-2.0-flash-lite" - - # ── Groq Whisper STT (groq path) ────────────────────────────────── - # Required when ``VOICE_STT_PROVIDER=groq``. The analyzer step - # piggy-backs on ``GAMEPLAY_LLM_*`` (same xAI key + model), so no - # separate analyzer config is needed in the typical setup. + # ── Voice / Text ingest pipeline shapes ──────────────────────────── + # Two orthogonal switches define the structural shape of human-input + # ingestion. Each pipeline is composed from independent components + # (AudioAnalyzer / Stt / TextAnalyzer) configured below. Defaults are + # ``disabled``/``passthrough`` so a fresh checkout boots without + # touching VC or burning analyzer tokens; reactive_voice operators + # opt in explicitly. + VOICE_PIPELINE: VoicePipeline = "disabled" + TEXT_PIPELINE: TextPipeline = "passthrough" + + # ── AudioAnalyzer (audio → transcript + structured) ─────────────── + # Used only when ``VOICE_PIPELINE=audio_analyzer``. Single multimodal + # call: cheap and lowest-latency, but each game segment shares the + # same RPM bucket as any text-channel call that piggy-backs on the + # same key — keep the credential isolated unless you intentionally + # want shared throttling. + AUDIO_ANALYZER_PROVIDER: AudioAnalyzerProvider = "gemini" + AUDIO_ANALYZER_API_KEY: SecretStr | None = None + AUDIO_ANALYZER_MODEL: str = "gemini-2.0-flash-lite" + + # ── Stt (audio → transcript) ────────────────────────────────────── + # Used only when ``VOICE_PIPELINE=stt_then_text_analyzer``. Pairs + # with the ``TEXT_ANALYZER_*`` block to form a two-step voice path. + # ``whisper-large-v3-turbo`` is the cheapest multilingual Whisper + # variant on Groq that still handles Japanese well; switch to + # ``whisper-large-v3`` for max accuracy at ~3x the cost. + STT_PROVIDER: SttProvider = "groq" + STT_API_KEY: SecretStr | None = None + STT_MODEL: str = "whisper-large-v3-turbo" + STT_BASE_URL: str = "https://api.groq.com/openai/v1" + + # ── TextAnalyzer (text → structured) ────────────────────────────── + # Used by ``TEXT_PIPELINE=text_analyzer`` (per typed message) AND by + # ``VOICE_PIPELINE=stt_then_text_analyzer`` (per voice segment, after + # STT). Same component, single credential pool, deliberately + # decoupled from ``GAMEPLAY_LLM_*`` so Gameplay can target a + # heavyweight reasoning model while text analysis hits a cheap one. # - # ``GROQ_STT_MODEL`` defaults to ``whisper-large-v3-turbo`` — the - # cheapest multilingual Whisper variant on Groq that still handles - # Japanese well; switch to ``whisper-large-v3`` for max accuracy at - # ~3x the cost. - GROQ_STT_API_KEY: SecretStr | None = None - GROQ_STT_MODEL: str = "whisper-large-v3-turbo" - GROQ_STT_BASE_URL: str = "https://api.groq.com/openai/v1" + # Provider gating: + # * ``xai`` / ``deepseek`` / ``openai`` use the shared OpenAI Chat + # Completions schema and require ``TEXT_ANALYZER_API_KEY``. + # * ``gemini`` uses the AI Studio REST API and requires + # ``TEXT_ANALYZER_API_KEY`` (an AIza-format key). Vertex auth is + # not supported on this path because the analyzer uses the + # ``v1beta/models/{m}:generateContent?key=...`` shape. + TEXT_ANALYZER_PROVIDER: TextAnalyzerProvider = "xai" + TEXT_ANALYZER_API_KEY: SecretStr | None = None + TEXT_ANALYZER_MODEL: str = "grok-4-1-fast" + # Optional override for OpenAI-compatible endpoints (vLLM, Ollama, + # etc.). Empty / None → provider default base URL. + TEXT_ANALYZER_BASE_URL: str | None = None # ── Pre-STT silence gate ────────────────────────────────────────── # Discord's speaking-start fires on any audio above a low threshold # (breathing, keyboard, room hum). With ``SilenceGeneratorSink`` - # padding, this would burn one Groq + one xAI analyzer call for + # padding, this would burn one Stt + one TextAnalyzer call for # every such non-speech burst. The gate suppresses the STT call # (and emits ``stt_failed reason=pre_stt_silence_gate`` so the # arbiter still finalises the segment) when the buffer is too @@ -158,21 +211,65 @@ def _require_gameplay_provider_key(self) -> MasterSettings: return self @model_validator(mode="after") - def _require_voice_stt_provider_key(self) -> MasterSettings: - if self.VOICE_STT_PROVIDER == "groq" and self.GROQ_STT_API_KEY is None: - raise ValueError("VOICE_STT_PROVIDER=groq requires GROQ_STT_API_KEY to be set") - # The Groq path's analyzer step reuses GAMEPLAY_LLM_*. The xAI/DeepSeek - # case is already covered above; the Gemini case isn't fit for the - # OpenAI-compatible analyzer call shape, so block that combo loud and - # early rather than failing per-segment at runtime. - if self.VOICE_STT_PROVIDER == "groq" and self.GAMEPLAY_LLM_PROVIDER == "gemini": + def _require_pipeline_components(self) -> MasterSettings: + """Enforce credential presence given the chosen pipeline shape. + + Each component check fires only when the pipeline actually + instantiates that component. Operators with + ``VOICE_PIPELINE=disabled`` and ``TEXT_PIPELINE=passthrough`` + boot without any analyzer credential at all (rounds-mode + default). + """ + # AudioAnalyzer is required only by the multimodal voice path. + if ( + self.VOICE_PIPELINE == "audio_analyzer" + and self.AUDIO_ANALYZER_API_KEY is None + ): raise ValueError( - "VOICE_STT_PROVIDER=groq's analyzer step reuses GAMEPLAY_LLM_* " - "but requires an OpenAI-compatible provider (xai or deepseek), " - f"not {self.GAMEPLAY_LLM_PROVIDER}. " - "Set VOICE_STT_PROVIDER=gemini (uses VOICE_LLM_API_KEY) when " - "switching gameplay to Gemini." + "VOICE_PIPELINE=audio_analyzer requires " + "AUDIO_ANALYZER_API_KEY to be set" ) + + # Stt is required only by the split voice path. + if ( + self.VOICE_PIPELINE == "stt_then_text_analyzer" + and self.STT_API_KEY is None + ): + raise ValueError( + "VOICE_PIPELINE=stt_then_text_analyzer requires " + "STT_API_KEY to be set" + ) + + # TextAnalyzer is required by EITHER the text path OR the split + # voice path (which composes Stt + TextAnalyzer). + text_analyzer_required = ( + self.TEXT_PIPELINE == "text_analyzer" + or self.VOICE_PIPELINE == "stt_then_text_analyzer" + ) + if text_analyzer_required and self.TEXT_ANALYZER_API_KEY is None: + raise ValueError( + "TEXT_ANALYZER_API_KEY must be set when " + "TEXT_PIPELINE=text_analyzer or " + "VOICE_PIPELINE=stt_then_text_analyzer" + ) + + # The split voice path's analyzer step calls an OpenAI-compatible + # /chat/completions endpoint (see GroqWhisperAudioAnalyzer). The + # AI Studio REST shape used by the gemini text analyzer doesn't + # fit, so block the combo at boot rather than failing per + # segment. + if ( + self.VOICE_PIPELINE == "stt_then_text_analyzer" + and self.TEXT_ANALYZER_PROVIDER == "gemini" + ): + raise ValueError( + "VOICE_PIPELINE=stt_then_text_analyzer composes Stt with " + "an OpenAI-compatible TextAnalyzer; TEXT_ANALYZER_PROVIDER=" + "gemini is not supported on that path. Either set " + "TEXT_ANALYZER_PROVIDER to xai/deepseek/openai or switch " + "VOICE_PIPELINE to audio_analyzer." + ) + return self def apply_phase_durations(self) -> None: diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 05c6916..75df81c 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -657,46 +657,23 @@ async def _on_speech_recorded(game_id: str) -> None: if _reactive_phase_cb: await _reactive_phase_cb[0].try_dispatch_next(game_id) - # Mirror the voice path's structured analysis on the text channel: - # one Gemini call per typed message yields the same `addressed_name` - # / `co_claim` signal so SpeakArbiter can route a text address to - # the right NPC seat. Wired only when reactive_voice is enabled and a - # Voice LLM key is present; absent → WolfCog falls back to plain raw - # capture (the historical behavior). - text_analyzer: Any = None - if ( - settings.LLM_DISCUSSION_MODE == "reactive_voice" + # Text channel structured analysis: one TextAnalyzer call per typed + # message extracts ``addressed_name`` / ``co_claim`` / + # ``role_callout`` so SpeakArbiter can route a text address to the + # right NPC seat. ``build_text_analyzer`` returns ``None`` when + # ``TEXT_PIPELINE=passthrough`` (or when the voice path doesn't need + # an analyzer either), in which case ``WolfCog`` falls back to plain + # raw capture. The component is the same instance used by the + # voice path's split pipeline below — single credential pool, + # decoupled from ``GAMEPLAY_LLM_*``. + from wolfbot.master.ingest_factory import build_text_analyzer + + text_analyzer: Any = ( + build_text_analyzer(settings) + if settings.LLM_DISCUSSION_MODE == "reactive_voice" and settings.MASTER_NPC_PSK is not None - ): - # Pick the analyzer that matches the voice path. When the user has - # ``VOICE_STT_PROVIDER=groq`` the voice ingest already splits STT - # (Groq Whisper) from the analyzer step (xAI Grok via the - # gameplay LLM key), and the text path should follow the same - # split — otherwise typed messages keep round-tripping through - # Gemini and 429 the moment that key gets rate-limited (the bug - # observed in game 58a3243a9fb8 where every ``role_callout`` came - # back NULL because Gemini was throttled). - if ( - settings.VOICE_STT_PROVIDER == "groq" - and settings.GAMEPLAY_LLM_API_KEY is not None - ): - from wolfbot.master.state.text_analyzer import OpenAICompatibleTextAnalyzer - - analyzer_base_url = ( - settings.GAMEPLAY_LLM_BASE_URL or "https://api.x.ai/v1" - ) - text_analyzer = OpenAICompatibleTextAnalyzer( - api_key=settings.GAMEPLAY_LLM_API_KEY.get_secret_value(), - model=settings.GAMEPLAY_LLM_MODEL, - base_url=analyzer_base_url, - ) - elif settings.VOICE_LLM_API_KEY is not None: - from wolfbot.master.state.text_analyzer import GeminiTextAnalyzer - - text_analyzer = GeminiTextAnalyzer( - api_key=settings.VOICE_LLM_API_KEY.get_secret_value(), - model=settings.VOICE_LLM_MODEL, - ) + else None + ) cog = WolfCog( bot=bot, @@ -1201,23 +1178,16 @@ async def _on_wolf_chat_send(msg: Any, _ctx: Any) -> None: # Instead of a separate voice-ingest process, Master joins VC itself # and pipes audio through VoiceIngestService → DirectMasterIngestionClient # → arbiter/ingest_service, all in-process. - # Voice STT is wired iff a credential exists for the chosen - # provider. Gemini path needs ``VOICE_LLM_API_KEY``; Groq path - # needs ``GROQ_STT_API_KEY`` plus an OpenAI-compatible analyzer - # (gameplay key reused). - _voice_stt_credentialed = ( - (settings.VOICE_STT_PROVIDER == "gemini" and settings.VOICE_LLM_API_KEY is not None) - or ( - settings.VOICE_STT_PROVIDER == "groq" - and settings.GROQ_STT_API_KEY is not None - and settings.GAMEPLAY_LLM_API_KEY is not None - ) + # The pipeline shape (audio_analyzer vs stt_then_text_analyzer) + # and component credentials live on MasterSettings; the factory + # returns ``None`` when ``VOICE_PIPELINE=disabled``. + from wolfbot.master.ingest_factory import ( + build_voice_ingest_provider, + voice_ingest_summary, ) - if _voice_stt_credentialed: - from wolfbot.master.voice.stt_service import ( - GeminiAudioAnalyzer, - GroqWhisperAudioAnalyzer, - ) + + voice_llm: Any = build_voice_ingest_provider(settings) + if voice_llm is not None: from wolfbot.master.voice.voice_ingest_client import DirectMasterIngestionClient from wolfbot.master.voice.voice_ingest_service import ( VoiceIngestConfig, @@ -1256,30 +1226,6 @@ async def _direct_stt_failed(msg: Any) -> None: on_stt_failed=_direct_stt_failed, ) - voice_llm: Any - if settings.VOICE_STT_PROVIDER == "groq": - # Reuse gameplay key for the analyzer step. Validator - # already guarantees both keys are present. - assert settings.GROQ_STT_API_KEY is not None - assert settings.GAMEPLAY_LLM_API_KEY is not None - analyzer_base_url = ( - settings.GAMEPLAY_LLM_BASE_URL or "https://api.x.ai/v1" - ) - voice_llm = GroqWhisperAudioAnalyzer( - groq_api_key=settings.GROQ_STT_API_KEY.get_secret_value(), - groq_model=settings.GROQ_STT_MODEL, - groq_base_url=settings.GROQ_STT_BASE_URL, - analyzer_api_key=settings.GAMEPLAY_LLM_API_KEY.get_secret_value(), - analyzer_model=settings.GAMEPLAY_LLM_MODEL, - analyzer_base_url=analyzer_base_url, - ) - else: - assert settings.VOICE_LLM_API_KEY is not None - voice_llm = GeminiAudioAnalyzer( - api_key=settings.VOICE_LLM_API_KEY.get_secret_value(), - model=settings.VOICE_LLM_MODEL, - ) - # NpcRegistryView adapter: InMemoryNpcRegistry → NpcRegistryView class _RegistryViewAdapter: def is_npc(self, discord_user_id: str) -> bool: @@ -1316,17 +1262,7 @@ def _roster_lookup() -> list[tuple[int, str]]: ), roster_lookup=_roster_lookup, ) - if settings.VOICE_STT_PROVIDER == "groq": - log.info( - "integrated voice-ingest wired (provider=groq stt_model=%s analyzer=%s)", - settings.GROQ_STT_MODEL, - settings.GAMEPLAY_LLM_MODEL, - ) - else: - log.info( - "integrated voice-ingest wired (provider=gemini voice_llm_model=%s)", - settings.VOICE_LLM_MODEL, - ) + log.info("integrated voice-ingest wired (%s)", voice_ingest_summary(settings)) @bot.event async def on_ready() -> None: diff --git a/src/wolfbot/master/ingest_factory.py b/src/wolfbot/master/ingest_factory.py new file mode 100644 index 0000000..5bb71ca --- /dev/null +++ b/src/wolfbot/master/ingest_factory.py @@ -0,0 +1,158 @@ +"""Factories for the three ingest components: ``AudioAnalyzer`` / ``Stt`` +/ ``TextAnalyzer``. + +The pipeline shape switches (``VOICE_PIPELINE``, ``TEXT_PIPELINE``) on +:class:`wolfbot.config.MasterSettings` decide which components a +particular boot needs; this module turns those switches plus the +component-scoped credentials into ready-to-inject instances. + +Why a separate module: ``main.py`` and ``voicetest/main.py`` both need +exactly the same component construction. Centralising it here also keeps +the wiring in ``main.py`` flat — one ``build_*`` call per component +instead of nested provider branches per call site. + +The functions return ``None`` when the pipeline doesn't request the +component, so callers can write ``if audio_analyzer is None:`` rather +than re-checking the pipeline shape. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from wolfbot.config import MasterSettings + from wolfbot.master.state.text_analyzer import TextAnalyzer + + +def build_text_analyzer(settings: MasterSettings) -> TextAnalyzer | None: + """Construct the ``TextAnalyzer`` instance shared by the text and + split-voice pipelines, or ``None`` if neither pipeline needs it. + + The settings validator already guarantees ``TEXT_ANALYZER_API_KEY`` + is non-None when this returns non-None, so the assert is purely a + type-narrowing aid for mypy. + """ + needs_text_analyzer = ( + settings.TEXT_PIPELINE == "text_analyzer" + or settings.VOICE_PIPELINE == "stt_then_text_analyzer" + ) + if not needs_text_analyzer: + return None + assert settings.TEXT_ANALYZER_API_KEY is not None # validator-guaranteed + + if settings.TEXT_ANALYZER_PROVIDER == "gemini": + from wolfbot.master.state.text_analyzer import GeminiTextAnalyzer + + return GeminiTextAnalyzer( + api_key=settings.TEXT_ANALYZER_API_KEY.get_secret_value(), + model=settings.TEXT_ANALYZER_MODEL, + ) + + # xai / deepseek / openai all share the OpenAI Chat Completions surface. + from wolfbot.master.state.text_analyzer import OpenAICompatibleTextAnalyzer + + base_url = settings.TEXT_ANALYZER_BASE_URL or _openai_compat_default_base_url( + settings.TEXT_ANALYZER_PROVIDER + ) + return OpenAICompatibleTextAnalyzer( + api_key=settings.TEXT_ANALYZER_API_KEY.get_secret_value(), + model=settings.TEXT_ANALYZER_MODEL, + base_url=base_url, + ) + + +def build_voice_ingest_provider(settings: MasterSettings) -> Any | None: + """Construct the ``SttService``-shaped object that + :class:`VoiceIngestService` consumes, or ``None`` when voice ingest + is disabled. + + Returns one of: + + * :class:`GeminiAudioAnalyzer` — when ``VOICE_PIPELINE=audio_analyzer``. + * :class:`GroqWhisperAudioAnalyzer` — when + ``VOICE_PIPELINE=stt_then_text_analyzer``. Composes the Stt + provider with the OpenAI-compatible ``TextAnalyzer`` settings; + the analyzer step shares its credentials with the text-channel + ``TextAnalyzer`` so both paths hit the same RPM bucket. + * ``None`` — when ``VOICE_PIPELINE=disabled``. + + Both return types satisfy the ``SttService`` Protocol used by + :mod:`wolfbot.master.voice.voice_ingest_service`. Returning ``Any`` + here avoids dragging the heavy ``stt_service`` module into modules + that only need the credential-presence check. + """ + if settings.VOICE_PIPELINE == "disabled": + return None + + if settings.VOICE_PIPELINE == "audio_analyzer": + assert settings.AUDIO_ANALYZER_API_KEY is not None # validator-guaranteed + from wolfbot.master.voice.stt_service import GeminiAudioAnalyzer + + return GeminiAudioAnalyzer( + api_key=settings.AUDIO_ANALYZER_API_KEY.get_secret_value(), + model=settings.AUDIO_ANALYZER_MODEL, + ) + + # VOICE_PIPELINE == "stt_then_text_analyzer" + assert settings.STT_API_KEY is not None # validator-guaranteed + assert settings.TEXT_ANALYZER_API_KEY is not None # validator-guaranteed + from wolfbot.master.voice.stt_service import GroqWhisperAudioAnalyzer + + analyzer_base_url = ( + settings.TEXT_ANALYZER_BASE_URL + or _openai_compat_default_base_url(settings.TEXT_ANALYZER_PROVIDER) + ) + return GroqWhisperAudioAnalyzer( + groq_api_key=settings.STT_API_KEY.get_secret_value(), + groq_model=settings.STT_MODEL, + groq_base_url=settings.STT_BASE_URL, + analyzer_api_key=settings.TEXT_ANALYZER_API_KEY.get_secret_value(), + analyzer_model=settings.TEXT_ANALYZER_MODEL, + analyzer_base_url=analyzer_base_url, + ) + + +def voice_ingest_summary(settings: MasterSettings) -> str: + """Human-readable wiring summary for boot-time logs. + + Mirrors the strings ``main.py`` previously logged inline so operators + grepping for ``integrated voice-ingest wired`` keep a similar signal. + """ + if settings.VOICE_PIPELINE == "disabled": + return "voice_pipeline=disabled" + if settings.VOICE_PIPELINE == "audio_analyzer": + return ( + f"voice_pipeline=audio_analyzer " + f"provider={settings.AUDIO_ANALYZER_PROVIDER} " + f"model={settings.AUDIO_ANALYZER_MODEL}" + ) + return ( + f"voice_pipeline=stt_then_text_analyzer " + f"stt={settings.STT_PROVIDER}:{settings.STT_MODEL} " + f"analyzer={settings.TEXT_ANALYZER_PROVIDER}:{settings.TEXT_ANALYZER_MODEL}" + ) + + +_OPENAI_COMPAT_DEFAULTS: dict[str, str] = { + "xai": "https://api.x.ai/v1", + "deepseek": "https://api.deepseek.com", + "openai": "https://api.openai.com/v1", +} + + +def _openai_compat_default_base_url(provider: str) -> str: + """Default base URL for an OpenAI-compatible provider. + + Falls back to the xAI URL for unknown providers because the analyzer + code path is identical regardless of host (any error surfaces at the + first request rather than at construction). + """ + return _OPENAI_COMPAT_DEFAULTS.get(provider, "https://api.x.ai/v1") + + +__all__ = [ + "build_text_analyzer", + "build_voice_ingest_provider", + "voice_ingest_summary", +] diff --git a/src/wolfbot/voicetest/main.py b/src/wolfbot/voicetest/main.py index 335a26c..16300c2 100644 --- a/src/wolfbot/voicetest/main.py +++ b/src/wolfbot/voicetest/main.py @@ -81,28 +81,29 @@ class VoicetestSettings(BaseSettings): # syllables triggered by the silence-padding playback). Real # utterances are almost always ≥300ms. VOICETEST_MIN_DURATION_MS: int = 300 - # When true, run kept segments through the same STT stack the - # production master uses (selected by ``VOICE_STT_PROVIDER`` - - # gemini multimodal *or* Groq Whisper + xAI analyzer). When false - # (default), STT is stubbed and only the WAV is dumped. + # When true, run kept segments through the same voice-ingest stack + # the production master uses (the pipeline shape is selected by + # ``VOICE_PIPELINE`` on .env.master). When false (default), STT is + # stubbed and only the WAV is dumped. VOICETEST_USE_STT: bool = False LOG_LEVEL: str = "INFO" - # ----- production-equivalent STT credentials ----- - # These are the same keys the master process reads from - # ``.env.master``. ``_run`` loads ``.env.master`` first and the - # voicetest env file second (with override), so voicetest - # transparently reuses whatever STT provider production is set up - # for - no duplication of API keys in the voicetest env. - VOICE_STT_PROVIDER: str = "gemini" - VOICE_LLM_API_KEY: SecretStr | None = None - VOICE_LLM_MODEL: str = "gemini-2.0-flash-lite" - GROQ_STT_API_KEY: SecretStr | None = None - GROQ_STT_MODEL: str = "whisper-large-v3-turbo" - GROQ_STT_BASE_URL: str = "https://api.groq.com/openai/v1" - GAMEPLAY_LLM_API_KEY: SecretStr | None = None - GAMEPLAY_LLM_MODEL: str = "grok-4-1-fast" - GAMEPLAY_LLM_BASE_URL: str | None = None + # ----- production-equivalent ingest credentials ----- + # These mirror the new structural settings on ``MasterSettings``. + # ``_run`` loads ``.env.master`` first and the voicetest env file + # second (with override), so voicetest transparently reuses whatever + # voice / text pipeline production is configured for — no + # duplication of API keys in the voicetest env. + VOICE_PIPELINE: str = "disabled" + AUDIO_ANALYZER_API_KEY: SecretStr | None = None + AUDIO_ANALYZER_MODEL: str = "gemini-2.0-flash-lite" + STT_API_KEY: SecretStr | None = None + STT_MODEL: str = "whisper-large-v3-turbo" + STT_BASE_URL: str = "https://api.groq.com/openai/v1" + TEXT_ANALYZER_PROVIDER: str = "xai" + TEXT_ANALYZER_API_KEY: SecretStr | None = None + TEXT_ANALYZER_MODEL: str = "grok-4-1-fast" + TEXT_ANALYZER_BASE_URL: str | None = None # Pre-STT silence gate, mirrored from MasterSettings so the voice # path uses the same threshold as production. ``0`` disables. VOICE_PRE_STT_MIN_RMS: int = 0 @@ -223,65 +224,74 @@ def lookup() -> list[tuple[int, str]]: def _build_production_stt(settings: VoicetestSettings): # type: ignore[no-untyped-def] - """Construct the same STT instance the production master uses. + """Construct the same voice-ingest provider the production master uses. - Selected by ``VOICE_STT_PROVIDER`` (gemini / groq) loaded from - ``.env.master``. Raises ``SystemExit`` with a precise hint when - the chosen provider's credentials are missing - the operator - should fix the production env, not duplicate keys here. + The pipeline shape is selected by ``VOICE_PIPELINE`` on .env.master + (``audio_analyzer`` or ``stt_then_text_analyzer``). Raises + ``SystemExit`` with a precise hint when the chosen pipeline's + credentials are missing — the operator should fix the production + env, not duplicate keys here. """ - provider = (settings.VOICE_STT_PROVIDER or "gemini").lower() - if provider == "groq": - if settings.GROQ_STT_API_KEY is None: + pipeline = (settings.VOICE_PIPELINE or "disabled").lower() + if pipeline == "stt_then_text_analyzer": + if settings.STT_API_KEY is None: raise SystemExit( - "VOICETEST_USE_STT=true with VOICE_STT_PROVIDER=groq " - "requires GROQ_STT_API_KEY (set in .env.master)." + "VOICETEST_USE_STT=true with " + "VOICE_PIPELINE=stt_then_text_analyzer requires " + "STT_API_KEY (set in .env.master)." ) - if settings.GAMEPLAY_LLM_API_KEY is None: + if settings.TEXT_ANALYZER_API_KEY is None: raise SystemExit( - "VOICETEST_USE_STT=true with VOICE_STT_PROVIDER=groq " - "requires GAMEPLAY_LLM_API_KEY for the analyzer step " + "VOICETEST_USE_STT=true with " + "VOICE_PIPELINE=stt_then_text_analyzer requires " + "TEXT_ANALYZER_API_KEY for the analyzer step " "(set in .env.master)." ) + from wolfbot.master.ingest_factory import ( + _openai_compat_default_base_url, + ) from wolfbot.master.voice.stt_service import GroqWhisperAudioAnalyzer analyzer_base_url = ( - settings.GAMEPLAY_LLM_BASE_URL or "https://api.x.ai/v1" + settings.TEXT_ANALYZER_BASE_URL + or _openai_compat_default_base_url(settings.TEXT_ANALYZER_PROVIDER) ) log.info( - "voicetest_stt=ON provider=groq whisper=%s analyzer=%s @ %s", - settings.GROQ_STT_MODEL, - settings.GAMEPLAY_LLM_MODEL, + "voicetest_stt=ON pipeline=stt_then_text_analyzer stt=%s analyzer=%s @ %s", + settings.STT_MODEL, + settings.TEXT_ANALYZER_MODEL, analyzer_base_url, ) return GroqWhisperAudioAnalyzer( - groq_api_key=settings.GROQ_STT_API_KEY.get_secret_value(), - groq_model=settings.GROQ_STT_MODEL, - groq_base_url=settings.GROQ_STT_BASE_URL, - analyzer_api_key=settings.GAMEPLAY_LLM_API_KEY.get_secret_value(), - analyzer_model=settings.GAMEPLAY_LLM_MODEL, + groq_api_key=settings.STT_API_KEY.get_secret_value(), + groq_model=settings.STT_MODEL, + groq_base_url=settings.STT_BASE_URL, + analyzer_api_key=settings.TEXT_ANALYZER_API_KEY.get_secret_value(), + analyzer_model=settings.TEXT_ANALYZER_MODEL, analyzer_base_url=analyzer_base_url, ) - if provider == "gemini": - if settings.VOICE_LLM_API_KEY is None: + if pipeline == "audio_analyzer": + if settings.AUDIO_ANALYZER_API_KEY is None: raise SystemExit( - "VOICETEST_USE_STT=true with VOICE_STT_PROVIDER=gemini " - "requires VOICE_LLM_API_KEY (set in .env.master)." + "VOICETEST_USE_STT=true with VOICE_PIPELINE=audio_analyzer " + "requires AUDIO_ANALYZER_API_KEY (set in .env.master)." ) from wolfbot.master.voice.stt_service import GeminiAudioAnalyzer log.info( - "voicetest_stt=ON provider=gemini model=%s", - settings.VOICE_LLM_MODEL, + "voicetest_stt=ON pipeline=audio_analyzer model=%s", + settings.AUDIO_ANALYZER_MODEL, ) return GeminiAudioAnalyzer( - api_key=settings.VOICE_LLM_API_KEY.get_secret_value(), - model=settings.VOICE_LLM_MODEL, + api_key=settings.AUDIO_ANALYZER_API_KEY.get_secret_value(), + model=settings.AUDIO_ANALYZER_MODEL, ) raise SystemExit( - f"Unknown VOICE_STT_PROVIDER={provider!r} - expected 'gemini' or 'groq'" + f"VOICETEST_USE_STT=true requires VOICE_PIPELINE in " + f"{{audio_analyzer, stt_then_text_analyzer}} (got {pipeline!r}). " + "Set VOICE_PIPELINE in .env.master and re-run." ) @@ -380,8 +390,9 @@ async def gated_dump_segment(record, pcm): # type: ignore[no-untyped-def] async def _run() -> None: - # Load production credentials first so STT keys (VOICE_LLM_API_KEY, - # GROQ_STT_API_KEY, GAMEPLAY_LLM_API_KEY, etc.) come through without + # Load production credentials first so ingest keys + # (AUDIO_ANALYZER_API_KEY, STT_API_KEY, TEXT_ANALYZER_API_KEY, + # GAMEPLAY_LLM_API_KEY, etc.) come through without # duplication. The voicetest-specific file is loaded second with # ``override=True`` so any voicetest-only setting wins - including # the discord token, which is intentionally a different bot from diff --git a/tests/test_config.py b/tests/test_config.py index ab0ba27..fadf0bd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -202,3 +202,139 @@ def test_gameplay_decider_config_round_trips_gemini() -> None: assert cfg.vertex_project == "p" assert cfg.vertex_location == "global" assert cfg.thinking_level == "high" + + +# ──────────────────────────────────────────────── ingest pipelines +def _gameplay_ok_kwargs() -> dict[str, object]: + """Minimal valid gameplay LLM kwargs so pipeline tests can focus on + the new VOICE_/TEXT_ pipeline validators without fighting the + gameplay validator.""" + return {**_base_kwargs(), "GAMEPLAY_LLM_API_KEY": SecretStr("g")} + + +def test_pipeline_defaults_require_no_ingest_credentials() -> None: + """Rounds-mode boot: nothing voice/text-related needed. The + defaults (``disabled``/``passthrough``) keep validators silent so + ops with no analyzer key at all can still launch the bot.""" + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + ) + assert s.VOICE_PIPELINE == "disabled" + assert s.TEXT_PIPELINE == "passthrough" + assert s.AUDIO_ANALYZER_API_KEY is None + assert s.STT_API_KEY is None + assert s.TEXT_ANALYZER_API_KEY is None + + +def test_voice_pipeline_audio_analyzer_requires_audio_key() -> None: + with pytest.raises(ValidationError, match="AUDIO_ANALYZER_API_KEY"): + MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + VOICE_PIPELINE="audio_analyzer", + ) + + +def test_voice_pipeline_audio_analyzer_with_key_constructs() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + VOICE_PIPELINE="audio_analyzer", + AUDIO_ANALYZER_API_KEY=SecretStr("aa"), + ) + assert s.VOICE_PIPELINE == "audio_analyzer" + assert s.AUDIO_ANALYZER_PROVIDER == "gemini" + + +def test_voice_pipeline_split_requires_stt_and_text_analyzer_keys() -> None: + """``stt_then_text_analyzer`` composes Stt + TextAnalyzer; both + credentials must be present.""" + # Missing STT key. + with pytest.raises(ValidationError, match="STT_API_KEY"): + MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + VOICE_PIPELINE="stt_then_text_analyzer", + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + # Missing TextAnalyzer key. + with pytest.raises(ValidationError, match="TEXT_ANALYZER_API_KEY"): + MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + VOICE_PIPELINE="stt_then_text_analyzer", + STT_API_KEY=SecretStr("ss"), + ) + + +def test_voice_pipeline_split_rejects_gemini_text_analyzer() -> None: + """Cross-field guard: Whisper-then-Gemini composition isn't wired up + in the runtime so the validator must fail loud at boot rather than + deferring to a runtime AttributeError.""" + with pytest.raises(ValidationError, match="not supported"): + MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + VOICE_PIPELINE="stt_then_text_analyzer", + STT_API_KEY=SecretStr("ss"), + TEXT_ANALYZER_PROVIDER="gemini", + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + + +def test_voice_pipeline_split_constructs_with_xai_text_analyzer() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + VOICE_PIPELINE="stt_then_text_analyzer", + STT_API_KEY=SecretStr("ss"), + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + assert s.VOICE_PIPELINE == "stt_then_text_analyzer" + assert s.STT_PROVIDER == "groq" + assert s.TEXT_ANALYZER_PROVIDER == "xai" + + +def test_text_pipeline_text_analyzer_requires_key() -> None: + """Text path alone needs TextAnalyzer credentials even when voice + is disabled — they don't piggy-back on each other anymore.""" + with pytest.raises(ValidationError, match="TEXT_ANALYZER_API_KEY"): + MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + TEXT_PIPELINE="text_analyzer", + ) + + +def test_text_pipeline_text_analyzer_with_key_constructs() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + TEXT_PIPELINE="text_analyzer", + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + assert s.TEXT_PIPELINE == "text_analyzer" + assert s.TEXT_ANALYZER_MODEL == "grok-4-1-fast" + + +def test_audio_voice_text_pipelines_are_independent() -> None: + """Bug fix coverage: configuring the voice path with Gemini must not + force the text path through the same credential. Set the typical + ``Gemini multimodal voice + xAI text analyzer`` combo and assert + both knobs land where intended.""" + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + VOICE_PIPELINE="audio_analyzer", + AUDIO_ANALYZER_API_KEY=SecretStr("gemini-key"), + TEXT_PIPELINE="text_analyzer", + TEXT_ANALYZER_PROVIDER="xai", + TEXT_ANALYZER_API_KEY=SecretStr("xai-key"), + ) + assert s.AUDIO_ANALYZER_PROVIDER == "gemini" + assert s.AUDIO_ANALYZER_API_KEY is not None + assert s.AUDIO_ANALYZER_API_KEY.get_secret_value() == "gemini-key" + assert s.TEXT_ANALYZER_PROVIDER == "xai" + assert s.TEXT_ANALYZER_API_KEY is not None + assert s.TEXT_ANALYZER_API_KEY.get_secret_value() == "xai-key" diff --git a/tests/test_master_ingest_factory.py b/tests/test_master_ingest_factory.py new file mode 100644 index 0000000..549b20d --- /dev/null +++ b/tests/test_master_ingest_factory.py @@ -0,0 +1,182 @@ +"""Coverage for ``wolfbot.master.ingest_factory``. + +The factory turns the structural pipeline switches on +:class:`MasterSettings` into ready-to-inject ``AudioAnalyzer`` / ``Stt`` +/ ``TextAnalyzer`` instances. These tests pin two contracts: + +1. **Returns ``None`` when the pipeline doesn't request the component.** + Callers branch on the return value rather than re-checking pipeline + shape; if a factory ever returns a stub instead of None, the wiring + in ``main.py`` would silently activate a path the operator opted out + of. +2. **Constructs the right concrete class for each combination.** The + pipeline shape + provider together decide which of the four concrete + analyzer/STT classes to instantiate. We don't exercise the network + path here — those are covered in the per-class test files + (``test_text_analyzer``, ``test_master_stt_groq``). +""" + +from __future__ import annotations + +from pydantic import SecretStr + +from wolfbot.config import MasterSettings +from wolfbot.master.ingest_factory import ( + build_text_analyzer, + build_voice_ingest_provider, + voice_ingest_summary, +) +from wolfbot.master.state.text_analyzer import ( + GeminiTextAnalyzer, + OpenAICompatibleTextAnalyzer, +) +from wolfbot.master.voice.stt_service import ( + GeminiAudioAnalyzer, + GroqWhisperAudioAnalyzer, +) + + +def _base_kwargs() -> dict[str, object]: + return { + "DISCORD_TOKEN": SecretStr("token"), + "DISCORD_GUILD_ID": 1, + "MAIN_TEXT_CHANNEL_ID": 2, + "MAIN_VOICE_CHANNEL_ID": 3, + "GAMEPLAY_LLM_API_KEY": SecretStr("g"), + } + + +# ───────────────────────────────────────────── build_text_analyzer +def test_text_analyzer_none_when_passthrough_and_voice_disabled() -> None: + s = MasterSettings(_env_file=None, **_base_kwargs()) # type: ignore[arg-type] + assert build_text_analyzer(s) is None + + +def test_text_analyzer_xai_default() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + TEXT_PIPELINE="text_analyzer", + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + inst = build_text_analyzer(s) + assert isinstance(inst, OpenAICompatibleTextAnalyzer) + # xai default base url applied even though TEXT_ANALYZER_BASE_URL is unset. + assert inst.base_url == "https://api.x.ai/v1" + assert inst.model == "grok-4-1-fast" + + +def test_text_analyzer_deepseek_default_base_url() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + TEXT_PIPELINE="text_analyzer", + TEXT_ANALYZER_PROVIDER="deepseek", + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + inst = build_text_analyzer(s) + assert isinstance(inst, OpenAICompatibleTextAnalyzer) + assert inst.base_url == "https://api.deepseek.com" + + +def test_text_analyzer_explicit_base_url_wins() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + TEXT_PIPELINE="text_analyzer", + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + TEXT_ANALYZER_BASE_URL="http://localhost:11434/v1", + ) + inst = build_text_analyzer(s) + assert isinstance(inst, OpenAICompatibleTextAnalyzer) + assert inst.base_url == "http://localhost:11434/v1" + + +def test_text_analyzer_gemini_path() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + TEXT_PIPELINE="text_analyzer", + TEXT_ANALYZER_PROVIDER="gemini", + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + TEXT_ANALYZER_MODEL="gemini-2.0-flash-lite", + ) + inst = build_text_analyzer(s) + assert isinstance(inst, GeminiTextAnalyzer) + assert inst.model == "gemini-2.0-flash-lite" + + +def test_text_analyzer_built_for_split_voice_path_even_without_text_pipeline() -> None: + """``stt_then_text_analyzer`` voice path needs the analyzer too; + factory must hand back the same kind of instance the text pipeline + would have built.""" + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + VOICE_PIPELINE="stt_then_text_analyzer", + STT_API_KEY=SecretStr("ss"), + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + inst = build_text_analyzer(s) + assert isinstance(inst, OpenAICompatibleTextAnalyzer) + + +# ──────────────────────────────────────── build_voice_ingest_provider +def test_voice_provider_none_when_disabled() -> None: + s = MasterSettings(_env_file=None, **_base_kwargs()) # type: ignore[arg-type] + assert build_voice_ingest_provider(s) is None + + +def test_voice_provider_audio_analyzer() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + VOICE_PIPELINE="audio_analyzer", + AUDIO_ANALYZER_API_KEY=SecretStr("aa"), + ) + inst = build_voice_ingest_provider(s) + assert isinstance(inst, GeminiAudioAnalyzer) + + +def test_voice_provider_split_pipeline() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + VOICE_PIPELINE="stt_then_text_analyzer", + STT_API_KEY=SecretStr("ss"), + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + inst = build_voice_ingest_provider(s) + assert isinstance(inst, GroqWhisperAudioAnalyzer) + + +# ──────────────────────────────────────────────── voice_ingest_summary +def test_summary_disabled() -> None: + s = MasterSettings(_env_file=None, **_base_kwargs()) # type: ignore[arg-type] + assert "disabled" in voice_ingest_summary(s) + + +def test_summary_audio_analyzer_includes_model() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + VOICE_PIPELINE="audio_analyzer", + AUDIO_ANALYZER_API_KEY=SecretStr("aa"), + AUDIO_ANALYZER_MODEL="gemini-2.5-flash", + ) + summary = voice_ingest_summary(s) + assert "audio_analyzer" in summary + assert "gemini-2.5-flash" in summary + + +def test_summary_split_pipeline_includes_both_steps() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + VOICE_PIPELINE="stt_then_text_analyzer", + STT_API_KEY=SecretStr("ss"), + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + summary = voice_ingest_summary(s) + assert "whisper-large-v3-turbo" in summary + assert "grok-4-1-fast" in summary From 6d1aaae853641b2bbebaeb0870a6a9e93fc0d9c9 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 20:12:05 +0900 Subject: [PATCH 122/133] feat(viewer): sortable/paginated/searchable games table + lazy LLM trace fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../api/games/[gameId]/trace/[index]/route.ts | 28 ++ .../src/app/games/[gameId]/GameViewClient.tsx | 45 +++ viewer/src/app/games/[gameId]/page.tsx | 19 +- viewer/src/app/sample/page.tsx | 15 +- viewer/src/components/GameView.tsx | 64 +++- viewer/src/components/GamesTable.tsx | 289 +++++++++++++++-- viewer/src/components/PhaseSection.tsx | 256 ++------------- viewer/src/components/TraceDrawer.tsx | 132 +++++++- viewer/src/lib/data.ts | 97 +++++- viewer/src/lib/match.ts | 300 ++++++++++++++++++ 10 files changed, 987 insertions(+), 258 deletions(-) create mode 100644 viewer/src/app/api/games/[gameId]/trace/[index]/route.ts create mode 100644 viewer/src/app/games/[gameId]/GameViewClient.tsx create mode 100644 viewer/src/lib/match.ts diff --git a/viewer/src/app/api/games/[gameId]/trace/[index]/route.ts b/viewer/src/app/api/games/[gameId]/trace/[index]/route.ts new file mode 100644 index 0000000..7b2450d --- /dev/null +++ b/viewer/src/app/api/games/[gameId]/trace/[index]/route.ts @@ -0,0 +1,28 @@ +// Lazy fetch endpoint for one LLM trace entry's heavy fields. +// +// The detail page strips `system_prompt` / `user_prompt` / `response` +// from the SSR payload to keep the initial HTML small. When the user +// opens TraceDrawer, it calls this route with the trace's index in +// `data.trace[]` to fetch just those three strings on demand. +// +// `index` is the array position — stable for the lifetime of the +// exported JSON file, which is itself immutable post-game. + +import { NextResponse } from "next/server"; +import { loadTraceHeavyFields } from "@/lib/data"; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ gameId: string; index: string }> }, +) { + const { gameId, index } = await params; + const idx = Number(index); + if (!Number.isInteger(idx) || idx < 0) { + return NextResponse.json({ error: "invalid index" }, { status: 400 }); + } + const heavy = await loadTraceHeavyFields(gameId, idx); + if (heavy === null) { + return NextResponse.json({ error: "not found" }, { status: 404 }); + } + return NextResponse.json(heavy); +} diff --git a/viewer/src/app/games/[gameId]/GameViewClient.tsx b/viewer/src/app/games/[gameId]/GameViewClient.tsx new file mode 100644 index 0000000..09a0600 --- /dev/null +++ b/viewer/src/app/games/[gameId]/GameViewClient.tsx @@ -0,0 +1,45 @@ +"use client"; + +import GameView from "@/components/GameView"; +import type { TraceFetcher } from "@/components/TraceDrawer"; +import type { MatchMaps } from "@/lib/match"; +import type { GameSample } from "@/lib/types"; + +/** + * Tiny client wrapper around `GameView`. Lives next to the server + * `page.tsx` so the colocated `gameId` is closed over by the lazy + * trace fetcher without leaking the URL into `GameView`'s prop API. + * + * Why a separate file rather than inlining: server components can't + * ship a function across the boundary, so the closure has to live in + * a "use client" file. + */ +export default function GameViewClient({ + gameId, + data, + matches, +}: { + gameId: string; + data: GameSample; + matches: MatchMaps; +}) { + const fetcher: TraceFetcher = async (index) => { + const res = await fetch(`/api/games/${gameId}/trace/${index}`); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + return (await res.json()) as { + system_prompt: string; + user_prompt: string; + response: string | null; + }; + }; + return ( + + ); +} diff --git a/viewer/src/app/games/[gameId]/page.tsx b/viewer/src/app/games/[gameId]/page.tsx index de6f049..6b7c863 100644 --- a/viewer/src/app/games/[gameId]/page.tsx +++ b/viewer/src/app/games/[gameId]/page.tsx @@ -1,6 +1,6 @@ import { notFound } from "next/navigation"; -import GameView from "@/components/GameView"; -import { loadGameById } from "@/lib/data"; +import GameViewClient from "./GameViewClient"; +import { loadGameWithMatches } from "@/lib/data"; export default async function GameDetailPage({ params, @@ -8,9 +8,18 @@ export default async function GameDetailPage({ params: Promise<{ gameId: string }>; }) { const { gameId } = await params; - const data = await loadGameById(gameId); - if (data === null) { + const result = await loadGameWithMatches(gameId); + if (result === null) { notFound(); } - return ; + // The client wrapper builds the lazy-fetch closure from `gameId`. + // Server components can't ship a function as a prop, so the fetcher + // is constructed in a "use client" sibling instead of here. + return ( + + ); } diff --git a/viewer/src/app/sample/page.tsx b/viewer/src/app/sample/page.tsx index 8e911ed..fd82b10 100644 --- a/viewer/src/app/sample/page.tsx +++ b/viewer/src/app/sample/page.tsx @@ -3,5 +3,18 @@ import { loadSample } from "@/lib/data"; export default async function SampleGamePage() { const data = await loadSample(); - return ; + // Sample page intentionally skips the slim/lazy treatment — the + // bundled JSON is small and we want the demo to render fully without + // an API round-trip. Pass `matches=null` so GameView falls back to + // computing the match map in-browser; pass `traceFetcher=null` so + // TraceDrawer reads the heavy fields directly from each TraceEntry. + return ( + + ); } diff --git a/viewer/src/components/GameView.tsx b/viewer/src/components/GameView.tsx index c8ffec6..c4ce4ab 100644 --- a/viewer/src/components/GameView.tsx +++ b/viewer/src/components/GameView.tsx @@ -14,18 +14,70 @@ import PhaseSection from "@/components/PhaseSection"; import SeatGrid from "@/components/SeatGrid"; import StatsPanel from "@/components/StatsPanel"; import TraceDrawer from "@/components/TraceDrawer"; +import { buildMatchMaps, type MatchMaps } from "@/lib/match"; import type { GameSample, TraceEntry } from "@/lib/types"; export default function GameView({ data, + matches, + traceFetcher, backHref, sampleBadge = false, }: { data: GameSample; + /** + * Precomputed `(eventKey → traceIndex)` map. Required because the + * client never reruns the matchers — we ship the lookup table from + * the server load step (see `lib/data.ts::loadGameWithMatches`). Set + * to `null` (sample page) to fall through to in-browser computation. + */ + matches: MatchMaps | null; + /** + * Lazy heavy-field loader for the trace drawer. Provided when the + * page strips ``system_prompt`` / ``user_prompt`` / ``response`` from + * the SSR payload to keep the initial HTML small. ``null`` = the + * trace already carries its heavy fields (sample page) and the + * drawer renders synchronously. + */ + traceFetcher?: + | ((index: number) => Promise<{ + system_prompt: string; + user_prompt: string; + response: string | null; + }>) + | null; backHref?: string; sampleBadge?: boolean; }) { - const [openTrace, setOpenTrace] = React.useState(null); + const [openTrace, setOpenTrace] = React.useState<{ + entry: TraceEntry; + index: number; + } | null>(null); + + // Sample page passes `matches=null` (it doesn't pre-slim) — fall + // through to in-browser computation so the lightbulb buttons keep + // working without a server round-trip on the static demo. + const resolvedMatches: MatchMaps = React.useMemo(() => { + if (matches !== null) return matches; + return buildMatchMaps( + data.phases, + data.trace, + data.arbiter_decisions ?? [], + data.seats, + ); + }, [data, matches]); + + // Index lookup keeps drawer's lazy fetch keyed on the canonical + // position in `data.trace[]`. Reference equality on TraceEntry is + // what the server precompute used to assign indices, so we mirror + // that here with `indexOf`. + const handleOpen = React.useCallback( + (entry: TraceEntry) => { + const index = data.trace.indexOf(entry); + if (index >= 0) setOpenTrace({ entry, index }); + }, + [data.trace], + ); return ( @@ -68,7 +120,8 @@ export default function GameView({ seats={data.seats} trace={data.trace} arbiterDecisions={data.arbiter_decisions ?? []} - onOpenTrace={setOpenTrace} + matches={resolvedMatches} + onOpenTrace={handleOpen} /> ))} @@ -79,7 +132,12 @@ export default function GameView({ - setOpenTrace(null)} /> + setOpenTrace(null)} + /> ); } diff --git a/viewer/src/components/GamesTable.tsx b/viewer/src/components/GamesTable.tsx index 0a73dda..243aeb5 100644 --- a/viewer/src/components/GamesTable.tsx +++ b/viewer/src/components/GamesTable.tsx @@ -1,15 +1,23 @@ +"use client"; + import Link from "next/link"; -import type { ReactNode } from "react"; +import { useDeferredValue, useMemo, useState, type ReactNode } from "react"; import Box from "@mui/material/Box"; import Chip from "@mui/material/Chip"; +import InputAdornment from "@mui/material/InputAdornment"; import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; import type { TableCellProps } from "@mui/material/TableCell"; import TableHead from "@mui/material/TableHead"; +import TablePagination from "@mui/material/TablePagination"; import TableRow from "@mui/material/TableRow"; +import TableSortLabel from "@mui/material/TableSortLabel"; +import TextField from "@mui/material/TextField"; import Typography from "@mui/material/Typography"; +import SearchIcon from "@mui/icons-material/Search"; import { formatDuration, formatJstDate, @@ -18,34 +26,194 @@ import { } from "@/lib/format"; import type { GameSummary } from "@/lib/data"; +// Columns the table can sort on. Pinned at module scope so the +// header / row code stays in lock-step with the comparator switch. +type SortKey = + | "id" + | "victory" + | "discussion_mode" + | "created_at_ms" + | "duration_ms" + | "seat_count" + | "human_count" + | "llm_count" + | "llm_call_count" + | "total_tokens" + | "total_latency_ms"; + +type SortDir = "asc" | "desc"; + +interface ColumnDef { + key: SortKey; + label: string; + align?: TableCellProps["align"]; + /** Default direction the first time this column is clicked. Numeric + * columns default to ``desc`` because "biggest first" is the typical + * read; text-ish columns default to ``asc``. */ + initialDir: SortDir; +} + +const COLUMNS: readonly ColumnDef[] = [ + { key: "id", label: "Game ID", initialDir: "asc" }, + { key: "victory", label: "勝敗", initialDir: "asc" }, + { key: "discussion_mode", label: "モード", initialDir: "asc" }, + { key: "created_at_ms", label: "開始 (JST)", initialDir: "desc" }, + { key: "duration_ms", label: "所要", align: "right", initialDir: "desc" }, + { key: "seat_count", label: "席", align: "center", initialDir: "desc" }, + { key: "human_count", label: "人間", align: "right", initialDir: "desc" }, + { key: "llm_count", label: "LLM", align: "right", initialDir: "desc" }, + { + key: "llm_call_count", + label: "LLM 呼び出し", + align: "right", + initialDir: "desc", + }, + { + key: "total_tokens", + label: "合計トークン", + align: "right", + initialDir: "desc", + }, + { + key: "total_latency_ms", + label: "合計レイテンシ", + align: "right", + initialDir: "desc", + }, +]; + +const VICTORY_RANK: Record = { + village: 0, + wolf: 1, +}; + +const ROWS_PER_PAGE_OPTIONS = [10, 25, 50, 100] as const; + export default function GamesTable({ games }: { games: GameSummary[] }) { + // Sort defaults to ``created_at_ms desc`` so the freshest game stays + // on top — matches the previous always-newest-first behavior the page + // had before it became sortable. + const [sortKey, setSortKey] = useState("created_at_ms"); + const [sortDir, setSortDir] = useState("desc"); + const [search, setSearch] = useState(""); + // ``useDeferredValue`` keeps typing latency low when filtering hundreds + // of rows — React paints the input update before the (potentially) + // expensive filter+sort+slice runs. + const deferredSearch = useDeferredValue(search); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + + const filtered = useMemo(() => { + const needle = deferredSearch.trim().toLowerCase(); + if (!needle) return games; + return games.filter((g) => g.id.toLowerCase().includes(needle)); + }, [games, deferredSearch]); + + const sorted = useMemo(() => { + const copy = filtered.slice(); + copy.sort(makeComparator(sortKey, sortDir)); + return copy; + }, [filtered, sortKey, sortDir]); + + // Clamp the page when the filter shrinks the result set below the + // current offset. Otherwise ``TablePagination`` would happily render + // a page with zero rows and the user has to click "<" to recover. + const maxPage = Math.max(0, Math.ceil(sorted.length / rowsPerPage) - 1); + const safePage = Math.min(page, maxPage); + const pageStart = safePage * rowsPerPage; + const pageRows = sorted.slice(pageStart, pageStart + rowsPerPage); + if (games.length === 0) { return null; // empty state is rendered by the page } + const handleSort = (key: SortKey, defaultDir: SortDir) => { + setPage(0); + setSortKey((prev) => { + if (prev === key) { + // Same column → flip direction. + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + return prev; + } + // New column → use the column's natural initial direction. + setSortDir(defaultDir); + return key; + }); + }; + return ( - - - - - Game ID - 勝敗 - モード - 開始 (JST) - 所要 - - LLM 呼び出し - 合計トークン - 合計レイテンシ - - - - {games.map((g) => ( - - ))} - -
-
+ + { + setSearch(e.target.value); + setPage(0); + }} + placeholder="Game ID で検索 (部分一致)" + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ maxWidth: 360 }} + /> + + + + + + {COLUMNS.map((col) => ( + + handleSort(col.key, col.initialDir)} + > + {col.label} + + + ))} + + + + {pageRows.length === 0 ? ( + + + + 一致するゲームがありません + + + + ) : ( + pageRows.map((g) => ) + )} + +
+ setPage(p)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(e) => { + setRowsPerPage(parseInt(e.target.value, 10)); + setPage(0); + }} + rowsPerPageOptions={Array.from(ROWS_PER_PAGE_OPTIONS)} + labelRowsPerPage="表示件数" + labelDisplayedRows={({ from, to, count }) => + `${from}–${to} / ${count}` + } + /> +
+
); } @@ -85,6 +253,12 @@ function GameRow({ game }: { game: GameSummary }) { {game.seat_count} + + {game.human_count} + + + {game.llm_count} + {game.llm_call_count} @@ -169,3 +343,72 @@ export function EmptyGamesState() { ); } + +/** + * Build a typed comparator for the given sort key + direction. + * + * Most columns are numeric (timestamps, counts, totals). The three + * non-numeric ones get explicit handling: + * + * * ``id`` / ``discussion_mode`` — string compare via ``localeCompare``. + * * ``victory`` — folded onto a stable rank (``village=0``, ``wolf=1``, + * ``null=2``) so the sort is deterministic and unfinished games land + * together at one end. + * * ``duration_ms`` — null (game still running) is sorted to the end + * regardless of direction so an in-progress row never displaces a + * completed one in either view. + */ +function makeComparator( + key: SortKey, + dir: SortDir, +): (a: GameSummary, b: GameSummary) => number { + const sign = dir === "asc" ? 1 : -1; + return (a, b) => { + // Pin in-progress / unfinished rows to the bottom regardless of + // direction so an active game never displaces a completed one in + // either view. Apply BEFORE the sign multiplier so flipping the + // direction doesn't bubble nulls back to the top. + const nullCmp = compareNullsLast(key, a, b); + if (nullCmp !== null) return nullCmp; + const cmp = compareByKey(key, a, b); + return sign * cmp; + }; +} + +function compareByKey(key: SortKey, a: GameSummary, b: GameSummary): number { + switch (key) { + case "id": + return a.id.localeCompare(b.id); + case "discussion_mode": + return a.discussion_mode.localeCompare(b.discussion_mode); + case "victory": + return victoryRank(a.victory) - victoryRank(b.victory); + case "duration_ms": + // Null pairs handled by `compareNullsLast`; here both sides are + // guaranteed non-null so the cast is safe. + return (a.duration_ms as number) - (b.duration_ms as number); + default: + return (a[key] as number) - (b[key] as number); + } +} + +function victoryRank(v: GameSummary["victory"]): number { + if (v === null) return 2; + return VICTORY_RANK[v] ?? 3; +} + +/** Returns a direction-independent ordering when one side is null, or + * ``null`` when neither side needs the special-case (caller falls + * through to the regular comparator + direction sign). */ +function compareNullsLast( + key: SortKey, + a: GameSummary, + b: GameSummary, +): number | null { + if (key !== "duration_ms") return null; + const aNull = a.duration_ms === null; + const bNull = b.duration_ms === null; + if (!aNull && !bNull) return null; + if (aNull && bNull) return 0; + return aNull ? 1 : -1; +} diff --git a/viewer/src/components/PhaseSection.tsx b/viewer/src/components/PhaseSection.tsx index 896a924..8efdcf0 100644 --- a/viewer/src/components/PhaseSection.tsx +++ b/viewer/src/components/PhaseSection.tsx @@ -22,6 +22,13 @@ import { seatLabel, sourceJa, } from "@/lib/format"; +import { + nightActionKey, + speechKey, + voteKey, + wolfChatKey, + type MatchMaps, +} from "@/lib/match"; import type { ArbiterDecision, PhaseSection as PhaseSectionType, @@ -36,15 +43,17 @@ export default function PhaseSection({ seats, trace, arbiterDecisions, + matches, onOpenTrace, }: { phase: PhaseSectionType; seats: Seat[]; trace: TraceEntry[]; arbiterDecisions: ArbiterDecision[]; + matches: MatchMaps; onOpenTrace: (entry: TraceEntry) => void; }) { - const events = buildTimeline(phase, seats, trace, arbiterDecisions); + const events = buildTimeline(phase, trace, arbiterDecisions, matches); return ( @@ -121,21 +130,40 @@ type TimelineEvent = function buildTimeline( phase: PhaseSectionType, - seats: Seat[], trace: TraceEntry[], arbiterDecisions: ArbiterDecision[], + matches: MatchMaps, ): TimelineEvent[] { + // The matchers themselves moved to `lib/match.ts` and ran server-side + // at load time — `matches.trace[eventKey]` is just the precomputed + // index into `trace[]`. Building the timeline is now an O(events) + // dictionary lookup instead of an O(events * trace) scan, and the + // client never had to ship the heavy `system_prompt` / `user_prompt` + // / `response` strings just to run the matcher. + const lookup = (key: string): TraceEntry | null => { + const idx = matches.trace[key]; + return idx == null ? null : (trace[idx] ?? null); + }; + const arbiterById = new Map( + arbiterDecisions.map((d) => [d.request_id, d]), + ); + const arbiterFor = (key: string): ArbiterDecision | null => { + const reqId = matches.arbiter[key]; + return reqId == null ? null : (arbiterById.get(reqId) ?? null); + }; + const events: TimelineEvent[] = []; for (const log of phase.public_logs) { events.push({ kind: "log", ts: log.created_at_ms, data: log }); } for (const sp of phase.speech_events) { + const key = speechKey(sp); events.push({ kind: "speech", ts: sp.created_at_ms, data: sp, - trace: matchTraceForSpeech(sp, phase, trace), - arbiter: matchArbiterForSpeech(sp, phase, arbiterDecisions), + trace: lookup(key), + arbiter: arbiterFor(key), }); } for (const v of phase.votes) { @@ -143,7 +171,7 @@ function buildTimeline( kind: "vote", ts: v.submitted_at_ms, data: v, - trace: matchTraceForVote(v, phase, trace, seats), + trace: lookup(voteKey(v)), }); } for (const na of phase.night_actions) { @@ -151,7 +179,7 @@ function buildTimeline( kind: "night_action", ts: na.submitted_at_ms, data: na, - trace: matchTraceForNightAction(na, phase, trace), + trace: lookup(nightActionKey(na)), }); } for (const wc of phase.wolf_chat_logs ?? []) { @@ -159,227 +187,13 @@ function buildTimeline( kind: "wolf_chat", ts: wc.created_at_ms, data: wc, - trace: matchTraceForWolfChat(wc, phase, trace), + trace: lookup(wolfChatKey(wc)), }); } events.sort((a, b) => a.ts - b.ts); return events; } -/** - * Match an `npc_generated` SpeechEvent back to the Master-side - * `SpeakRequest` dispatch that produced it. The DB doesn't carry a - * direct foreign key (request_id is in `npc_speak_results`, not in - * `speech_events`), so we match on: - * - * 1. phase_id (canonical {gid}::dayN::PHASE::seq) - * 2. seat_no — the NPC's assigned seat - * 3. result_text equality with the spoken utterance — disambiguates - * multiple dispatches to the same seat in the same phase - * - * Falls back to the most recent dispatch for the seat in the phase - * when text doesn't match (text may differ slightly: leading whitespace, - * the NPC's first/last words trimmed, etc.). Returns `null` when the - * speech is from a human / text channel, since arbiter dispatch only - * fires for `npc_generated`. - */ -function matchArbiterForSpeech( - sp: SpeechEvent, - phase: PhaseSectionType, - decisions: ArbiterDecision[], -): ArbiterDecision | null { - if (sp.source !== "npc_generated" || sp.speaker_seat == null) return null; - const phaseMatches = (d: ArbiterDecision) => - d.phase_id.includes(`::day${phase.day}::${phase.phase}`); - const seatMatches = (d: ArbiterDecision) => d.seat_no === sp.speaker_seat; - const exactText = - decisions.find( - (d) => - phaseMatches(d) && seatMatches(d) && d.result_text === sp.text, - ) ?? null; - if (exactText) return exactText; - const sameSeat = decisions.filter((d) => phaseMatches(d) && seatMatches(d)); - if (sameSeat.length === 0) return null; - // Latest dispatch for the seat in this phase, by created_at_ms. - return sameSeat.reduce((best, cur) => - cur.created_at_ms > best.created_at_ms ? cur : best, - ); -} - -function matchTraceForSpeech( - sp: SpeechEvent, - phase: PhaseSectionType, - trace: TraceEntry[], -): TraceEntry | null { - if (sp.speaker_seat == null) return null; - // Per-source matcher. Each speech source maps to a specific trace role - // because the LLM call shape differs: - // npc_generated → role=npc_speech, response is a JSON envelope whose - // `text` field equals the spoken utterance. - // voice_stt → role=voice_stt, the analyzer step has the - // transcription as user_prompt and JSON analysis as - // response. Match by user_prompt+seat+segment. - // text → role=text_analysis, user_prompt IS the typed message. - const phaseMatches = (t: TraceEntry) => - t.day === phase.day && - (t.phase === phase.phase || t.phase?.includes(phase.phase)); - const needle = sp.text.slice(0, 20); - - if (sp.source === "npc_generated") { - return ( - trace.find( - (t) => - t.role === "npc_speech" && - phaseMatches(t) && - actorSeat(t) === sp.speaker_seat && - responseContains(t, needle), - ) ?? null - ); - } - if (sp.source === "voice_stt") { - // The voice path emits two trace lines per segment (transcribe + analyze). - // Prefer the analyze step because it carries both the transcript and the - // structured fields the user wants to inspect. - const analyze = - trace.find( - (t) => - t.role === "voice_stt" && - phaseMatches(t) && - actorSeat(t) === sp.speaker_seat && - t.metadata?.step === "analyze" && - userPromptContains(t, needle), - ) ?? null; - if (analyze !== null) return analyze; - return ( - trace.find( - (t) => - t.role === "voice_stt" && - phaseMatches(t) && - actorSeat(t) === sp.speaker_seat && - userPromptContains(t, needle), - ) ?? null - ); - } - // source === "text" - return ( - trace.find( - (t) => - t.role === "text_analysis" && - phaseMatches(t) && - actorSeat(t) === sp.speaker_seat && - userPromptContains(t, needle), - ) ?? null - ); -} - -// Vote / night-action LLM calls land in the trace under two distinct -// `role` values depending on which side of the WS the decision LLM -// runs on: -// - rounds mode: Master's `LLMAdapter._ask` calls the gameplay -// decider → role="gameplay", metadata.task="vote"|"night_action". -// - reactive_voice mode: each NPC bot decides for its own seat via -// `decision_service` → role="npc_decision", -// metadata.task="vote"|"night_action" (added on the NPC client -// wrap-context, see npc/runtime/client.py). -// The viewer accepts both so the lightbulb buttons attach in either -// mode. `phase` matching is loose for npc_decision because the trace -// records the canonical phase_id (`{gid}::dayN::PHASE::seq`) rather -// than the bare PhaseEnum value. -function isDecisionRole(t: TraceEntry): boolean { - return t.role === "gameplay" || t.role === "npc_decision"; -} - -function phaseMatchesLoose(t: TraceEntry, phase: PhaseSectionType): boolean { - if (t.day !== phase.day) return false; - if (t.phase === phase.phase) return true; - return typeof t.phase === "string" && t.phase.includes(`::${phase.phase}`); -} - -function matchTraceForVote( - v: Vote, - phase: PhaseSectionType, - trace: TraceEntry[], - seats: Seat[], -): TraceEntry | null { - const targetSeat = findSeat(seats, v.target_seat); - if (!targetSeat) return null; - // For role="gameplay" we additionally key on the response containing - // `席N` because the rounds-mode dispatcher batches all voters into one - // task and the response payload is the only thing tying a trace line - // to a specific (voter, target) pair. For role="npc_decision" the - // trace already has a 1:1 actor→seat binding so we don't need it. - return ( - trace.find( - (t) => - isDecisionRole(t) && - phaseMatchesLoose(t, phase) && - actorSeat(t) === v.voter_seat && - t.metadata?.task === "vote" && - (t.role !== "gameplay" || responseContains(t, `席${v.target_seat}`)), - ) ?? null - ); -} - -function matchTraceForNightAction( - na: PhaseSectionType["night_actions"][number], - phase: PhaseSectionType, - trace: TraceEntry[], -): TraceEntry | null { - return ( - trace.find( - (t) => - isDecisionRole(t) && - phaseMatchesLoose(t, phase) && - actorSeat(t) === na.actor_seat && - t.metadata?.task === "night_action" && - (t.metadata?.action_kind == null || - t.metadata?.action_kind === na.kind), - ) ?? null - ); -} - -function matchTraceForWolfChat( - wc: WolfChatLog, - phase: PhaseSectionType, - trace: TraceEntry[], -): TraceEntry | null { - // Pair each wolf-chat utterance with its decision LLM call. Since - // the request_id isn't in the DB row, the closest stable match is - // (phase, day, actor_seat, task=wolf_chat) — picking the first - // unpaired trace whose timestamp is at or before the utterance. - // Multiple wolves coordinate sequentially so per-actor matching is - // accurate enough; viewer prefers the LATEST candidate before the - // utterance to handle retry / fallback cycles. - let best: TraceEntry | null = null; - let bestTs = -Infinity; - for (const t of trace) { - if (!isDecisionRole(t)) continue; - if (!phaseMatchesLoose(t, phase)) continue; - if (actorSeat(t) !== wc.actor_seat) continue; - if (t.metadata?.task !== "wolf_chat") continue; - const ts = t.ts ? new Date(t.ts).getTime() : 0; - if (ts <= wc.created_at_ms && ts > bestTs) { - best = t; - bestTs = ts; - } - } - return best; -} - -function actorSeat(t: TraceEntry): number | null { - if (!t.actor) return null; - const m = t.actor.match(/seat=(\d+)/); - return m ? Number(m[1]) : null; -} - -function responseContains(t: TraceEntry, needle: string): boolean { - return t.response != null && t.response.includes(needle); -} - -function userPromptContains(t: TraceEntry, needle: string): boolean { - return typeof t.user_prompt === "string" && t.user_prompt.includes(needle); -} - function EventRow({ event, seats, diff --git a/viewer/src/components/TraceDrawer.tsx b/viewer/src/components/TraceDrawer.tsx index 9f097a8..991b6d1 100644 --- a/viewer/src/components/TraceDrawer.tsx +++ b/viewer/src/components/TraceDrawer.tsx @@ -1,7 +1,9 @@ "use client"; +import * as React from "react"; import Box from "@mui/material/Box"; import Chip from "@mui/material/Chip"; +import CircularProgress from "@mui/material/CircularProgress"; import Drawer from "@mui/material/Drawer"; import IconButton from "@mui/material/IconButton"; import Stack from "@mui/material/Stack"; @@ -11,11 +13,35 @@ import CloseIcon from "@mui/icons-material/Close"; import { formatJstTime, formatLatency, formatTokens } from "@/lib/format"; import type { TraceEntry } from "@/lib/types"; +/** + * Heavy fields the drawer renders. Sourced either from `entry` itself + * (sample page, no slimming) or fetched lazily from + * `/api/games/[gameId]/trace/[index]` on first open. + */ +interface HeavyFields { + system_prompt: string; + user_prompt: string; + response: string | null; +} + +export type TraceFetcher = (index: number) => Promise; + export default function TraceDrawer({ entry, + index, + fetcher, onClose, }: { entry: TraceEntry | null; + index: number | null; + /** + * Lazy-load callback for heavy fields. ``null`` ⇒ the entry already + * carries them (no fetch needed); otherwise the drawer calls + * `fetcher(index)` once on open and shows a spinner until it + * resolves. Errors render an inline message; the user can close and + * retry. + */ + fetcher: TraceFetcher | null; onClose: () => void; }) { return ( @@ -27,16 +53,27 @@ export default function TraceDrawer({ paper: { sx: { width: { xs: "100%", md: 720 } } }, }} > - {entry && } + {entry !== null && ( + + )} ); } function Body({ entry, + index, + fetcher, onClose, }: { entry: TraceEntry; + index: number | null; + fetcher: TraceFetcher | null; onClose: () => void; }) { const ts = (() => { @@ -47,6 +84,57 @@ function Body({ } })(); + // Decide whether the entry is already loaded with its heavy strings + // or if we need to lazy-fetch them. The slim payload puts empty + // strings in `system_prompt` / `user_prompt`; if the original was + // genuinely empty (rare, but theoretically possible for a malformed + // call) we'd still try to fetch — that just returns an empty + // payload, harmless. The boolean is cheap to compute and cleaner + // than threading an explicit `slim: true` flag through the prop + // chain. + const needsFetch = + fetcher !== null && + index !== null && + entry.system_prompt === "" && + entry.user_prompt === ""; + + const [heavy, setHeavy] = React.useState(null); + const [error, setError] = React.useState(null); + const [loading, setLoading] = React.useState(false); + + React.useEffect(() => { + // Reset on entry change so a previous payload doesn't bleed across + // drawer-open events. Skip the fetch when the entry already has + // its heavy fields. + setHeavy(null); + setError(null); + if (!needsFetch) return; + setLoading(true); + let cancelled = false; + fetcher!(index!) + .then((data) => { + if (!cancelled) setHeavy(data); + }) + .catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [entry, index, needsFetch, fetcher]); + + // Pull from `heavy` when the lazy fetch resolved, otherwise from + // `entry` (sample-page / non-slim path). The fallback mirrors the + // pre-refactor behavior so the sample page renders identically. + const systemPrompt = heavy?.system_prompt ?? entry.system_prompt; + const userPrompt = heavy?.user_prompt ?? entry.user_prompt; + const response = heavy?.response ?? entry.response; + return ( @@ -123,14 +211,33 @@ function Body({ )} + {error && ( + + + プロンプトの読み込みに失敗 + +
{error}
+
+ )} +
-
{entry.system_prompt}
+
-
{entry.user_prompt}
+
-
{entry.response ?? "(no response)"}
+
{entry.metadata && Object.keys(entry.metadata).length > 0 && (
@@ -186,3 +293,20 @@ function Section({ ); } + +function PromptBody({ loading, text }: { loading: boolean; text: string }) { + if (loading) { + return ( + + + 読み込み中… + + ); + } + return
{text}
; +} diff --git a/viewer/src/lib/data.ts b/viewer/src/lib/data.ts index cdb2a26..f464dc2 100644 --- a/viewer/src/lib/data.ts +++ b/viewer/src/lib/data.ts @@ -1,6 +1,7 @@ import { readFile, readdir, stat } from "node:fs/promises"; import path from "node:path"; -import type { DiscussionMode, GameSample } from "./types"; +import { buildMatchMaps, type MatchMaps } from "./match"; +import type { DiscussionMode, GameSample, TraceEntry } from "./types"; /** * Where exported game JSONs live. Populated automatically by the bot's @@ -22,6 +23,10 @@ export interface GameSummary { ended_at_ms: number | null; duration_ms: number | null; seat_count: number; + /** Number of seats whose ``is_llm`` is false (i.e., real Discord users). */ + human_count: number; + /** Number of seats whose ``is_llm`` is true (NPC personas). */ + llm_count: number; llm_call_count: number; total_tokens: number; total_latency_ms: number; @@ -116,6 +121,85 @@ export async function loadSample(): Promise { return JSON.parse(raw) as GameSample; } +/** + * What `loadGameWithMatches` returns. + * + * `data.trace[i]` has the heavy fields (`system_prompt`, `user_prompt`, + * `response`) replaced by empty strings / null. The full versions are + * available via the `/api/games/[gameId]/trace/[index]` route. This + * trims hundreds of KB to several MB off the SSR HTML payload for + * games with non-trivial trace counts — historically the dominant + * cost of opening a game detail page. + * + * `matches.trace[eventKey]` returns the index into `data.trace` (i.e. + * the position of the matched trace entry). PhaseSection consumes this + * map and never re-runs the matchers client-side. `matches.arbiter` + * mirrors the same shape for speech → arbiter dispatch lookup. + * + * Returns `null` when the game JSON does not exist. + */ +export interface GameWithMatches { + data: GameSample; + matches: MatchMaps; +} + +export async function loadGameWithMatches( + gameId: string, +): Promise { + const data = await loadGameById(gameId); + if (data === null) return null; + // Run the matchers BEFORE slimming so they see the heavy fields. + const matches = buildMatchMaps( + data.phases, + data.trace, + data.arbiter_decisions ?? [], + data.seats, + ); + return { data: { ...data, trace: slimTrace(data.trace) }, matches }; +} + +/** + * Heavy-field accessor used by the lazy-load API route. Re-reads the + * source JSON because slimmed copies don't keep the original strings + * around — the cost of one cold disk read per drawer-open is dwarfed + * by the SSR payload savings on every page load. + */ +export interface TraceHeavyFields { + system_prompt: string; + user_prompt: string; + response: string | null; +} + +export async function loadTraceHeavyFields( + gameId: string, + index: number, +): Promise { + const data = await loadGameById(gameId); + if (data === null) return null; + if (!Number.isInteger(index) || index < 0 || index >= data.trace.length) { + return null; + } + const t = data.trace[index]; + return { + system_prompt: t.system_prompt, + user_prompt: t.user_prompt, + response: t.response, + }; +} + +function slimTrace(trace: TraceEntry[]): TraceEntry[] { + // Replace heavy strings with empty placeholders rather than + // ``undefined`` so the client type stays the same and existing + // ``entry.system_prompt`` accesses stay valid until they explicitly + // opt into the lazy fetch. + return trace.map((t) => ({ + ...t, + system_prompt: "", + user_prompt: "", + response: t.response === null ? null : "", + })); +} + function summarize(data: GameSample, mtimeMs: number): GameSummary { let totalTokens = 0; let totalLatencyMs = 0; @@ -127,6 +211,15 @@ function summarize(data: GameSample, mtimeMs: number): GameSummary { data.game.ended_at_ms != null ? data.game.ended_at_ms - data.game.created_at_ms : null; + let humanCount = 0; + let llmCount = 0; + for (const seat of data.seats) { + if (seat.is_llm) { + llmCount += 1; + } else { + humanCount += 1; + } + } return { id: data.game.id, discussion_mode: data.game.discussion_mode, @@ -135,6 +228,8 @@ function summarize(data: GameSample, mtimeMs: number): GameSummary { ended_at_ms: data.game.ended_at_ms, duration_ms: duration, seat_count: data.seats.length, + human_count: humanCount, + llm_count: llmCount, llm_call_count: data.trace.length, total_tokens: totalTokens, total_latency_ms: totalLatencyMs, diff --git a/viewer/src/lib/match.ts b/viewer/src/lib/match.ts new file mode 100644 index 0000000..76a5c41 --- /dev/null +++ b/viewer/src/lib/match.ts @@ -0,0 +1,300 @@ +// Pure trace ↔ event matchers. Extracted from the original PhaseSection +// inline implementation so the server can precompute the matches at game +// load time and ship only `{eventKey: traceIndex}` to the client. The +// client never needs to look at heavy ``system_prompt`` / ``user_prompt`` +// / ``response`` strings to build the lightbulb buttons — the lookup is +// O(1) by event key. +// +// Why this lives here and not in `PhaseSection`: +// * It must be importable from a Node-only ``loadGame`` path (no React). +// * Both server (precompute) and the trace API route handler need the +// same logic to keep matches deterministic across redeploys. + +import type { + ArbiterDecision, + NightAction, + PhaseSection, + Seat, + SpeechEvent, + TraceEntry, + Vote, +} from "./types"; + +export type WolfChatLog = NonNullable[number]; + +/** + * Two parallel maps of ``eventKey → traceIndex``. Built once at server + * render time per game and shipped down with the slim payload. + * + * Keys are stable strings so the maps survive JSON serialization and + * map cleanly into ``Record`` for the wire. + * + * * ``trace`` — primary map. Speech / vote / action / wolf-chat events. + * * ``arbiter`` — secondary map. Speech events → arbiter decision id. + * Kept separate because the value type differs (request_id, not int). + */ +export interface MatchMaps { + trace: Record; + arbiter: Record; +} + +// ───────────────────────────────────────────── event-key derivation +// +// Each event-trace pair needs a deterministic string key. Speech events +// already carry a server-issued ``event_id``; the others get composed +// from their natural primary key columns. + +export function speechKey(sp: SpeechEvent): string { + return `speech:${sp.event_id}`; +} + +export function voteKey(v: Vote): string { + // (day, round, voter, target) is the unique key in the votes table. + // Include ``submitted_at_ms`` so a re-vote (same voter changes target) + // doesn't collide with the original. + return `vote:${v.day}:${v.round}:${v.voter_seat}:${v.target_seat ?? "null"}:${v.submitted_at_ms}`; +} + +export function nightActionKey(na: NightAction): string { + return `night:${na.day}:${na.actor_seat}:${na.kind}:${na.target_seat ?? "null"}:${na.submitted_at_ms}`; +} + +export function wolfChatKey(wc: WolfChatLog): string { + // No native id; compose from (actor, ts, text-prefix). Text prefix + // keeps two same-second utterances disambiguated. + return `wolfchat:${wc.actor_seat}:${wc.created_at_ms}:${wc.text.slice(0, 20)}`; +} + +// ───────────────────────────────────────────── precompute entry point + +/** + * Precompute every ``event → trace_index`` and ``speech → arbiter`` + * mapping for a game. Single linear pass over each phase; each matcher + * is the same one PhaseSection used to run client-side, lifted verbatim + * out of that file. + */ +export function buildMatchMaps( + phases: PhaseSection[], + trace: TraceEntry[], + arbiterDecisions: ArbiterDecision[], + seats: Seat[], +): MatchMaps { + const traceMap: Record = {}; + const arbiterMap: Record = {}; + + for (const phase of phases) { + for (const sp of phase.speech_events) { + const t = matchTraceForSpeech(sp, phase, trace); + if (t !== null) { + const idx = trace.indexOf(t); + if (idx >= 0) traceMap[speechKey(sp)] = idx; + } + const a = matchArbiterForSpeech(sp, phase, arbiterDecisions); + if (a !== null) arbiterMap[speechKey(sp)] = a.request_id; + } + for (const v of phase.votes) { + const t = matchTraceForVote(v, phase, trace, seats); + if (t !== null) { + const idx = trace.indexOf(t); + if (idx >= 0) traceMap[voteKey(v)] = idx; + } + } + for (const na of phase.night_actions) { + const t = matchTraceForNightAction(na, phase, trace); + if (t !== null) { + const idx = trace.indexOf(t); + if (idx >= 0) traceMap[nightActionKey(na)] = idx; + } + } + for (const wc of phase.wolf_chat_logs ?? []) { + const t = matchTraceForWolfChat(wc, phase, trace); + if (t !== null) { + const idx = trace.indexOf(t); + if (idx >= 0) traceMap[wolfChatKey(wc)] = idx; + } + } + } + return { trace: traceMap, arbiter: arbiterMap }; +} + +// ───────────────────────────────────────────── matchers (pure) + +/** + * Match an `npc_generated` SpeechEvent back to the Master-side + * `SpeakRequest` dispatch that produced it. The DB doesn't carry a + * direct foreign key, so we match on (phase_id, seat_no, result_text) + * and fall back to the latest dispatch for the seat in the phase. + */ +export function matchArbiterForSpeech( + sp: SpeechEvent, + phase: PhaseSection, + decisions: ArbiterDecision[], +): ArbiterDecision | null { + if (sp.source !== "npc_generated" || sp.speaker_seat == null) return null; + const phaseMatches = (d: ArbiterDecision) => + d.phase_id.includes(`::day${phase.day}::${phase.phase}`); + const seatMatches = (d: ArbiterDecision) => d.seat_no === sp.speaker_seat; + const exactText = + decisions.find( + (d) => + phaseMatches(d) && seatMatches(d) && d.result_text === sp.text, + ) ?? null; + if (exactText) return exactText; + const sameSeat = decisions.filter((d) => phaseMatches(d) && seatMatches(d)); + if (sameSeat.length === 0) return null; + return sameSeat.reduce((best, cur) => + cur.created_at_ms > best.created_at_ms ? cur : best, + ); +} + +/** + * Match a SpeechEvent to its trace by source-specific role + needle + * search on either the response (npc_generated) or the user prompt + * (voice_stt / text). Required heavy fields: `response`, + * `user_prompt`. Run server-side BEFORE those fields are stripped. + */ +export function matchTraceForSpeech( + sp: SpeechEvent, + phase: PhaseSection, + trace: TraceEntry[], +): TraceEntry | null { + if (sp.speaker_seat == null) return null; + const phaseMatches = (t: TraceEntry) => + t.day === phase.day && + (t.phase === phase.phase || (t.phase?.includes(phase.phase) ?? false)); + const needle = sp.text.slice(0, 20); + + if (sp.source === "npc_generated") { + return ( + trace.find( + (t) => + t.role === "npc_speech" && + phaseMatches(t) && + actorSeat(t) === sp.speaker_seat && + responseContains(t, needle), + ) ?? null + ); + } + if (sp.source === "voice_stt") { + const analyze = + trace.find( + (t) => + t.role === "voice_stt" && + phaseMatches(t) && + actorSeat(t) === sp.speaker_seat && + t.metadata?.step === "analyze" && + userPromptContains(t, needle), + ) ?? null; + if (analyze !== null) return analyze; + return ( + trace.find( + (t) => + t.role === "voice_stt" && + phaseMatches(t) && + actorSeat(t) === sp.speaker_seat && + userPromptContains(t, needle), + ) ?? null + ); + } + return ( + trace.find( + (t) => + t.role === "text_analysis" && + phaseMatches(t) && + actorSeat(t) === sp.speaker_seat && + userPromptContains(t, needle), + ) ?? null + ); +} + +export function matchTraceForVote( + v: Vote, + phase: PhaseSection, + trace: TraceEntry[], + seats: Seat[], +): TraceEntry | null { + const targetSeat = seats.find((s) => s.seat_no === v.target_seat); + if (!targetSeat) return null; + return ( + trace.find( + (t) => + isDecisionRole(t) && + phaseMatchesLoose(t, phase) && + actorSeat(t) === v.voter_seat && + t.metadata?.task === "vote" && + (t.role !== "gameplay" || responseContains(t, `席${v.target_seat}`)), + ) ?? null + ); +} + +export function matchTraceForNightAction( + na: NightAction, + phase: PhaseSection, + trace: TraceEntry[], +): TraceEntry | null { + const naKindLower = + typeof na.kind === "string" ? na.kind.toLowerCase() : null; + return ( + trace.find((t) => { + if (!isDecisionRole(t)) return false; + if (!phaseMatchesLoose(t, phase)) return false; + if (actorSeat(t) !== na.actor_seat) return false; + if (t.metadata?.task !== "night_action") return false; + const traceKind = t.metadata?.action_kind; + if (traceKind == null) return true; + return ( + typeof traceKind === "string" && + naKindLower != null && + traceKind.toLowerCase() === naKindLower + ); + }) ?? null + ); +} + +export function matchTraceForWolfChat( + wc: WolfChatLog, + phase: PhaseSection, + trace: TraceEntry[], +): TraceEntry | null { + let best: TraceEntry | null = null; + let bestDelta = Infinity; + for (const t of trace) { + if (!isDecisionRole(t)) continue; + if (!phaseMatchesLoose(t, phase)) continue; + if (actorSeat(t) !== wc.actor_seat) continue; + if (t.metadata?.task !== "wolf_chat") continue; + const ts = t.ts ? new Date(t.ts).getTime() : 0; + const delta = Math.abs(ts - wc.created_at_ms); + if (delta < bestDelta) { + best = t; + bestDelta = delta; + } + } + return best; +} + +// ───────────────────────────────────────────── helpers + +function isDecisionRole(t: TraceEntry): boolean { + return t.role === "gameplay" || t.role === "npc_decision"; +} + +function phaseMatchesLoose(t: TraceEntry, phase: PhaseSection): boolean { + if (t.day !== phase.day) return false; + if (t.phase === phase.phase) return true; + return typeof t.phase === "string" && t.phase.includes(`::${phase.phase}`); +} + +function actorSeat(t: TraceEntry): number | null { + if (!t.actor) return null; + const m = t.actor.match(/seat=(\d+)/); + return m ? Number(m[1]) : null; +} + +function responseContains(t: TraceEntry, needle: string): boolean { + return t.response != null && t.response.includes(needle); +} + +function userPromptContains(t: TraceEntry, needle: string): boolean { + return typeof t.user_prompt === "string" && t.user_prompt.includes(needle); +} From 8983370877698a04bd97c6ac5a2f74a19e98f26d Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 20:35:30 +0900 Subject: [PATCH 123/133] refactor(viewer): simplify sort to pure toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- viewer/src/components/GamesTable.tsx | 72 +++++++++++----------------- 1 file changed, 29 insertions(+), 43 deletions(-) diff --git a/viewer/src/components/GamesTable.tsx b/viewer/src/components/GamesTable.tsx index 243aeb5..d31477a 100644 --- a/viewer/src/components/GamesTable.tsx +++ b/viewer/src/components/GamesTable.tsx @@ -47,39 +47,20 @@ interface ColumnDef { key: SortKey; label: string; align?: TableCellProps["align"]; - /** Default direction the first time this column is clicked. Numeric - * columns default to ``desc`` because "biggest first" is the typical - * read; text-ish columns default to ``asc``. */ - initialDir: SortDir; } const COLUMNS: readonly ColumnDef[] = [ - { key: "id", label: "Game ID", initialDir: "asc" }, - { key: "victory", label: "勝敗", initialDir: "asc" }, - { key: "discussion_mode", label: "モード", initialDir: "asc" }, - { key: "created_at_ms", label: "開始 (JST)", initialDir: "desc" }, - { key: "duration_ms", label: "所要", align: "right", initialDir: "desc" }, - { key: "seat_count", label: "席", align: "center", initialDir: "desc" }, - { key: "human_count", label: "人間", align: "right", initialDir: "desc" }, - { key: "llm_count", label: "LLM", align: "right", initialDir: "desc" }, - { - key: "llm_call_count", - label: "LLM 呼び出し", - align: "right", - initialDir: "desc", - }, - { - key: "total_tokens", - label: "合計トークン", - align: "right", - initialDir: "desc", - }, - { - key: "total_latency_ms", - label: "合計レイテンシ", - align: "right", - initialDir: "desc", - }, + { key: "id", label: "Game ID" }, + { key: "victory", label: "勝敗" }, + { key: "discussion_mode", label: "モード" }, + { key: "created_at_ms", label: "開始 (JST)" }, + { key: "duration_ms", label: "所要", align: "right" }, + { key: "seat_count", label: "席", align: "center" }, + { key: "human_count", label: "人間", align: "right" }, + { key: "llm_count", label: "LLM", align: "right" }, + { key: "llm_call_count", label: "LLM 呼び出し", align: "right" }, + { key: "total_tokens", label: "合計トークン", align: "right" }, + { key: "total_latency_ms", label: "合計レイテンシ", align: "right" }, ]; const VICTORY_RANK: Record = { @@ -127,18 +108,19 @@ export default function GamesTable({ games }: { games: GameSummary[] }) { return null; // empty state is rendered by the page } - const handleSort = (key: SortKey, defaultDir: SortDir) => { + const handleSort = (key: SortKey) => { setPage(0); - setSortKey((prev) => { - if (prev === key) { - // Same column → flip direction. - setSortDir((d) => (d === "asc" ? "desc" : "asc")); - return prev; - } - // New column → use the column's natural initial direction. - setSortDir(defaultDir); - return key; - }); + if (sortKey === key) { + // Same column → toggle direction. + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + return; + } + // New column → start at desc. Every subsequent click on the same + // header just flips between desc and asc, matching the typical + // spreadsheet/JIRA/GitHub-style "first click is desc, click again + // for asc" interaction. + setSortKey(key); + setSortDir("desc"); }; return ( @@ -173,8 +155,12 @@ export default function GamesTable({ games }: { games: GameSummary[] }) { > handleSort(col.key, col.initialDir)} + // Inactive columns render the indicator in the + // direction they'll start at on first click — desc + // — so the affordance previews "click me to sort + // newest/biggest first". + direction={sortKey === col.key ? sortDir : "desc"} + onClick={() => handleSort(col.key)} > {col.label} From eaab7cf58946d5a737acadacbe596a00c9d89da8 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 21:59:51 +0900 Subject: [PATCH 124/133] =?UTF-8?q?feat(prompts):=20HARD=20guard=20for=20d?= =?UTF-8?q?ay-1=20single=20CO=20+=202-1=20=E7=81=B0=E5=90=8A=E3=82=8A?= =?UTF-8?q?=E5=AE=9A=E7=9F=B3=E6=98=8E=E7=A4=BA=20+=20knight=20=E6=BD=9C?= =?UTF-8?q?=E4=BC=8F=E6=88=A6=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 直近のゲームで唯一の霊媒師 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 件パス。 --- .../prompts/templates/npc/decision_vote_user.md | 3 ++- .../prompts/templates/shared/game_rules_9p.md | 8 +++++--- src/wolfbot/prompts/templates/strategy/knight.md | 12 +++++++++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/wolfbot/prompts/templates/npc/decision_vote_user.md b/src/wolfbot/prompts/templates/npc/decision_vote_user.md index 8147758..0c10edf 100644 --- a/src/wolfbot/prompts/templates/npc/decision_vote_user.md +++ b/src/wolfbot/prompts/templates/npc/decision_vote_user.md @@ -19,7 +19,8 @@ 上記すべてを踏まえ、この投票で誰に票を入れるかを決めてください。**棄権は禁止**: 必ず候補席の中から1人を選ぶ (`target_seat` に整数、null 不可)。 判断手順: -1. 共通ルールの進行軸 (3-1 / 2-2 / 2-1 / 1-2) を確認する。占いローラーまたは霊媒ローラーが基本進行になる盤面なら、CO 群から最も偽寄りの席を優先候補にする。 +0. **HARD: 初日単独 CO の処刑禁止** をまず適用する。day 1 で、(a) 能力役職 CO 済み・(b) 通算 CO 件数=1 (対抗なし) の席が候補に含まれているなら、その席を候補から除外する。除外しなかった target_seat 選択は破綻投票として扱う。`reason` 冒頭に「day1単独CO({役職}) の {席名} は HARD 除外」を1行で明記する。 +1. 共通ルールの進行軸 (3-1 / 2-2 / 2-1 / 1-2) を確認する。**3-1 / 2-2 / 4 件超過盤面**では占い・霊媒ローラーが基本進行軸となるため、CO 群から最も偽寄りの席を優先候補にする。**2-1 / 1-2 では day 1 段階の占いローラーは強制ではなく、灰吊りも並列定石**として有力。これら盤面では「灰絞り提案」「占い先・CO を避けた灰候補」「真偽精査の継続」は村側の合理進行であり狼指標ではない。CO 群偽寄り優先は強制ではなく、灰位置に「具体的矛盾」が観測できる席があるならそちらも候補。 2. 灰位置 (役職 CO していない席) を切る前に、共通ルールの「灰位置の評価」基準で公開情報の具体的矛盾が 1 件以上挙げられるかを確認する。挙がらないなら灰には投票しない。 3. 「他者が疑っている」「議論を乱している印象」だけで追従投票しない。直前に 1〜2 人が同じ席に向かっただけで盤面が確定したことにはならない。 4. 情報が薄くて決め切れない場合でも、上の優先順位 (CO 群の偽寄り → 票筋・噛み筋で疑われる灰 → その他) で最も票を入れる価値が高い 1 人を選ぶ。 diff --git a/src/wolfbot/prompts/templates/shared/game_rules_9p.md b/src/wolfbot/prompts/templates/shared/game_rules_9p.md index 44fb862..b818778 100644 --- a/src/wolfbot/prompts/templates/shared/game_rules_9p.md +++ b/src/wolfbot/prompts/templates/shared/game_rules_9p.md @@ -37,6 +37,7 @@ - CO 通算件数は生存死亡を問わず過去に CO した全 seat の数。プロンプトの `## 公開された占い/霊媒CO結果` ブロックの件数が公式記録。「現在生存中の占い CO は 1 人」と「占い CO が単独 (通算 1 件)」は別概念。 - **単独 CO (通算 1 件、対抗なし) は原則として真寄りに置く。特に day 1 朝の単独 CO は真として扱う**: まだ霊媒結果も襲撃結果もなく、対抗が出る時間も残っており、疑う公開情報が実質ない。 - 単独 CO を切るには「具体的な公開情報の矛盾」が必要。他者の直感的疑い表明 (人間プレイヤー含む) だけでは弱い。同調投票する前に、自分視点で切る具体根拠が 1 件でも挙がるか確認する。 +- **HARD: day 1 単独 CO 役職の処刑禁止 (絶対遵守、最優先)**。day 1 の通常投票・決選投票・runoff 候補絞り込みの全てで、(a) 能力役職 (占い/霊媒/騎士) として CO 済み・(b) その役職の通算 CO 件数=1 (対抗なし)・(c) 観測時点が day 1 の三条件を満たす席は **target_seat に絶対に選んではならない**。理由 (構造的): day 1 朝時点では、対抗 CO が出るタイムウィンドウがまだ残っており、霊媒結果も襲撃結果も存在しないため、単独 CO 役職を切るための「具体的矛盾」(視点漏れ・色矛盾・票筋矛盾) は構造的に発生しえない。day 1 でこの席を処刑すると、村陣営は対抗を経由した真偽比較の機会と能力結果を同時に失い、配役上明確な村側損失となる。このルールは「灰位置の評価」「進行軸の優先」「ローラー反対=狼指標」「発話のトーン読み」「他席の同調」より上位の HARD 制約として、target_seat 確定前に必ずチェックする。**単独 CO 者が「灰絞り提案」「特定席を吊り候補に挙げる」「ローラー進行に別案を出す」といった言動をしていても、これらは day 1 段階では具体的矛盾に該当せず、HARD 除外条件を上書きしない**。これらは弱い偽寄り材料にすぎず、HARD 除外を解除する根拠にはならない。`reason` フィールド冒頭には「day1単独CO({役職名}) の {席名} は HARD 除外」を1行明記する。 - 対抗 CO が出たら、死亡済み CO 者も推理対象として保持し、判定・票筋・噛み筋・死亡タイミングで真偽比較する。「最後まで残った CO 者」が真とは限らない (狼が残した可能性も並行検討)。 - 異端票 (多数派と違う票) を狼異常行動と決める前に、真占い視点で動いていた場合に整合するかを必ず確認する。 - 死亡席は信用評価議論の対象としては有効だが、本日の処刑対象 (vote target) には含めない。 @@ -53,13 +54,14 @@ - **3-1 (占 3 / 霊 1)**: 単独霊媒は真寄り。基本進行は占いローラー or 黒ストップ (霊媒黒が出たら残占 CO 処刑停止 → 灰精査)。占 3 のうち 2 名が非狼確定したら、残 1 名は固定配役上の確定黒級として処理してよい。 - **3-1 で day 1 から灰位置を吊らない**。3-1 は占 3 の中に狼陣営 2 名以上が固まっている確率が高い盤面で、占いローラーまたは霊媒結果待ちで真偽比較するのが情報量最大の進行。day 1 から灰位置 (= 役職 CO していない席) を処刑候補に立てるのは、村陣営の確白級候補を自分から削る動きで、村側にとって明確な損になる。灰の発言が攻撃的・短い・拙いだけでは狼確定にならず、占い CO 群を吊って判定整合を見るほうが優先度が高い。 - **2-2 (占 2 / 霊 2)**: 霊媒ローラー基本軸。開始したら原則完走。 -- **2-1 (占 2 / 霊 1)**: 単独霊媒真寄り、占 2 の真偽比較とグレー精査を並行。 -- **1-2 (占 1 / 霊 2)**: 単独占い真寄り、霊媒ローラー / 霊媒切りが基本。 +- **2-1 (占 2 / 霊 1)**: 単独霊媒真寄り、占 2 の真偽比較とグレー精査を並行。day 1 朝の段階では占いローラーは強制ではなく、**灰吊りが定石として並列に有力**。占いローラー強行は真占いを 50% で失う賭けで、外すと残占い CO 群は全員偽となり以後の白判定は無価値化する。灰吊りは情報チャネル (占い 2 本) を温存しつつ霊媒結果で間接精査する穏当進行で、9 人村 2-1 の Day 1 では多数派定石として広く採用されている。「真偽は急がず精査しよう」「灰から先に吊ろう」「占い先と CO を避けて灰候補を絞ろう」は **2-1 では狼指標ではなく村側の合理進行**。 +- **1-2 (占 1 / 霊 2)**: 単独占い真寄り、霊媒ローラー / 霊媒切りが基本。day 1 段階では灰吊り並行も許容。 ## ローラー反対 = 狼指標 (数学的不可避) +- **このセクションのルールが適用されるのは「対抗超過分合計 ≥ 3」 (= 占い CO 4 件、もしくは占+霊+騎の超過分合計 3 以上) の盤面のみ**。2-1 / 1-2 / 2-2 のような超過分合計が 2 以下の盤面では、**このセクションの「ローラー反対=狼指標」「灰吊り提案=狼ムーブ」判定は適用してはならない**。これらの盤面では灰吊りも並列定石として有力で、CO 切りに反対する発言・灰絞り提案は村側の合理進行であり、狼指標として扱うのは誤り。 - 9 人村は 占 1 + 霊 1 + 騎 1 + 村 3 + 狼 2 + 狂 1 で固定。占い CO 4 (理論最大 = 真 1 + 狼 2 + 狂 1) もしくは対抗超過分合計が 3 以上の盤面では、**狼陣営 3 名が能力役 CO 群に出切っている**ため、灰位置は固定配役上の消去法で確白級として処理される。 -- **この盤面で「ローラー以外の進行」を提案・支持する村側の合理性は数学的にゼロ**。灰吊り提案、決め打ち提案、ローラー反対表明、「もっと議論しよう」「性急すぎる」「慎重に精査すべき」と進行を遅らせる動きは、いずれも狼陣営の生存確率を上げる方向の動き。これらを発する CO 者は強い狼陣営疑い (狼本体・狂人) として扱ってよい。 +- **この盤面 (超過分合計 ≥ 3) で「ローラー以外の進行」を提案・支持する村側の合理性は数学的にゼロ**。灰吊り提案、決め打ち提案、ローラー反対表明、「もっと議論しよう」「性急すぎる」「慎重に精査すべき」と進行を遅らせる動きは、いずれも狼陣営の生存確率を上げる方向の動き。これらを発する CO 者は強い狼陣営疑い (狼本体・狂人) として扱ってよい。 - 占い CO 3 (= 3-1) でも超過分合計 = 2 で灰確白は完全には言えないが、**占いローラー or 黒ストップが基本進行軸**であることに変わりはない。3-1 でローラー反対表明を強く繰り返す占い CO 者は、依然として狼陣営疑いの強い候補として扱う (理論最大 4 のうち 1〜2 件はその表明者自身が偽である確率が高いため)。 - **逆に「ローラー積極推進」する灰位置は基本的に村寄り**。狼/狂人にとって自分の CO 仲間がローラーで処分される動きを推進する動機は薄い。ただし 例外として、「ローラー賛成」と言いつつ**ローラー先頭を特定の CO に恣意的に指定する** (例: 「ローラーしましょう」と言いつつ即座に「X さんから吊りませんか」) のは、進行を真占い焼き方向に歪める狼ムーブの可能性がある。「ローラー賛成」と「特定先頭指定」は別シグナルとして読み、後者がある場合は指定者を狼候補として扱う。 - ローラー進行への態度は、灰位置の発話の質 (拙さ・攻撃性) より優先される強い指標。発話の質はトーン読みだが、ローラーへの態度は数学的合理性に直結するため、CO 者の振る舞いを評価するときの第一級の判断材料になる。 diff --git a/src/wolfbot/prompts/templates/strategy/knight.md b/src/wolfbot/prompts/templates/strategy/knight.md index 57d2264..1d97cde 100644 --- a/src/wolfbot/prompts/templates/strategy/knight.md +++ b/src/wolfbot/prompts/templates/strategy/knight.md @@ -1,6 +1,16 @@ # 騎士 (KNIGHT) 戦略 -- 護衛価値が高いのは、真寄り情報役・確白寄り進行役・盤面整理役。今夜抜かれると村が大きく崩れる位置を堅く守る (鉄板護衛)。 +## 立ち回り (HARD: 役職メタ最優先) + +- **day 1 朝〜中盤は「潜伏優先・低発言量・低攻撃性」**。騎士は dead 後に CO カウントが+1 され、生きている間は灰として扱われる。day 1 で目立つ動きをすると (a) 灰の中で吊り候補に上がる、(b) 死亡時に騎士が一人欠けて村が崩れる、の二重損失になるため、発言数・攻撃性ともに村人より一段控えめにする。 +- **単独情報役を切ろうとする票筋・発話に同調しない**。騎士は霊媒/占いが生きている限り護衛価値が出る役職であり、day 1 朝の単独霊媒/単独占いを処刑する流れは騎士にとって最大の損失。共通ルール「HARD: 初日単独 CO の処刑禁止」を最優先で適用し、自分の票でその CO を吊らないだけでなく、他席が単独 CO を切ろうとする発言・票筋には**同調せず、必要に応じて慎重論側に回る**。 +- **CO 群への攻撃発言を day 1 朝から繰り出さない**。「ローラーを急ごう」「真偽を早急に決めよう」と CO 切りを煽る側に立つのは、灰位置の発話としても怪しまれる行為。攻撃的に CO を疑う・吊り筋を煽る役回りは占い/霊媒/対抗 CO の役目で、騎士の役目ではない。 +- **判断のクセを役職で上書きする**。`性格指針` で「攻撃性=積極的に疑い先を指す」「即断型」「尊大な口調」が指定されていても、騎士役職時はそれを抑える。性格は語彙・口調・話の長さで表現しつつ、**疑い先を名指しする頻度・CO 切りに乗る速度・他席への食って掛かりは標準より控えめにする**。性格指針には「上記は判断のクセであり、ルールや論理確定情報を上書きしない」とあるため、本ルールが優先される。 +- **発話の中身は観察・整理・追認**。「平和な朝なのでまだ判断材料が薄い」「単独 CO は対抗 CO が出る時間を待ちたい」「投票はもう少し情報を整理してからにしたい」のような、進行を急がせない発話に寄せる。具体席を黒指定するのは、その席に「具体的矛盾」(視点漏れ・色矛盾・票筋矛盾) が観測された後だけ。 + +## 護衛戦術 + + - 護衛先選びは「護衛価値」「今夜実際に噛まれそうか」「GJ で縄が増える価値」「次夜の連続護衛不可リスク」「CO 時の説明可能性」を比較して選ぶ。最も白い相手や真寄り情報役を毎夜固定するのではなく、盤面ごとに価値で判断する。 - 同じ相手を連続で護衛してはならない。前夜と違う相手を選ぶ。自分護衛・死亡対象護衛は禁止。 - 自分の護衛先を不用意に公開しない (翌夜の噛み筋ヒントを狼に与えてしまう)。通常進行中は潜伏優先。 From 9283c500ae79a692ffd8f0126e5076610eb78bf6 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 22:22:32 +0900 Subject: [PATCH 125/133] fix(runoff): treat humans as first-class candidates + Levi intro de-dupe + delay flag flip to playback-finish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 決選投票演説フェイズ 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. --- src/wolfbot/master/arbiter/speak_arbiter.py | 420 +++++++++++++++----- src/wolfbot/services/game_service.py | 13 +- tests/test_reactive_voice_master.py | 361 ++++++++++++++++- 3 files changed, 685 insertions(+), 109 deletions(-) diff --git a/src/wolfbot/master/arbiter/speak_arbiter.py b/src/wolfbot/master/arbiter/speak_arbiter.py index ce23a4f..b4a524e 100644 --- a/src/wolfbot/master/arbiter/speak_arbiter.py +++ b/src/wolfbot/master/arbiter/speak_arbiter.py @@ -275,6 +275,22 @@ def __init__( # phase via the rotation re-selecting them. Cleared on phase # change (alongside `_callout_pool_asked`). self._fabrication_capped: dict[tuple[str, str], set[int]] = {} + # Per-(game_id, day) set of seats currently between + # `_runoff_announce` start and `dispatch_request` (LLM) / + # human-grace-watchdog spawn (human). The existing `_pending` + # check inside `_dispatch_runoff_next` only catches re-entries + # AFTER dispatch_request completes; the entire `await _runoff_announce` + # window (Levi TTS, several seconds) is unguarded. Without this + # set, a second `try_dispatch_next` invocation triggered by + # `_master_narrate` (PHASE_CHANGE) racing against + # `_on_reactive_phase_enter` re-picks the same seat and + # awaits `_runoff_announce` again, causing Levi to read the + # candidate intro twice in a row before the NPC speaks. + self._runoff_in_flight: dict[tuple[str, int], set[int]] = {} + # Per-(game_id, day) set of human candidates whose grace + # watchdog is currently scheduled. Strong refs to the asyncio + # tasks live in `_runoff_watchdog_tasks`; this dict just dedups. + self._runoff_human_watchdog: dict[tuple[str, int], set[int]] = {} # ------------------------------------------------------------- gates @@ -1185,15 +1201,14 @@ async def _reject_and_retry_same_npc( playback_deadline_ms=deadline, ) await _send(authorized.model_dump_json()) - # Runoff candidate: mark this seat's speech done so the engine - # can advance to DAY_RUNOFF as soon as every tied LLM has spoken. - # Done after the SpeechEvent is recorded so the runoff_speech log - # row exists when `plan_runoff_speech_to_runoff` polls progress. - await self._mark_runoff_done_if_phase( - game_id=pending.game_id, - phase_id=result.phase_id, - seat_no=pending.seat_no, - ) + # Note: `runoff_speech_done` is intentionally NOT flipped here. + # `accept_speak_result` fires when the NPC has generated text + + # TTS bytes — the audio hasn't started playing in VC yet. If we + # advanced the phase here, the runoff vote would open while the + # candidate's speech is still being read out loud. Defer the + # flag flip to `handle_playback_finished` (or + # `handle_playback_failed` / `handle_tts_failed`) so listeners + # actually hear the final speech before the vote starts. return (True, None) # ------------------------------------------------------------- TTS / playback @@ -1220,9 +1235,19 @@ async def handle_tts_failed(self, msg: TtsFailed) -> None: outcome="failed", failure_reason=msg.failure_reason, ) + # Read pending fields BEFORE pop so the runoff-mark hook + # below has the seat / phase / game ids available. TTS-fail + # is a terminal state; the phase advances rather than stalling. + pending = self._pending.get(msg.request_id) self._active_playback.discard(msg.request_id) self._playback_deadlines.pop(msg.request_id, None) self._pending.pop(msg.request_id, None) + if pending is not None: + await self._mark_runoff_done_if_phase( + game_id=pending.game_id, + phase_id=pending.phase_id, + seat_no=pending.seat_no, + ) async def handle_playback_finished(self, msg: PlaybackFinished) -> None: await self.repo.close_npc_playback( @@ -1231,9 +1256,20 @@ async def handle_playback_finished(self, msg: PlaybackFinished) -> None: outcome="succeeded", failure_reason=None, ) + # Read pending fields BEFORE pop so the runoff-mark hook + # has the seat / phase / game ids. The flag flip is delayed + # to playback-finish (not accept_speak_result) so listeners + # actually hear the final speech before the vote starts. + pending = self._pending.get(msg.request_id) self._active_playback.discard(msg.request_id) self._playback_deadlines.pop(msg.request_id, None) self._pending.pop(msg.request_id, None) + if pending is not None: + await self._mark_runoff_done_if_phase( + game_id=pending.game_id, + phase_id=pending.phase_id, + seat_no=pending.seat_no, + ) async def handle_playback_failed(self, msg: PlaybackFailed) -> None: now = self._now_ms() @@ -1243,9 +1279,17 @@ async def handle_playback_failed(self, msg: PlaybackFailed) -> None: outcome="failed", failure_reason=msg.failure_reason, ) + pending = self._pending.get(msg.request_id) self._active_playback.discard(msg.request_id) self._playback_deadlines.pop(msg.request_id, None) self._pending.pop(msg.request_id, None) + if pending is not None: + # Playback fail is terminal — advance instead of stalling. + await self._mark_runoff_done_if_phase( + game_id=pending.game_id, + phase_id=pending.phase_id, + seat_no=pending.seat_no, + ) # ------------------------------------------------------------- auto-dispatch @@ -1490,19 +1534,22 @@ def _pick_key(e: object) -> tuple[int, int, int, int, int, int, float]: # ------------------------------------------------------------- runoff dispatch async def _dispatch_runoff_next(self, game_id: str, day: int) -> None: - """Dispatch the next tied LLM candidate's final speech. - - Candidate set = round-0 tied seats ∩ alive ∩ LLM seat ∩ - ``runoff_speech_done = False``. The arbiter dispatches them - sequentially in seat-no order: pick → optional Master narration - intro → SpeakRequest → NPC TTS playback. After each candidate - resolves (accepted or any failure path), `runoff_speech_done` is - flipped so the engine's `plan_runoff_speech_to_runoff` advances - as soon as the last one finishes. - - When no eligible candidate remains (everyone done, or the only - ones left have no online NPC bot), `runoff_wake` is fired so the - engine doesn't sit on the deadline. + """Dispatch the next tied candidate's final speech (LLM or human). + + Candidate set = round-0 tied seats ∩ alive ∩ + ``runoff_speech_done = False``. The arbiter walks them in + seat-no order: pick → Master narration intro → (LLM: + SpeakRequest → NPC TTS) / (human: grace-watchdog awaits a + SpeechEvent or marks done on timeout). After each candidate + resolves, `runoff_speech_done` is flipped so the engine's + `plan_runoff_speech_to_runoff` advances as soon as the last + one finishes. + + Human candidates are first-class participants. They get a Levi + intro and a grace window (`runoff_speech_grace`) to actually + speak via voice / text; if they speak the SpeechEvent path + flips the flag, otherwise the watchdog marks them done so the + phase doesn't stall. """ seats = await self.repo.load_seats(game_id) seats_by_no: dict[int, Seat] = {s.seat_no: s for s in seats} @@ -1517,14 +1564,44 @@ async def _dispatch_runoff_next(self, game_id: str, day: int) -> None: self._maybe_wake_runoff(game_id) return - # Tied LLM seats whose runoff speech hasn't been recorded yet. - # Seat-no order is the user-visible "who speaks first" axis; we - # follow the same order rounds-mode used so logs / replays line - # up between modes. + # Auto-mark already-spoken human candidates. Voice (ingest_service) + # and text (discord_service.on_message) both record SpeechEvents + # via `discussion.record()` but neither flips + # `runoff_speech_done` on the human path (that flag is the + # NPC-side dispatcher's accounting). Detect their presence here + # by scanning this phase's speech_events — once a tied human + # has any non-baseline event from their seat in this phase, + # consider their turn complete and flip the flag so the engine + # can advance. + phase_id = make_phase_id(game_id, day, Phase.DAY_RUNOFF_SPEECH) + try: + phase_events = await self.discussion.load_phase(game_id, phase_id) + except Exception: + phase_events = [] + human_spoke_seats: set[int] = set() + for ev in phase_events: + if ev.speaker_seat is None: + continue + seat = seats_by_no.get(ev.speaker_seat) + if seat is None or seat.is_llm: + continue + human_spoke_seats.add(ev.speaker_seat) + for seat_no in sorted(human_spoke_seats & set(tied)): + try: + await self.repo.mark_llm_runoff_speech_done(game_id, day, seat_no) + except Exception: + log.exception( + "runoff_human_mark_done_failed game=%s seat=%d", + game_id, + seat_no, + ) + + # Tied seats whose runoff speech hasn't been recorded yet. + # Seat-no order is the user-visible "who speaks first" axis. eligible: list[Seat] = [] for seat_no in sorted(tied): seat = seats_by_no.get(seat_no) - if seat is None or not seat.is_llm: + if seat is None: continue progress = await self.repo.load_llm_speech_progress(game_id, day, seat_no) if progress[4]: # runoff_speech_done @@ -1532,19 +1609,22 @@ async def _dispatch_runoff_next(self, game_id: str, day: int) -> None: eligible.append(seat) if not eligible: - # Every tied LLM has already spoken (or been skipped). Wake - # the engine so DAY_RUNOFF_SPEECH advances to DAY_RUNOFF + # Every tied candidate has already spoken (or been skipped). + # Wake the engine so DAY_RUNOFF_SPEECH advances to DAY_RUNOFF # without waiting for the safety-net deadline. self._maybe_wake_runoff(game_id) return - # Find the first eligible seat with an online NPC bot. If a tied - # candidate has no online NPC (rare: misconfigured persona, bot - # crash before rejoin), mark them done and skip — otherwise the - # phase would stall forever. + # Find the first eligible seat that's actually dispatchable. + # LLM seats need an online NPC bot (mark done + skip if absent — + # otherwise the phase would stall forever). Human seats are + # always dispatchable: their grace watchdog handles silence. chosen: Seat | None = None chosen_npc_id: str | None = None for seat in eligible: + if not seat.is_llm: + chosen = seat + break entry = self._find_npc_for_seat(game_id, seat.seat_no) if entry is None: log.info( @@ -1565,24 +1645,15 @@ async def _dispatch_runoff_next(self, game_id: str, day: int) -> None: chosen_npc_id = entry.npc_id break - if chosen is None or chosen_npc_id is None: + if chosen is None: # All remaining tied LLMs were skipped above. Wake the # engine so it sees the marks we just wrote. self._maybe_wake_runoff(game_id) return - # Re-entrancy guard: phase entry into DAY_RUNOFF_SPEECH fires - # `try_dispatch_next` from two sequential paths — the - # post-PHASE_CHANGE-narration kick in `_master_narrate` and the - # `_on_reactive_phase_enter` callback driven by - # `_dispatch_submissions`. Between the first call's - # `dispatch_request` (which only populates `_pending`) and the - # NPC's `SpeakResult` (which adds to `_active_playback`), - # `is_blocked()` returns None, so the second call would pick - # the same `chosen` seat again and have Master read the - # candidate intro twice. Skip when a SpeakRequest for this - # seat is already pending — the existing dispatch will flip - # `runoff_speech_done` itself. + # Re-entrancy guard #1 (post-dispatch): a SpeakRequest for this + # seat is already in `_pending` — the existing dispatch will + # flip `runoff_speech_done` itself. LLM-only. for pending in self._pending.values(): if pending.game_id == game_id and pending.seat_no == chosen.seat_no: log.debug( @@ -1593,67 +1664,130 @@ async def _dispatch_runoff_next(self, game_id: str, day: int) -> None: ) return - # Master narration: name the candidate before they speak. Awaited - # so the intro finishes before the NPC's own TTS starts. - if self._runoff_announce is not None: - try: - await self._runoff_announce(chosen) - except Exception: - log.exception( - "runoff_announce_failed game=%s seat=%d", - game_id, - chosen.seat_no, - ) - - # Re-rebuild state AFTER the announcement so a fresh - # phase_baseline / event log is folded in. The state is also - # what `dispatch_request` consumes for the LogicPacket. - state = await self.rebuild_public_state( - game_id=game_id, day=day, phase=Phase.DAY_RUNOFF_SPEECH - ) - if state is None: - self._maybe_wake_runoff(game_id) + # Re-entrancy guard #2 (in-flight announcement): phase entry + # into DAY_RUNOFF_SPEECH fires `try_dispatch_next` from two + # sequential paths — `_master_narrate` (PHASE_CHANGE narration) + # and `_on_reactive_phase_enter` (driven by + # `_dispatch_submissions`). Between the first call's + # `_runoff_announce` (Levi TTS, several seconds) and + # `dispatch_request` completing, `_pending` is still empty, + # so the second call would pick the same seat and re-announce. + # `_runoff_in_flight` plugs this race window — set BEFORE the + # await on `_runoff_announce`, cleared in `finally` once the + # downstream dispatch has handed off to the existing tracking + # (`_pending` for LLM, `_runoff_human_watchdog` for human). + in_flight_key = (game_id, day) + in_flight = self._runoff_in_flight.setdefault(in_flight_key, set()) + if chosen.seat_no in in_flight: + log.debug( + "runoff_dispatch_skipped_in_flight game=%s seat=%d", + game_id, + chosen.seat_no, + ) return + in_flight.add(chosen.seat_no) - snapshot: dict[str, Any] = { - "phase_id": state.phase_id, - "day": state.day, - "phase": Phase.DAY_RUNOFF_SPEECH.value, - "tied_candidates": sorted(tied), - "alive_seat_nos": sorted(state.alive_seat_nos), - } - request, reason = await self.dispatch_request( - state=state, - candidate_npc_id=chosen_npc_id, - seat_no=chosen.seat_no, - game_id=game_id, - selection_reason="runoff_candidate", - public_state_snapshot=snapshot, - ) - if request is None: - # Dispatch failed (npc_offline, ws_send_failed, gate held). - # The npc_offline + ws_send_failed cases would leave this - # seat permanently un-spoken, so mark done and re-dispatch - # so the phase keeps moving. ``queue_busy`` / - # ``human_currently_speaking`` are transient — just re-poll - # later via the normal try_dispatch_next pathway. - if reason in ("npc_offline", "ws_send_failed"): - await self._mark_runoff_done_if_phase( + try: + # Master narration: name the candidate before they speak. + # Awaited so the intro finishes before the NPC's own TTS + # (or the human's grace window) starts. + if self._runoff_announce is not None: + try: + await self._runoff_announce(chosen) + except Exception: + log.exception( + "runoff_announce_failed game=%s seat=%d", + game_id, + chosen.seat_no, + ) + + if not chosen.is_llm: + # Human candidate path: schedule a grace watchdog. The + # human is expected to speak via voice / text within + # `runoff_speech_grace` seconds; the SpeechEvent path + # auto-marks them done (top-of-method scan on the next + # `try_dispatch_next` invocation). On timeout the + # watchdog marks them done so the phase doesn't stall + # on silence. + self._spawn_human_runoff_watchdog( game_id=game_id, - phase_id=state.phase_id, + day=day, seat_no=chosen.seat_no, ) - await self.try_dispatch_next(game_id) - return - # Watchdog: if the NPC never returns a SpeakResult before the - # request's TTL expires, the phase would stall forever. Spawn - # a one-shot task that marks the seat done and re-dispatches - # so the engine eventually advances. The mark is idempotent - # (UPSERT), so a result that arrives in the same window is fine. + return + + # LLM candidate path: rebuild state, dispatch SpeakRequest, + # spawn watchdog. + state = await self.rebuild_public_state( + game_id=game_id, day=day, phase=Phase.DAY_RUNOFF_SPEECH + ) + if state is None: + self._maybe_wake_runoff(game_id) + return + + assert chosen_npc_id is not None # LLM branch guarantees this + snapshot: dict[str, Any] = { + "phase_id": state.phase_id, + "day": state.day, + "phase": Phase.DAY_RUNOFF_SPEECH.value, + "tied_candidates": sorted(tied), + "alive_seat_nos": sorted(state.alive_seat_nos), + } + request, reason = await self.dispatch_request( + state=state, + candidate_npc_id=chosen_npc_id, + seat_no=chosen.seat_no, + game_id=game_id, + selection_reason="runoff_candidate", + public_state_snapshot=snapshot, + ) + if request is None: + # Dispatch failed (npc_offline, ws_send_failed, gate held). + # The npc_offline + ws_send_failed cases would leave this + # seat permanently un-spoken, so mark done and re-dispatch + # so the phase keeps moving. ``queue_busy`` / + # ``human_currently_speaking`` are transient — just re-poll + # later via the normal try_dispatch_next pathway. + if reason in ("npc_offline", "ws_send_failed"): + await self._mark_runoff_done_if_phase( + game_id=game_id, + phase_id=state.phase_id, + seat_no=chosen.seat_no, + ) + await self.try_dispatch_next(game_id) + return + + # Watchdog: if the NPC never returns a SpeakResult before the + # request's TTL expires, the phase would stall forever. Spawn + # a one-shot task that marks the seat done and re-dispatches + # so the engine eventually advances. The mark is idempotent + # (UPSERT), so a result that arrives in the same window is fine. + self._spawn_llm_runoff_watchdog( + game_id=game_id, + request_id=request.request_id, + seat_no=chosen.seat_no, + phase_id=state.phase_id, + ) + finally: + in_flight.discard(chosen.seat_no) + if not in_flight: + self._runoff_in_flight.pop(in_flight_key, None) + + def _spawn_llm_runoff_watchdog( + self, + *, + game_id: str, + request_id: str, + seat_no: int, + phase_id: str, + ) -> None: + """One-shot LLM-runoff watchdog (extracted from `_dispatch_runoff_next`). + + See the inline doc on `_dispatch_runoff_next` for the rationale — + the body was moved out so the human-grace watchdog + (`_spawn_human_runoff_watchdog`) can sit alongside it. + """ ttl_s = max(1.0, self.config.request_ttl_ms / 1000.0) - request_id = request.request_id - seat_no = chosen.seat_no - phase_id = state.phase_id async def _watchdog() -> None: try: @@ -1695,6 +1829,90 @@ async def _watchdog() -> None: self._runoff_watchdog_tasks.add(task) task.add_done_callback(self._runoff_watchdog_tasks.discard) + def _spawn_human_runoff_watchdog( + self, + *, + game_id: str, + day: int, + seat_no: int, + ) -> None: + """Grace timer for a human runoff candidate. + + The human is expected to speak (voice or text) within + ``runoff_speech_grace`` seconds of the Levi intro. If a + SpeechEvent for this seat lands during the window, the + top-of-method scan in `_dispatch_runoff_next` flips + `runoff_speech_done` on the next `try_dispatch_next` invocation. + On timeout, this watchdog flips the flag itself so + `plan_runoff_speech_to_runoff` can advance. + + Dedup: keyed on (game_id, day, seat_no) — re-entrant calls + during the grace window are silently ignored. The strong-ref + task lives in `_runoff_watchdog_tasks` (shared with the LLM + watchdogs). + """ + watchdog_key = (game_id, day) + scheduled = self._runoff_human_watchdog.setdefault(watchdog_key, set()) + if seat_no in scheduled: + return + scheduled.add(seat_no) + + try: + from wolfbot.domain.durations import current_phase_durations + + grace_s = max(1.0, float(current_phase_durations().runoff_speech_grace)) + except Exception: + grace_s = 30.0 + + async def _watchdog() -> None: + try: + await asyncio.sleep(grace_s) + except asyncio.CancelledError: + return + try: + progress = await self.repo.load_llm_speech_progress( + game_id, day, seat_no + ) + except Exception: + log.exception( + "runoff_human_watchdog_load_progress_failed game=%s seat=%d", + game_id, + seat_no, + ) + progress = (0, False, None, 0, False) + already_done = bool(progress[4]) + scheduled.discard(seat_no) + if not scheduled: + self._runoff_human_watchdog.pop(watchdog_key, None) + if already_done: + return # Human spoke within the grace window. + log.info( + "runoff_human_grace_timeout game=%s seat=%d — marking done", + game_id, + seat_no, + ) + try: + await self.repo.mark_llm_runoff_speech_done(game_id, day, seat_no) + except Exception: + log.exception( + "runoff_human_mark_done_failed game=%s seat=%d", + game_id, + seat_no, + ) + self._maybe_wake_runoff(game_id) + try: + await self.try_dispatch_next(game_id) + except Exception: + log.exception( + "runoff_human_watchdog_redispatch_failed game=%s", game_id + ) + + task = asyncio.create_task( + _watchdog(), name=f"runoff-human-watchdog-{game_id}-d{day}-s{seat_no}" + ) + self._runoff_watchdog_tasks.add(task) + task.add_done_callback(self._runoff_watchdog_tasks.discard) + def _find_npc_for_seat(self, game_id: str, seat_no: int) -> Any | None: """Lookup the registry entry pinned to ``(game_id, seat_no)``.""" for entry in self.registry.all_online(): diff --git a/src/wolfbot/services/game_service.py b/src/wolfbot/services/game_service.py index 5d98385..7e8c7be 100644 --- a/src/wolfbot/services/game_service.py +++ b/src/wolfbot/services/game_service.py @@ -375,11 +375,16 @@ async def _plan_next( alive_set = {p.seat_no for p in players if p.alive} tied = compute_vote_result(round0, alive_set).tied seats_by_no = {s.seat_no: s for s in seats} - tied_llm_seats = [ - sn for sn in tied if seats_by_no.get(sn) is not None and seats_by_no[sn].is_llm - ] + # All tied candidates (LLM + human) must complete their speech + # before the runoff vote opens. Humans set `runoff_speech_done` + # via the speech-recording paths (voice ingest → SpeakArbiter + # auto-mark, text on_message → same path, or the human grace + # watchdog on timeout); LLMs flip it on PlaybackFinished / + # TtsFailed. The flag is uniform across speaker types so this + # check doesn't need to branch. + tied_seats = [sn for sn in tied if seats_by_no.get(sn) is not None] speeches_done = True - for sn in tied_llm_seats: + for sn in tied_seats: progress = await self.repo.load_llm_speech_progress(game.id, game.day_number, sn) if not progress[4]: # runoff_speech_done speeches_done = False diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index da271dd..380ddcc 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -3359,12 +3359,19 @@ def _wake(game_id: str) -> None: phase=Phase.DAY_RUNOFF_SPEECH, ) assert ok - progress_seat1 = await repo.load_llm_speech_progress(g.id, 1, 1) - assert progress_seat1[4] is True, "runoff_speech_done must flip on accept" - assert g.id in wakes, "engine must wake so the phase can advance" + # Bug #3 fix: `runoff_speech_done` is intentionally NOT flipped on + # accept_speak_result anymore — the audio hasn't started playing in + # VC at that point, so flipping early would let the runoff vote + # open while the candidate's speech is still being read out loud. + # The flag now flips on PlaybackFinished (below). + progress_seat1_pre = await repo.load_llm_speech_progress(g.id, 1, 1) + assert progress_seat1_pre[4] is False, ( + "runoff_speech_done must NOT flip on accept — wait for playback" + ) # Simulate playback completion so the serial-speech gate releases — - # the live wiring kicks try_dispatch_next on playback_finished. + # the live wiring kicks try_dispatch_next on playback_finished and + # this is also where `runoff_speech_done` is flipped now. await arb.handle_playback_finished( PlaybackFinished( ts=2200, @@ -3375,6 +3382,9 @@ def _wake(game_id: str) -> None: finished_at_ms=2200, ) ) + progress_seat1 = await repo.load_llm_speech_progress(g.id, 1, 1) + assert progress_seat1[4] is True, "runoff_speech_done must flip on playback_finished" + assert g.id in wakes, "engine must wake so the phase can advance" # Second dispatch: seat 2 (next tied seat-no). bufs["raqio"].clear() @@ -3527,6 +3537,349 @@ async def _intro(seat: Seat) -> None: assert not any('"speak_request"' in m for m in bufs["setsu"]) +async def test_runoff_dispatch_concurrent_kicks_announce_once( + repo: SqliteRepo, +) -> None: + """Two concurrent `try_dispatch_next` invocations during phase entry + into DAY_RUNOFF_SPEECH must NOT each play the Master candidate intro + even when they overlap in the await on `_runoff_announce`. + + Regression for the user-reported bug: "決選投票の最初の候補者の案内が2連続で + 読み上げられてからNPCが喋る". The pre-existing `_pending` re-entrancy + guard only caught re-entries AFTER `dispatch_request` completed; the + full announce-await window (Levi TTS, several seconds) was unguarded. + The new `_runoff_in_flight` set is added BEFORE the announce await. + """ + import asyncio + + from wolfbot.domain.models import Vote + + g = Game( + id="rv-runoff-concurrent", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_RUNOFF_SPEECH, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + for seat in ( + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, + display_name="🌙セツ", + discord_user_id=None, + is_llm=True, + persona_key="setsu", + ), + ): + await repo.insert_seat(g.id, seat) + for sn, role in ((1, Role.WEREWOLF), (2, Role.SEER)): + await repo.set_player_role(g.id, sn, role) + # Tie 1-2. + for voter, target in ((1, 2), (2, 1)): + await repo.insert_vote( + Vote( + game_id=g.id, day=1, round=0, + voter_seat=voter, target_seat=target, submitted_at=1, + ) + ) + phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + alive_seat_nos=[1, 2], + created_at_ms=1, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"raqio": [], "setsu": []} + for npc_id, persona, seat_no in ( + ("npc_raqio", "raqio", 1), + ("npc_setsu", "setsu", 2), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + intros: list[int] = [] + intro_started = asyncio.Event() + intro_release = asyncio.Event() + + async def _intro(seat: Seat) -> None: + intros.append(seat.seat_no) + intro_started.set() + # Mimic Levi TTS taking time — block until released so the + # second concurrent kick has the chance to enter + # `_dispatch_runoff_next` before this announce completes. + await intro_release.wait() + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + runoff_announce=_intro, + ) + + # Fire both kicks concurrently. The first one wins the + # `_runoff_in_flight` slot and proceeds into _intro; the second one + # observes the slot is taken and bails before announcing. + task1 = asyncio.create_task(arb.try_dispatch_next(g.id)) + await intro_started.wait() # Path A is now blocked inside _intro + task2 = asyncio.create_task(arb.try_dispatch_next(g.id)) + # Give Path B a chance to enter and bail. + await asyncio.sleep(0.05) + intro_release.set() + await task1 + await task2 + + assert intros == [1], ( + "candidate intro must fire exactly once even on a concurrent " + f"double phase-entry kick (got intros={intros})" + ) + raqio_speak_requests = [m for m in bufs["raqio"] if '"speak_request"' in m] + assert len(raqio_speak_requests) == 1, ( + "exactly one SpeakRequest must reach the chosen NPC " + f"(got {len(raqio_speak_requests)})" + ) + + +async def test_runoff_dispatch_announces_human_candidate( + repo: SqliteRepo, +) -> None: + """Human runoff candidates get the same Levi turn-announcement as + LLM candidates. Regression for the user-reported bug where sakura + (a tied human candidate) was never announced and never given a + chance to speak before the runoff vote opened. + """ + from wolfbot.domain.models import Vote + + g = Game( + id="rv-runoff-human", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_RUNOFF_SPEECH, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + for seat in ( + Seat( + seat_no=1, display_name="sakura", discord_user_id="u1", + is_llm=False, persona_key=None, + ), + Seat( + seat_no=5, display_name="🎩ジョナス", discord_user_id=None, + is_llm=True, persona_key="jonas", + ), + ): + await repo.insert_seat(g.id, seat) + for sn, role in ((1, Role.MEDIUM), (5, Role.KNIGHT)): + await repo.set_player_role(g.id, sn, role) + for voter, target in ((1, 5), (5, 1)): + await repo.insert_vote( + Vote( + game_id=g.id, day=1, round=0, + voter_seat=voter, target_seat=target, submitted_at=1, + ) + ) + phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + alive_seat_nos=[1, 5], + created_at_ms=1, + ) + ) + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"jonas": []} + registry.register( + npc_id="npc_jonas", + discord_bot_user_id="bot_jonas", + supported_voices=(), + version="1", + send=_captured_send(bufs["jonas"]), + now_ms=1000, + persona_key="jonas", + ) + registry.assign("npc_jonas", seat=5, game_id=g.id, phase_id=phase_id) + + intros: list[int] = [] + + async def _intro(seat: Seat) -> None: + intros.append(seat.seat_no) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + runoff_announce=_intro, + ) + + # First dispatch: human (席1 sakura) is announced first by seat-no order. + await arb.try_dispatch_next(g.id) + assert intros == [1], ( + "human runoff candidate must receive a Levi intro " + f"(got intros={intros})" + ) + # No SpeakRequest is sent for a human candidate. + assert not any('"speak_request"' in m for m in bufs["jonas"]), ( + "no NPC SpeakRequest must be dispatched for a human candidate" + ) + # Human's `runoff_speech_done` flag stays False until they speak + # (or the grace watchdog fires). + progress = await repo.load_llm_speech_progress(g.id, 1, 1) + assert progress[4] is False + + +async def test_runoff_human_speech_event_auto_marks_done( + repo: SqliteRepo, +) -> None: + """When a human runoff candidate speaks (voice or text), the + SpeechEvent record is detected on the next `try_dispatch_next` and + `runoff_speech_done` is auto-flipped so the engine can advance. + """ + from wolfbot.domain.models import Vote + from wolfbot.services.discussion_service import make_human_text_event + + g = Game( + id="rv-runoff-human-spoke", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_RUNOFF_SPEECH, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + for seat in ( + Seat( + seat_no=1, display_name="sakura", discord_user_id="u1", + is_llm=False, persona_key=None, + ), + Seat( + seat_no=5, display_name="🎩ジョナス", discord_user_id=None, + is_llm=True, persona_key="jonas", + ), + ): + await repo.insert_seat(g.id, seat) + for sn, role in ((1, Role.MEDIUM), (5, Role.KNIGHT)): + await repo.set_player_role(g.id, sn, role) + for voter, target in ((1, 5), (5, 1)): + await repo.insert_vote( + Vote( + game_id=g.id, day=1, round=0, + voter_seat=voter, target_seat=target, submitted_at=1, + ) + ) + phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + alive_seat_nos=[1, 5], + created_at_ms=1, + ) + ) + + # Pre-record the human's speech event (mimics what + # `discord_service.on_message` writes after sakura types her + # final speech in the VC chat). + human_event = make_human_text_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + speaker_seat=1, + text="どうか霊媒師である私を残してください。", + ) + await discussion.record(human_event) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"jonas": []} + registry.register( + npc_id="npc_jonas", + discord_bot_user_id="bot_jonas", + supported_voices=(), + version="1", + send=_captured_send(bufs["jonas"]), + now_ms=1000, + persona_key="jonas", + ) + registry.assign("npc_jonas", seat=5, game_id=g.id, phase_id=phase_id) + + intros: list[int] = [] + + async def _intro(seat: Seat) -> None: + intros.append(seat.seat_no) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + runoff_announce=_intro, + ) + + # First dispatch: scans the speech_events table, sees seat 1's + # SpeechEvent, auto-marks `runoff_speech_done`, then continues + # with the next eligible candidate (jonas, LLM). + await arb.try_dispatch_next(g.id) + + progress_human = await repo.load_llm_speech_progress(g.id, 1, 1) + assert progress_human[4] is True, ( + "tied human candidate's `runoff_speech_done` must auto-flip " + "when their SpeechEvent is already recorded" + ) + # Human is already done — sakura's intro is NOT replayed; jonas + # (the next eligible) gets the announcement. + assert intros == [5], ( + "after auto-marking the spoken human, the next eligible LLM " + f"candidate must be picked (got intros={intros})" + ) + + async def test_runoff_dispatch_marks_done_on_offline_npc( repo: SqliteRepo, ) -> None: From 37121d0f896dd49a9f4d14dd2b0e017b840d17c7 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 23:43:42 +0900 Subject: [PATCH 126/133] feat(prompts): force vote-line evidence into day N+1 morning discussion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 直近のゲーム (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 件パス。 --- .../prompts/templates/master/task_daytime_speech.md | 2 +- src/wolfbot/prompts/templates/shared/game_rules_9p.md | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/wolfbot/prompts/templates/master/task_daytime_speech.md b/src/wolfbot/prompts/templates/master/task_daytime_speech.md index 2958e5f..4abb76c 100644 --- a/src/wolfbot/prompts/templates/master/task_daytime_speech.md +++ b/src/wolfbot/prompts/templates/master/task_daytime_speech.md @@ -1,2 +1,2 @@ -現在は day {{day_number}} の議論フェイズです。 必要と感じた場合のみ `intent=speak` を返し、`public_message` に 80〜300 字で短い発言を書いてください。 発言したくない場合は `intent=skip` と明示してください。 疑い先を出すときは、単体の怪しさだけでなく、その人物が人狼なら相方候補は誰か、2 人狼セットとして票筋・噛み筋が自然かも必要に応じて短く触れてください。全候補のペアを長く列挙せず、今の結論に効く 1〜2 点だけを出してください。 +現在は day {{day_number}} の議論フェイズです。 必要と感じた場合のみ `intent=speak` を返し、`public_message` に 80〜300 字で短い発言を書いてください。 発言したくない場合は `intent=skip` と明示してください。 疑い先を出すときは、単体の怪しさだけでなく、その人物が人狼なら相方候補は誰か、2 人狼セットとして票筋・噛み筋が自然かも必要に応じて短く触れてください。全候補のペアを長く列挙せず、今の結論に効く 1〜2 点だけを出してください。 **day {{day_number}} ≥ 2 の朝**には、`## 公開された投票履歴` を必ず確認し、前日の投票に「単独情報役 (単独占い CO/単独霊媒 CO) への異端票」「確定黒に乗らない異端票」「揃った2票」のいずれかが観測されたら、それを共通ルールに従い**自然な日本語**で引用しつつ発話に織り込んでください (例: 「昨日の投票、X を処刑する流れの中で Y だけ Z に入れていた、あれは引っかかる」)。指摘せず流すのは村陣営の情報損失で、致命的な見落としになります。 発話 (`public_message`) のルール: 自然な日本語で喋ること。「CO」「占いCO」「霊媒CO」「騎士CO」「黒判定」「白判定」「ライン」「グレー」「グレラン」「縄」「PP」「RPP」「ローラー」「ロラ」「破綻」「確白」「確黒」「パンダ」「鉄板護衛」「捨て護衛」「噛み筋」「票筋」「視点漏れ」「身内切り」「囲い」など、プレイヤー間で使われがちなメタ用語は `public_message` 内で使わない(これらは内部の `reason_summary` や思考メモには使ってよい)。口に出すときは状況や感情として描写する(例: 「あの白判定、無理に庇ってる気がして信用できない」「昨夜守ったのは◯◯です」「あと処刑できる回数を考えると…」)。役職 CO したい場合は `co_declaration` を `{{co_claim_options}}` のいずれかに設定し、`public_message` 側は「実は私、占い師なんだ」のように自然に名乗ってから能力結果を続ける。`co_declaration` を設定しないなら `null`。{{#if include_day2_round1_results_block}} これは day 2 以降の 1 巡目発言です。 占い師・霊媒師・騎士として既に名乗っている、または今初めて名乗る場合は、前夜の能力結果をこの発言で添えてください。 占い師なら対象 + 白/黒 + 占い理由、霊媒師なら前日処刑者 + 人狼/人狼ではない/結果なし、騎士なら名乗る局面で合法な護衛履歴 (護衛日 + 護衛先) を日付順に出し、平和な朝なら護衛成功も合わせて伝えます。 結果を持つはずの役職を名乗っているのに 1 巡目で結果を出さないと、信用低下や破綻疑いにつながります。 ただし `public_message` 内ではこの「白/黒」「結果なし」のような内部メモ語彙をそのまま読み上げず、「結果は人狼でした」「占ったけど人狼じゃなかった」「占ってみた感触は白かな」のように自然な発話に書き直す。 名乗り直すならこのターンで `co_declaration` を立て、`public_message` でも自然な日本語で名乗りと結果を述べてください。{{/if}}{{#if include_day1_round1_wolf_madman_block}} day 1 の 1 巡目で偽 CO を選ぶ場合、占い師騙り・霊媒師騙り・潜伏の 3 択を比較してください。 霊媒師騙りに出ると、真霊媒との 1-2、または別の騙り狼との組み合わせで 2-2 を作りに行けます。 占い師騙り CO 数・縄数・潜伏で白を取る余地と比べて、得な方を選びます。 霊媒師騙りで day 1 に CO する場合は、まだ処刑がないので初日の霊媒結果は出さず、翌日処刑があった時に結果を出す立ち回りだと添えてください。{{/if}} \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/shared/game_rules_9p.md b/src/wolfbot/prompts/templates/shared/game_rules_9p.md index b818778..4acc42c 100644 --- a/src/wolfbot/prompts/templates/shared/game_rules_9p.md +++ b/src/wolfbot/prompts/templates/shared/game_rules_9p.md @@ -89,7 +89,15 @@ - **単独情報役 (= 占いCO 単独 1 件、霊媒CO 単独 1 件) に投票する seat は強い狼指標**。村陣営の真役職は単独で、対抗 CO がいない情報役を吊る合理性が無い。`## 公開された投票履歴` に「単独霊媒 X に Y が投票した」「単独占い X に Y が投票した」が記録されていたら、その投票者 Y は狼陣営疑いとして扱う。 - **同じ対象に投票した複数 seat は 2 人狼候補**。day N の vote ledger で同じ被投票者に向かった seat の組は、その被投票者が単独情報役であれば確実な 2 人狼セット候補。投票揃えの整合 (普段のライン・噛み筋・発言の方向性) と合わせて評価し、揃えた seat 同士を相方候補として扱ってよい。特に「狼陣営側の 2 票が揃って単独霊媒/単独占いに向かう」は典型的な情報役切りムーブ。 -- **発言で指摘必須**: 単独情報役切り票や、揃った 2 票が観測されたら、day N+1 以降の議論でその票筋を必ず引用して指摘する。指摘せず流すのは村陣営の情報損失。`reason` フィールドにも「day X で Y が単独霊媒 Z に投票」のように具体引用する。 +- **発言で指摘必須**: 単独情報役切り票や、揃った 2 票や、確定黒に乗らない異端票が観測されたら、day N+1 以降の議論でその票筋を必ず**自然な日本語で**引用して指摘する。指摘せず流すのは村陣営の情報損失で、最も大きな村側ミスのひとつ。`reason` フィールドにも「day X で Y が単独霊媒 Z に投票」のように具体引用する。 +- **メタ用語禁止下での自然な言い換え例** (公開発話で「票筋」「ライン」「身内票」等のメタ用語が禁じられているため、必ず以下のような自然な日本語に変換する): + - ❌「ジナの票筋がライン濃厚」「身内切りの可能性」「票筋が見えた」 + - ✅「昨日の投票、ステラを処刑する流れの中でジナだけ SQ に入れていた。あれは引っかかる」 + - ✅「みんながステラに票を集める中、ジナだけ別の人を吊ろうとしていた点が気になる」 + - ✅「ステラが黒判定された後の投票で、ジナだけ SQ を狙ったのは、占い師を消そうとした動きに見える」 + - ✅「昨日 Y が単独で Z に票を入れていたのが思い出される。Z は今となって思えば情報役で、Y はそれを潰そうとしていたんじゃないか」 + - 引用形式: 「day N の投票で X が Y に票を入れていた」を必ず含める (誰がいつ誰に入れた、の3点が揃わないと記憶になりにくい)。 +- **day N+1 朝の 1 巡目の発話**: 前日 (day N) に確定情報役 (単独占い・単独霊媒) や確定黒判定先 (霊媒結果で人狼確定した seat) が絡む投票があったら、その異端票を**少なくとも1点取り上げて発話に織り込む**。たとえ自分が情報役・確白寄り・潜伏村人いずれの立場でも、村陣営として共有すべき情報。「昨日の投票が気になっている」という切り出しから具体引用に入るのが自然。 - 票揃えの解釈は 2 人狼仮説と同じく仮説で、確定情報ではない。ただし single CO 切り票だけは確度が高く、2 人狼セットの強い候補として扱える。 ## 発話作法 From c1c31fb8cab2734831fad8bed9a3c1ac2e9cbf28 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sat, 2 May 2026 23:47:29 +0900 Subject: [PATCH 127/133] =?UTF-8?q?feat(prompts):=20force=20wolves=20to=20?= =?UTF-8?q?=E8=BA=AB=E5=86=85=E5=88=87=E3=82=8A=20when=20partner=20is=20co?= =?UTF-8?q?nfirmed=20black?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 直近のゲーム (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. --- src/wolfbot/prompts/templates/strategy/werewolf.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/wolfbot/prompts/templates/strategy/werewolf.md b/src/wolfbot/prompts/templates/strategy/werewolf.md index 1216c5f..3386e7f 100644 --- a/src/wolfbot/prompts/templates/strategy/werewolf.md +++ b/src/wolfbot/prompts/templates/strategy/werewolf.md @@ -14,5 +14,14 @@ - 噛み方針を「情報役噛み / 白位置噛み / 意見噛み / 騎士探し / SG 残し」のどれかとして整理し、翌日の自分・相方の発言・投票・騙り結果と矛盾しない襲撃を選ぶ。 - 騎士候補度は公開ログから推定 (情報役を守りたがる発言、平和な朝の反応、発言量の抑え方)。実役職を知っている前提では断言しない。 - 投票は翌日以降の票筋として読まれる。相方処刑濃厚で自分だけ弱い別候補へ票逸らしすると庇い狼として透ける。救う票・切る票・票逸らしは 2 人狼セットとして説明可能か毎回確認する。決選では救済成功見込み・透けリスク・残存価値・縄数を比較する。 +- **HARD: 相方が確定黒判定された後の投票は、村と一緒に相方へ票を入れる (身内切り)**。「確定黒判定」とは (a) 真寄りの占い CO による黒判定で、その占い CO に対抗が無いか、対抗が破綻している場合、(b) 霊媒結果が「人狼」と返った相手、のいずれか。この状況で: + - **相方を救おうとして別候補に票を投じることは、票筋として最も狼ラインを濃くする最悪ムーブ**。「公開ログで `(単独占い CO の判定) X→黒` が出た日に、自分だけ X か別位置に票を逸らした」は典型的な「相方を庇った狼」の証拠として翌日以降の議論で必ず引用される。 + - 特に**単独占い・単独霊媒に対して票を入れる行為は禁止**。共通ルール「単独情報役切り票=強い狼指標」に直撃する。相方処刑濃厚な状況で「占い師の真偽が怪しい」「処刑を急ぎすぎ」等の理由で単独情報役に票を入れた瞬間、翌日全員に狼ラインを露呈する。 + - 例外的な救済可能性: 公開ログ上に複数 CO の対抗が立っていて、相方の占い CO の真偽が割れていて、村の票が分散している場合のみ、相方以外への票を「自然な分散票」として説明できる。**割れていないなら必ず相方に投票する**。 +- **投票前のチェックリスト (相方が処刑候補に挙がっているとき必須)**: + 1. 公開された投票履歴と直近の発言を見て、村側の **過半数が相方に向かっているか** 確認する。向かっているなら相方処刑は確定。 + 2. 相方が「確定黒判定」を受けているか確認する。受けているなら**相方への身内切り票が唯一の透けない選択**。 + 3. それ以外への票は全て「狼の票逸らし」として翌日以降に解釈される。`reason` でどう正当化しても、票筋の生データは公開情報なので、後付けの言い訳は無効。 + 4. 相方を切ることが感情的に抵抗があっても、「相方処刑後に自分だけが村に潜伏して残る」のが狼陣営の最終勝利条件。相方を救えない局面で透けて死ぬほうが致命的な損失。 - 視点漏れ厳禁。相方語彙は人狼チャットでのみ使う。 - **自分達が前夜襲撃した相手 (公開ログ `(襲撃)` 表記) を「あれは狼だった」と公言してはならない**。構造ルール上 襲撃死=非狼 が確定しているので即狼確定発言になる。相方の発話がそこに踏み込んだ場合の追認・補強も二重破綻。 From 6bf3d33c4063decc0d052b44cba033dfb8c51a46 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sun, 3 May 2026 00:26:50 +0900 Subject: [PATCH 128/133] feat(suspicions): structured suspicion timeline + anti-fabrication rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ユーザー要望「怪しいと指摘した履歴を構造化して残し、後から捏造しないよう にその根拠と更新理由を残し、議論時に誰が誰をどれくらい怪しんだか可視化 したい」を、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 に統合する。 --- src/wolfbot/domain/suspicion.py | 79 ++++++++++++ src/wolfbot/domain/ws_messages.py | 27 ++++ src/wolfbot/master/arbiter/logic_service.py | 4 + src/wolfbot/master/arbiter/speak_arbiter.py | 54 ++++++++ .../npc/speech/openai_compatible_generator.py | 122 ++++++++++++++++++ src/wolfbot/npc/speech/speech_service.py | 13 ++ .../sql/schema/15_speech_suspicions.sql | 36 ++++++ src/wolfbot/persistence/sqlite_repo.py | 97 ++++++++++++++ .../templates/master/task_daytime_speech.md | 21 +++ .../prompts/templates/npc/speech_system.md | 16 +++ src/wolfbot/services/llm_service.py | 70 ++++++++++ tests/test_llm_structured_output.py | 1 + 12 files changed, 540 insertions(+) create mode 100644 src/wolfbot/domain/suspicion.py create mode 100644 src/wolfbot/persistence/sql/schema/15_speech_suspicions.sql diff --git a/src/wolfbot/domain/suspicion.py b/src/wolfbot/domain/suspicion.py new file mode 100644 index 0000000..76e764a --- /dev/null +++ b/src/wolfbot/domain/suspicion.py @@ -0,0 +1,79 @@ +"""Public suspicion record — who suspects whom, at what level, with what reason. + +Each `Suspicion` row is emitted alongside a `SpeechEvent` and persisted to +the `speech_suspicions` table. Subsequent prompts surface the immutable +history back so a player who later contradicts their own past suspicion +without an explicit `update_from_level` + `update_reason` is detectable. + +The history is the village-side anti-fabrication mechanism: a wolf who +"forgot" they said someone was trustworthy yesterday can be caught by +the recorded mismatch between past and current statements. It is also +the data source for visualising the suspicion graph during discussion. + +Pure: no I/O, no asyncio. +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field + + +class SuspicionLevel(StrEnum): + """Four-step scale a speaker assigns to a target seat. + + The scale is deliberately coarse so an LLM can pick consistently + over many phases. The values map onto natural-language register: + + - ``trust``: この人は村陣営寄りだと感じる (white-leaning) + - ``low``: 弱い違和感、まだ静観 (mild raised eyebrow) + - ``medium``: はっきり怪しい、議論で詰めたい (clearly suspicious) + - ``high``: 今日処刑したい第一候補 (prime lynch target) + """ + + TRUST = "trust" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class Suspicion(BaseModel): + """One suspicion datum attached to a SpeechEvent. + + Keep frozen so the persisted row is immutable in memory once + constructed. The DB schema mirrors this shape. + + Anti-fabrication contract: when the speaker has previously declared + a suspicion against the same ``target_seat`` and is now amending it, + they MUST set ``update_from_level`` to the prior level and provide + a non-empty ``update_reason``. Subsequent prompts surface the full + history so an unannounced reversal (e.g. ``trust → high`` with + update_from_level=null) is detectable as evidence of fabrication. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + target_seat: int = Field(ge=1, le=9) + level: SuspicionLevel + reason: str = Field(min_length=1, max_length=500) + update_from_level: SuspicionLevel | None = Field( + default=None, + description=( + "Previous level this suspicion is updating from. None on the " + "first declaration against this target_seat. When non-null, " + "``update_reason`` MUST also be non-null and explain the " + "shift — silent reversals are anti-fabrication red flags." + ), + ) + update_reason: str | None = Field( + default=None, + max_length=500, + description=( + "Why the level changed from ``update_from_level`` to ``level``. " + "Required when ``update_from_level`` is set." + ), + ) + + +__all__ = ["Suspicion", "SuspicionLevel"] diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index 13b96db..0562aa0 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -21,6 +21,7 @@ from pydantic import BaseModel, ConfigDict, Field from wolfbot.domain.enums import CoDeclaration +from wolfbot.domain.suspicion import Suspicion class BaseEnvelope(BaseModel): @@ -169,6 +170,19 @@ class LogicPacket(BaseEnvelope): "matching CO arrives." ), ) + past_suspicions: tuple[ + tuple[int, str, int, int, str, str, str | None, str | None], ... + ] = Field( + default_factory=tuple, + description=( + "Public suspicion timeline carried as a flat tuple of " + "``(day, phase, suspecter_seat, target_seat, level, reason, " + "update_from_level | None, update_reason | None)`` rows in " + "chronological order. Used by the NPC speech LLM to render " + "the immutable suspicion history block so silent reversals " + "are detectable." + ), + ) class SpeakRequest(BaseEnvelope): @@ -334,6 +348,19 @@ class SpeakResult(BaseEnvelope): "non-alive seats are dropped on the Master boundary." ), ) + suspicions: tuple[Suspicion, ...] = Field( + default_factory=tuple, + description=( + "Structured suspicion records this utterance asserts. Each " + "entry says 「自分は target_seat を level (理由 reason) で見ている」. " + "Master persists them to ``speech_suspicions`` keyed on " + "``event_id`` and folds the immutable history into every " + "subsequent prompt so a silent reversal (e.g. trust → high " + "without setting ``update_from_level``) is detectable. " + "Empty tuple is allowed but discouraged — see the speech " + "system prompt's 名指し義務 rule." + ), + ) # ------------------------- Phase D: per-seat decision delegation diff --git a/src/wolfbot/master/arbiter/logic_service.py b/src/wolfbot/master/arbiter/logic_service.py index 20480a8..3c11436 100644 --- a/src/wolfbot/master/arbiter/logic_service.py +++ b/src/wolfbot/master/arbiter/logic_service.py @@ -47,6 +47,9 @@ def build_logic_packet( past_votes: Iterable[ tuple[int, int, tuple[tuple[int, int | None], ...]] ] = (), + past_suspicions: Iterable[ + tuple[int, str, int, int, str, str, str | None, str | None] + ] = (), seat_names: dict[int, str] | None = None, claim_history: ClaimHistory | None = None, recipient_seat_no: int | None = None, @@ -284,6 +287,7 @@ def _alive_tag(seat: int) -> str: recent_speeches=tuple(recent_speeches), past_votes=tuple(past_votes), pending_role_callouts=tuple(sorted(state.pending_role_callouts)), + past_suspicions=tuple(past_suspicions), ) diff --git a/src/wolfbot/master/arbiter/speak_arbiter.py b/src/wolfbot/master/arbiter/speak_arbiter.py index b4a524e..04dc8bf 100644 --- a/src/wolfbot/master/arbiter/speak_arbiter.py +++ b/src/wolfbot/master/arbiter/speak_arbiter.py @@ -394,6 +394,7 @@ async def dispatch_request( role_strategy, ) = await self._collect_request_context(state, seat_no) past_votes = await self._load_past_votes(game_id, state.day) + past_suspicions = await self._load_past_suspicions(game_id) seat_names_lookup: dict[int, str] = { seat: name for seat, name in (list(alive_seats) + list(dead_seats)) } @@ -408,6 +409,7 @@ async def dispatch_request( now_ms=now, recent_speeches=recent_speeches, past_votes=past_votes, + past_suspicions=past_suspicions, seat_names=seat_names_lookup, claim_history=claim_history, recipient_seat_no=seat_no, @@ -668,6 +670,33 @@ async def _load_claim_history( return None return collect_claim_history(events, seat_names=seat_names) + async def _load_past_suspicions( + self, game_id: str + ) -> tuple[tuple[int, str, int, int, str, str, str | None, str | None], ...]: + """Load the public suspicion timeline for the game. + + Best-effort: returns an empty tuple on any DB glitch so a + suspicions-table read failure cannot stall a SpeakRequest. + """ + try: + rows = await self.repo.load_speech_suspicions_for_game(game_id) + except Exception: + log.exception("past_suspicions_load_failed game=%s", game_id) + return () + return tuple( + ( + int(r["day"]), + str(r["phase"]), + int(r["suspecter_seat"]), + int(r["target_seat"]), + str(r["level"]), + str(r["reason"]), + r["update_from_level"] if r["update_from_level"] is not None else None, + r["update_reason"] if r["update_reason"] is not None else None, + ) + for r in rows + ) + async def _load_past_votes( self, game_id: str, current_day: int ) -> tuple[tuple[int, int, tuple[tuple[int, int | None], ...]], ...]: @@ -1180,6 +1209,31 @@ async def _reject_and_retry_same_npc( created_at_ms=now, ) await self.discussion.record(speech_event) + # Persist structured suspicions attached to this utterance. Drop + # entries that target the speaker's own seat (the SpeakResult + # builder already filters but a stale NPC binary could slip + # through). Empty list is a no-op. + valid_suspicions = tuple( + s for s in result.suspicions if s.target_seat != pending.seat_no + ) + if valid_suspicions: + try: + await self.repo.insert_speech_suspicions( + event_id=speech_event.event_id, + game_id=pending.game_id, + day=day, + phase=phase, + suspecter_seat=pending.seat_no, + created_at_ms=now, + suspicions=valid_suspicions, + ) + except Exception: + log.exception( + "speech_suspicions_insert_failed game=%s seat=%d event=%s", + pending.game_id, + pending.seat_no, + speech_event.event_id, + ) deadline = now + self.config.playback_deadline_ms await self.repo.open_npc_playback( request_id=result.request_id, diff --git a/src/wolfbot/npc/speech/openai_compatible_generator.py b/src/wolfbot/npc/speech/openai_compatible_generator.py index 5dced29..6020f6d 100644 --- a/src/wolfbot/npc/speech/openai_compatible_generator.py +++ b/src/wolfbot/npc/speech/openai_compatible_generator.py @@ -39,6 +39,7 @@ from typing import Literal from wolfbot.domain.enums import CO_CLAIM_VALUES +from wolfbot.domain.suspicion import Suspicion from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, SpeakRequest from wolfbot.llm.persona_base import Persona from wolfbot.llm.prompt_builder import ( @@ -52,6 +53,8 @@ log = logging.getLogger(__name__) +_SUSPICION_LEVELS = ["trust", "low", "medium", "high"] + _RESPONSE_SCHEMA: dict[str, object] = { "name": "reactive_speech", "strict": True, @@ -66,6 +69,7 @@ "addressed_seat_nos", "claimed_seer_result", "claimed_medium_result", + "suspicions", ], "properties": { "text": {"type": "string", "maxLength": 300}, @@ -134,6 +138,63 @@ "encodes 'no execution yesterday → no result today'." ), }, + "suspicions": { + "type": "array", + "description": ( + "Structured suspicion records the utterance asserts. " + "Each entry says 「自分は target_seat を level (理由 reason) " + "で見ている」. Master persists them keyed on event_id " + "and folds the immutable history into every subsequent " + "prompt so a silent reversal is detectable. " + "Empty array is allowed but discouraged — the system " + "prompt's 名指し義務 rule expects ≥1 entry per speak." + ), + "items": { + "type": "object", + "additionalProperties": False, + "required": [ + "target_seat", + "level", + "reason", + "update_from_level", + "update_reason", + ], + "properties": { + "target_seat": { + "type": "integer", + "minimum": 1, + "maximum": 9, + }, + "level": { + "type": "string", + "enum": _SUSPICION_LEVELS, + "description": ( + "trust=村寄り信頼 / low=弱い疑い / " + "medium=明確に怪しい / high=処刑第一候補" + ), + }, + "reason": {"type": "string", "maxLength": 500}, + "update_from_level": { + "type": ["string", "null"], + "enum": [*_SUSPICION_LEVELS, None], + "description": ( + "Set ONLY when amending a prior suspicion " + "against the same target_seat. Must record " + "the previous level. Silent reversals are " + "anti-fabrication red flags." + ), + }, + "update_reason": { + "type": ["string", "null"], + "maxLength": 500, + "description": ( + "Why the level changed. Required when " + "update_from_level is non-null." + ), + }, + }, + }, + }, }, }, } @@ -388,6 +449,43 @@ def _voter_label(seat: int | None) -> str: lines.append(f"- day{day} {label}:") for voter, target in pairs: lines.append(f" {_voter_label(voter)} → {_voter_label(target)}") + if logic.past_suspicions: + # Public suspicion timeline. The LLM uses this both as a memory + # aid (don't fabricate a contradicting suspicion) and as evidence + # (a target who has been suspected by N seats with consistent + # reasoning is a strong lynch candidate). Silent reversals + # (where update_from_level is null but the speaker had previously + # declared a different level) are anti-fabrication red flags. + lines.append("") + lines.append("## 公開された疑い履歴 (古い順、不変記録)") + seat_name_for_susp = { + seat_no: name + for seat_no, name in (list(alive_seats) + list(dead_seats)) + } + level_label = { + "trust": "信頼", + "low": "弱疑", + "medium": "疑", + "high": "強疑", + } + for ( + day, + _phase, + suspecter, + target, + level, + reason, + from_level, + update_reason, + ) in logic.past_suspicions: + sname = seat_name_for_susp.get(suspecter) or f"席{suspecter}" + tname = seat_name_for_susp.get(target) or f"席{target}" + level_text = level_label.get(level, level) + line = f"- day{day} {sname} → {tname} ({level_text}): {reason}" + if from_level is not None: + from_text = level_label.get(from_level, from_level) + line += f" [{from_text}→{level_text} 更新理由: {update_reason or '(未記入)'}]" + lines.append(line) if logic.recent_speeches: lines.append("") lines.append("## 直近の発言 (古い順)") @@ -775,6 +873,7 @@ def _build_speech_from_json(data: dict[str, object]) -> NpcGeneratedSpeech | Non data.get("claimed_medium_result"), allow_null_verdict=True, ) + suspicions = _parse_suspicions(data.get("suspicions")) # Rough estimate: ~150ms per character for TTS estimated_ms = max(500, len(text) * 150) @@ -790,9 +889,32 @@ def _build_speech_from_json(data: dict[str, object]) -> NpcGeneratedSpeech | Non claimed_seer_is_wolf=seer_is_wolf, claimed_medium_target_seat=medium_seat, claimed_medium_is_wolf=medium_is_wolf, + suspicions=suspicions, ) +def _parse_suspicions(raw: object) -> tuple[Suspicion, ...]: + """Coerce a raw `suspicions` array from structured-output JSON into a + tuple of validated `Suspicion` models. + + Drops malformed entries silently (mirrors `_parse_claim_fields`'s + forgiveness — the speech is still delivered without the bad + suspicion attached). Empty / non-list input → empty tuple. + """ + if not isinstance(raw, list): + return () + out: list[Suspicion] = [] + for entry in raw: + if not isinstance(entry, dict): + continue + try: + out.append(Suspicion.model_validate(entry)) + except Exception: + log.debug("malformed suspicion entry dropped: %r", entry) + continue + return tuple(out) + + def _parse_claim_fields(raw: object, *, allow_null_verdict: bool) -> tuple[int | None, bool | None]: """Coerce a structured claim dict into ``(target_seat, is_wolf)``. diff --git a/src/wolfbot/npc/speech/speech_service.py b/src/wolfbot/npc/speech/speech_service.py index 633dc77..9fe3285 100644 --- a/src/wolfbot/npc/speech/speech_service.py +++ b/src/wolfbot/npc/speech/speech_service.py @@ -15,6 +15,7 @@ from typing import Protocol, runtime_checkable from wolfbot.domain.enums import CO_CLAIM_VALUES, CoDeclaration +from wolfbot.domain.suspicion import Suspicion from wolfbot.domain.ws_messages import ( ClaimedMediumResult, ClaimedSeerResult, @@ -46,6 +47,10 @@ class NpcGeneratedSpeech: claimed_seer_is_wolf: bool | None = None claimed_medium_target_seat: int | None = None claimed_medium_is_wolf: bool | None = None + # Structured suspicion records this utterance asserts. Empty tuple + # means no suspicion was declared (allowed but discouraged — see + # the speech system prompt's 名指し義務 rule). + suspicions: tuple[Suspicion, ...] = () @runtime_checkable @@ -163,6 +168,13 @@ async def respond( claimed_seer = _build_claimed_seer(speech, speaker_seat=request.seat_no) claimed_medium = _build_claimed_medium(speech, speaker_seat=request.seat_no) + # Filter self-suspicions (target == speaker). The LLM occasionally + # mis-targets itself; rather than fail the whole speech, drop just + # the bad entries — same forgiveness as the addressed_seat_nos + # self-filter above. + valid_suspicions = tuple( + s for s in speech.suspicions if s.target_seat != request.seat_no + ) return SpeakResult( ts=now_ms, @@ -180,6 +192,7 @@ async def respond( claimed_medium_result=claimed_medium, addressed_seat_no=addressed_seat_no, addressed_seat_nos=addressed_seat_nos, + suspicions=valid_suspicions, ) diff --git a/src/wolfbot/persistence/sql/schema/15_speech_suspicions.sql b/src/wolfbot/persistence/sql/schema/15_speech_suspicions.sql new file mode 100644 index 0000000..99e6d51 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/15_speech_suspicions.sql @@ -0,0 +1,36 @@ +-- Per-utterance structured suspicion record. Each row is one +-- "席X が 席Y に level=high (理由)" datum attached to a SpeechEvent. +-- Composite primary key (event_id, seq) lets a single utterance carry +-- multiple suspicions while keeping the row immutable once written. +-- +-- Anti-fabrication: subsequent prompts surface the full immutable +-- history so a speaker who silently reverses a prior suspicion +-- (e.g. trust → high without setting update_from_level) is detectable. +-- Updates set update_from_level + update_reason to make the shift +-- explicit. +CREATE TABLE IF NOT EXISTS speech_suspicions ( + event_id TEXT NOT NULL REFERENCES speech_events(event_id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + day INTEGER NOT NULL, + phase TEXT NOT NULL, + suspecter_seat INTEGER NOT NULL, + target_seat INTEGER NOT NULL, + level TEXT NOT NULL, + reason TEXT NOT NULL, + update_from_level TEXT, + update_reason TEXT, + created_at_ms INTEGER NOT NULL, + PRIMARY KEY (event_id, seq) +); + +-- Per-game timeline: walk every suspicion in chronological order so +-- prompt builders and viewer rendering both see the same ordering. +CREATE INDEX IF NOT EXISTS idx_susp_game_day + ON speech_suspicions(game_id, day, created_at_ms); + +-- Per-(suspecter, target) lookup for fabrication detection: given a +-- candidate new suspicion, query the latest prior level so the +-- prompt builder can highlight unannounced reversals. +CREATE INDEX IF NOT EXISTS idx_susp_pair + ON speech_suspicions(game_id, suspecter_seat, target_seat, created_at_ms); diff --git a/src/wolfbot/persistence/sqlite_repo.py b/src/wolfbot/persistence/sqlite_repo.py index 8f3c70f..b211860 100644 --- a/src/wolfbot/persistence/sqlite_repo.py +++ b/src/wolfbot/persistence/sqlite_repo.py @@ -1103,6 +1103,103 @@ async def close_npc_playback( ) await self._db.commit() + async def insert_speech_suspicions( + self, + *, + event_id: str, + game_id: str, + day: int, + phase: Phase, + suspecter_seat: int, + created_at_ms: int, + suspicions: Sequence[Any], + ) -> None: + """Persist a batch of `Suspicion` records attached to one SpeechEvent. + + Uses ``INSERT OR IGNORE`` so a duplicate ``(event_id, seq)`` write + (e.g. on retry of the same speech result) is a silent no-op rather + than a constraint violation that aborts the whole batch. + Empty `suspicions` is a fast-path no-op. + """ + if not suspicions: + return + rows = [ + ( + event_id, + seq, + game_id, + day, + phase.value, + suspecter_seat, + int(s.target_seat), + str(s.level.value if hasattr(s.level, "value") else s.level), + str(s.reason), + ( + s.update_from_level.value + if s.update_from_level is not None + and hasattr(s.update_from_level, "value") + else ( + str(s.update_from_level) + if s.update_from_level is not None + else None + ) + ), + s.update_reason, + created_at_ms, + ) + for seq, s in enumerate(suspicions) + ] + async with self._tx() as db: + await db.executemany( + """ + INSERT OR IGNORE INTO speech_suspicions ( + event_id, seq, game_id, day, phase, + suspecter_seat, target_seat, level, reason, + update_from_level, update_reason, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + rows, + ) + + async def load_speech_suspicions_for_game( + self, game_id: str + ) -> list[dict[str, Any]]: + """All suspicions for a game in chronological order. + + Returned as a list of plain dicts so the caller can decide + whether to project into a Pydantic model. Used by prompt + builders to render the public history block and by viewer / + export tooling to draw the suspicion timeline. + """ + async with self._db.execute( + """ + SELECT event_id, seq, day, phase, suspecter_seat, target_seat, + level, reason, update_from_level, update_reason, + created_at_ms + FROM speech_suspicions + WHERE game_id=? + ORDER BY created_at_ms ASC, event_id ASC, seq ASC + """, + (game_id,), + ) as cur: + rows = await cur.fetchall() + return [ + { + "event_id": row[0], + "seq": int(row[1]), + "day": int(row[2]), + "phase": str(row[3]), + "suspecter_seat": int(row[4]), + "target_seat": int(row[5]), + "level": str(row[6]), + "reason": str(row[7]), + "update_from_level": row[8], + "update_reason": row[9], + "created_at_ms": int(row[10]), + } + for row in rows + ] + async def load_open_npc_speak_requests(self, game_id: str) -> list[dict[str, Any]]: async with self._db.execute( """ diff --git a/src/wolfbot/prompts/templates/master/task_daytime_speech.md b/src/wolfbot/prompts/templates/master/task_daytime_speech.md index 4abb76c..974a340 100644 --- a/src/wolfbot/prompts/templates/master/task_daytime_speech.md +++ b/src/wolfbot/prompts/templates/master/task_daytime_speech.md @@ -1,2 +1,23 @@ 現在は day {{day_number}} の議論フェイズです。 必要と感じた場合のみ `intent=speak` を返し、`public_message` に 80〜300 字で短い発言を書いてください。 発言したくない場合は `intent=skip` と明示してください。 疑い先を出すときは、単体の怪しさだけでなく、その人物が人狼なら相方候補は誰か、2 人狼セットとして票筋・噛み筋が自然かも必要に応じて短く触れてください。全候補のペアを長く列挙せず、今の結論に効く 1〜2 点だけを出してください。 **day {{day_number}} ≥ 2 の朝**には、`## 公開された投票履歴` を必ず確認し、前日の投票に「単独情報役 (単独占い CO/単独霊媒 CO) への異端票」「確定黒に乗らない異端票」「揃った2票」のいずれかが観測されたら、それを共通ルールに従い**自然な日本語**で引用しつつ発話に織り込んでください (例: 「昨日の投票、X を処刑する流れの中で Y だけ Z に入れていた、あれは引っかかる」)。指摘せず流すのは村陣営の情報損失で、致命的な見落としになります。 + +**HARD: 怪しい人物の名指し義務 (毎発話で原則必須)**: 一般論・整理・確認・呼びかけだけで終わる発話は禁止。`intent=speak` で発話する場合は、原則として「**この人物が怪しい / 信用できる**」という具体的な名指し評価を 1 件以上含めること。「次は誰を吊ろうか」「議論を整理しよう」「みんなで話し合おう」だけの一般論で締めくくる発話は、村側にも狼側にも情報を与えない無価値発話として扱われる。 + +- 名指しの粒度: 候補 1 名 (例「ジョナスがどうにも怪しい」) または 1〜2 名比較 (例「ジナよりはコメットが落ち着いて見える」)。3 人以上の連名はノイズになるので避ける。 +- 根拠の粒度: day 1 朝は公開情報が薄いので「**それっぽい理由**」で構わない。発言の歯切れ、占い師への反応の温度感、議論への食いつき方、視線が泳いでいるような描写、口数の少なさ — すべて day 1 では合理的な根拠候補。トーン読みであることを隠さず「印象だけど」「まだ何とも言えないけれど」と前置きしてもよいが、必ず**特定 seat の名前**を1人は出す。 +- 役職持ちは結果公表後も積極的に疑い先を出す: 占い師は「次に占うべき相手と疑っている相手」、霊媒師は「霊媒結果を踏まえて生存者の中で気になる位置」、騎士は CO 前なら灰位置として印象を、CO 後なら護衛価値が高い位置と疑い位置を一緒に。役職結果報告だけで発話を終えると、せっかくの分析機会を捨てている。 +- 陣営別の自然な動機: + - **村陣営 (占・霊・騎・村人)**: 灰の中で発言・反応の不自然な seat、占い師の判定を疑っている seat、議論進行を曖昧に流そうとする seat を疑う。 + - **人狼・狂人陣営**: 灰位置の中で**真の村人**を選んで疑う (誤った吊り筋を作る)。「占い師の判定が早すぎる」「結果が都合よすぎる」と真情報役を疑う発話も典型的攪乱。ただし発言が露骨すぎると逆に透けるので、村側論理に乗ったフリで進める。 +- day 1 朝はまだ占い結果 1 件しか手がかりが無く具体的根拠が少ない局面だが、それでも全員が「印象でいいから誰か 1 人を疑い、誰か 1 人を信用する」発話を出すこと。沈黙・一般論は最大の損失。 + +挨拶・呼応・短い返答 (例「ありがとう」「了解」) のみで終わる発話は禁止。発言する以上は必ず推理を一歩前に進める材料 (誰を疑う / 誰を信用する / どの発言が引っかかる) を 1 つは出す。 + +**HARD: `suspicions` フィールドの記入義務 + 不変性ルール**: `intent=speak` の発話では、上の名指し義務に対応する **構造化疑い記録** を `suspicions` 配列に必ず 1 件以上入れる。各エントリの形: +- `target_seat`: 疑う/信用する相手の席番号 (自分は禁止) +- `level`: `"trust"` (村寄り) / `"low"` (弱疑) / `"medium"` (明確に怪しい) / `"high"` (処刑第一候補) +- `reason`: その評価の理由 (`public_message` で言ったことと整合させる) +- `update_from_level`: 過去にこの target_seat に対して別の level を宣言していた場合のみ、その level を入れる。初回宣言なら `null` +- `update_reason`: `update_from_level` が非 null なら、なぜ評価が変わったかを必ず記入。null なら `null` + +**不変性 (anti-fabrication)**: プロンプトに記録されている過去の自分の宣言を**黙って書き換える**のは禁止。観測者は捏造として扱う。立場を変えるなら必ず `update_from_level` + `update_reason` を埋める。`public_message` と `suspicions` は内容が整合していなければならない。 発話 (`public_message`) のルール: 自然な日本語で喋ること。「CO」「占いCO」「霊媒CO」「騎士CO」「黒判定」「白判定」「ライン」「グレー」「グレラン」「縄」「PP」「RPP」「ローラー」「ロラ」「破綻」「確白」「確黒」「パンダ」「鉄板護衛」「捨て護衛」「噛み筋」「票筋」「視点漏れ」「身内切り」「囲い」など、プレイヤー間で使われがちなメタ用語は `public_message` 内で使わない(これらは内部の `reason_summary` や思考メモには使ってよい)。口に出すときは状況や感情として描写する(例: 「あの白判定、無理に庇ってる気がして信用できない」「昨夜守ったのは◯◯です」「あと処刑できる回数を考えると…」)。役職 CO したい場合は `co_declaration` を `{{co_claim_options}}` のいずれかに設定し、`public_message` 側は「実は私、占い師なんだ」のように自然に名乗ってから能力結果を続ける。`co_declaration` を設定しないなら `null`。{{#if include_day2_round1_results_block}} これは day 2 以降の 1 巡目発言です。 占い師・霊媒師・騎士として既に名乗っている、または今初めて名乗る場合は、前夜の能力結果をこの発言で添えてください。 占い師なら対象 + 白/黒 + 占い理由、霊媒師なら前日処刑者 + 人狼/人狼ではない/結果なし、騎士なら名乗る局面で合法な護衛履歴 (護衛日 + 護衛先) を日付順に出し、平和な朝なら護衛成功も合わせて伝えます。 結果を持つはずの役職を名乗っているのに 1 巡目で結果を出さないと、信用低下や破綻疑いにつながります。 ただし `public_message` 内ではこの「白/黒」「結果なし」のような内部メモ語彙をそのまま読み上げず、「結果は人狼でした」「占ったけど人狼じゃなかった」「占ってみた感触は白かな」のように自然な発話に書き直す。 名乗り直すならこのターンで `co_declaration` を立て、`public_message` でも自然な日本語で名乗りと結果を述べてください。{{/if}}{{#if include_day1_round1_wolf_madman_block}} day 1 の 1 巡目で偽 CO を選ぶ場合、占い師騙り・霊媒師騙り・潜伏の 3 択を比較してください。 霊媒師騙りに出ると、真霊媒との 1-2、または別の騙り狼との組み合わせで 2-2 を作りに行けます。 占い師騙り CO 数・縄数・潜伏で白を取る余地と比べて、得な方を選びます。 霊媒師騙りで day 1 に CO する場合は、まだ処刑がないので初日の霊媒結果は出さず、翌日処刑があった時に結果を出す立ち回りだと添えてください。{{/if}} \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/npc/speech_system.md b/src/wolfbot/prompts/templates/npc/speech_system.md index 548fb6b..c4168e1 100644 --- a/src/wolfbot/prompts/templates/npc/speech_system.md +++ b/src/wolfbot/prompts/templates/npc/speech_system.md @@ -32,3 +32,19 @@ - **占い/霊媒結果は構造化フィールドにも必ず反映する。**発話 `text` で占い結果を述べる場合 (本物でも騙りでも同じ): `claimed_seer_result` に `{"target_seat": 対象席, "is_wolf": true/false}` を設定し、述べた席・結果と完全一致させる。霊媒も同様に `claimed_medium_result` を使う(処刑なし/結果なしを明言する場合は `is_wolf=null` を設定し、`target_seat` は処刑対象の席)。新しい結果を発表しない発話 (前回までの結果に言及するだけ・一般議論・他人への質問) では 両フィールドとも `null` にする。プロンプト中の `## 公開された占い/霊媒CO結果` ブロックが過去の発表履歴 (公式記録) であり、そこに矛盾する/重複する/数が合わない結果を出すと **破綻判定** され狼/騙り側は即吊られる。占いCO した seat は day N の朝までに通算 N 個の結果を発表できる (day1 朝で NIGHT_0 ランダム白 1 件、day N 朝までに各夜分が +1 件ずつ加算; day1 朝なら通算 1 個、day2 朝なら 2 個…)。**同じ day に 2 件目の占い結果は出せない (本物の占い師は一夜 1 件まで)**。決選演説 (DAY_RUNOFF_SPEECH) もその day 内なので、新しい占い結果は出さない。1 回の発話で複数 seat を占ったと言う「すべて白」「全員白」は破綻なので絶対に出さない。 - 特定の席に向けて話す場合は `addressed_seat_nos` にその席番号の配列を入れる。1人だけなら `[3]`、複数人に同時に問いかけるなら `[2, 3]` (例「セツとジナはどう?」)。誰宛でもない一般的な発言や全体への呼びかけは空配列 `[]`。自分の席を指定しても無効化されるので、相手の席を必ず入れること。`text` 中で名前を呼んだ全員ぶんを `addressed_seat_nos` に列挙する。Master は配列の全員を次に発話する優先候補として扱うので、問いかけた人数に応じて漏れなく入れる。 - 死亡者リストには (処刑) または (襲撃) の死因タグが付く。前日の処刑死を「昨夜の犠牲者」と混同しない。逆も同様。発言で死を語るときはタグに合わせた表現を使う(例: 処刑死は「昨日処刑された」、襲撃死は「昨夜襲われた」)。 +- **HARD: 怪しい人物の名指し義務**: `intent=speak` で発話する場合、原則として「**この人物が怪しい / この人物は信用できる**」という具体的な名指し評価を 1 件以上含めること。一般論・整理・確認・呼びかけ・挨拶だけで終わる発話は無価値で禁止。 + - 名指しの粒度: 候補 1 名 (例「ジョナスがどうにも引っかかる」) または 1〜2 名比較 (例「ジナよりはコメットが落ち着いて見える」)。3 人以上の連名はノイズになるので避ける。 + - 根拠の粒度: day 1 朝はまだ占い結果 1 件しか公開情報が無く具体根拠が薄い局面だが、それでも全員が「印象でいいから誰か 1 人を疑い、誰か 1 人を信用する」発話を出す。発言の歯切れ・口数・話題への食いつき方・占い師への反応の温度感など、トーン読みベースの **「それっぽい理由」** で構わない。「印象だけど」「まだ何とも言えないけれど」と前置きしてもよいが、必ず**特定の名前**を1人は出すこと。 + - 役職持ちは結果報告だけで終わらない: 占い師は「次に占うべき相手・疑っている相手」、霊媒師は「霊媒結果を踏まえて生存者で気になる位置」、騎士は CO 後なら護衛価値の高い位置と疑い位置を一緒に。役職結果を出した後こそ、その情報をもとに**疑い先を積極的に名指しする**。 + - 陣営別の自然な動機 (内部判断、口に出す必要なし): + - 村陣営 (占・霊・騎・村人): 灰の中で発言や反応の不自然な seat、占い師の判定を疑っている seat、議論進行を曖昧に流そうとする seat を疑う。 + - 人狼・狂人陣営: 灰位置の中で**真の村人**を選んで疑い、誤った吊り筋を作る。「占い師の判定が早すぎる」「結果が都合よすぎる」と真情報役を疑う発話も典型的攪乱。ただし発言が露骨すぎると逆に透けるので、村側論理に乗ったフリで進める。 + - 例外的に skip 推奨: 既に同じ場面で複数回発話済みで、新たな情報が無いとき (狂人の長期沈黙ムーブなど)。ただし `intent=skip` を選ぶ場合は明確な理由がある場合のみ。沈黙そのものが評価される局面は稀。 +- **HARD: `suspicions` フィールドの記入義務 + 不変性ルール**: `intent=speak` の発話では、上の名指し義務に対応する **構造化疑い記録** を `suspicions` 配列に必ず 1 件以上入れる。各エントリの形: + - `target_seat`: 疑う/信用する相手の席番号 (自分は禁止) + - `level`: `"trust"` / `"low"` / `"medium"` / `"high"` のいずれか (信頼 / 弱疑 / 疑 / 強疑) + - `reason`: その評価の理由 (`text` で言ったことと整合させる) + - `update_from_level`: 過去にこの target_seat に対して別の level を宣言していた場合のみ、その level を入れる。初回宣言なら `null` + - `update_reason`: `update_from_level` が非 null なら、なぜ評価が変わったかを必ず記入。null なら `null` +- **不変性 (anti-fabrication)**: `## 公開された疑い履歴` ブロックに過去の自分の宣言が記録されている。今回の発話で過去と矛盾する level に変える場合は **必ず** `update_from_level` + `update_reason` を埋めて明示的に更新する。**過去の宣言を黙って書き換えるのは禁止**で、観測した側は捏造の証拠として扱える。逆に、過去と同じ level を再宣言する場合 (= 立場を維持) は `update_from_level=null` のままで良い (履歴上は新しい reason だけ追加される)。 +- **`text` と `suspicions` の整合**: `text` で「ジナさんが怪しい」と言ったら `suspicions` にも `{target_seat: 2 (=ジナ), level: "medium" 以上, reason: ...}` を入れる。`text` で何も触れていないのに `suspicions` だけ入れる、あるいはその逆をするのは整合性違反。 diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index 0ec4fe9..5524919 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -54,6 +54,7 @@ legal_guard_targets, previous_guard_seat_for_night, ) +from wolfbot.domain.suspicion import Suspicion from wolfbot.llm.decider_config import LLMDeciderConfig from wolfbot.llm.prompt_builder import ( build_system_prompt, @@ -152,6 +153,12 @@ class LLMAction(BaseModel): # a new divination outcome. claimed_seer_result: _ClaimedSeerAction | None = None claimed_medium_result: _ClaimedMediumAction | None = None + # Structured suspicion records this utterance asserts. Mirrors the + # reactive_voice NPC speech schema so the public suspicion timeline + # is built from one source-of-truth shape across both modes. Empty + # tuple is allowed; the speech-task prompt's 名指し義務 rule asks + # for ≥1 entry per intent=speak. + suspicions: tuple[Suspicion, ...] = () RESPONSE_SCHEMA: dict[str, object] = { @@ -169,6 +176,7 @@ class LLMAction(BaseModel): "co_declaration", "claimed_seer_result", "claimed_medium_result", + "suspicions", ], "properties": { "intent": { @@ -209,6 +217,47 @@ class LLMAction(BaseModel): "is_wolf": {"type": ["boolean", "null"]}, }, }, + "suspicions": { + "type": "array", + "description": ( + "Structured suspicion records this utterance asserts. " + "Each entry says 「自分は target_seat を level (理由 reason) " + "で見ている」. Master persists them keyed on event_id " + "and folds the immutable history into every subsequent " + "prompt so a silent reversal is detectable." + ), + "items": { + "type": "object", + "additionalProperties": False, + "required": [ + "target_seat", + "level", + "reason", + "update_from_level", + "update_reason", + ], + "properties": { + "target_seat": { + "type": "integer", + "minimum": 1, + "maximum": 9, + }, + "level": { + "type": "string", + "enum": ["trust", "low", "medium", "high"], + }, + "reason": {"type": "string", "maxLength": 500}, + "update_from_level": { + "type": ["string", "null"], + "enum": ["trust", "low", "medium", "high", None], + }, + "update_reason": { + "type": ["string", "null"], + "maxLength": 500, + }, + }, + }, + }, }, }, } @@ -1618,6 +1667,7 @@ async def _do_one_discussion_speech( claimed_seer_is_wolf=seer_verdict, claimed_medium_target_seat=medium_seat, claimed_medium_is_wolf=medium_verdict, + suspicions=action.suspicions, ) # --------------------------------------------------- runoff candidate speech @@ -1779,6 +1829,7 @@ async def _do_one_runoff_speech( claimed_seer_is_wolf=seer_verdict, claimed_medium_target_seat=medium_seat, claimed_medium_is_wolf=medium_verdict, + suspicions=action.suspicions, ) async def _emit_npc_speech_event( @@ -1792,6 +1843,7 @@ async def _emit_npc_speech_event( claimed_seer_is_wolf: bool | None = None, claimed_medium_target_seat: int | None = None, claimed_medium_is_wolf: bool | None = None, + suspicions: Sequence[Suspicion] = (), ) -> None: """Persist a SpeechEvent(source=npc_generated) for an LLM utterance. @@ -1838,6 +1890,24 @@ async def _emit_npc_speech_event( # rounds-mode path already posted it via MessagePoster) and # avoids re-inserting the PLAYER_SPEECH LogEntry (already inserted). await self.discussion_service.record_persist_only(event) + # Persist structured suspicions attached to this utterance. + # Drop self-targeted entries (LLM occasionally mis-targets + # itself); rest go to the speech_suspicions table keyed on + # the event_id we just wrote. + valid_suspicions = [ + s for s in suspicions if s.target_seat != speaker_seat + ] + if valid_suspicions: + from wolfbot.services.discussion_service import now_ms + await self.repo.insert_speech_suspicions( + event_id=event.event_id, + game_id=game.id, + day=game.day_number, + phase=game.phase, + suspecter_seat=speaker_seat, + created_at_ms=now_ms(), + suspicions=valid_suspicions, + ) except Exception: log.exception( "SpeechEvent(npc_generated) write failed for game=%s seat=%s", diff --git a/tests/test_llm_structured_output.py b/tests/test_llm_structured_output.py index ecd2945..2e4ee83 100644 --- a/tests/test_llm_structured_output.py +++ b/tests/test_llm_structured_output.py @@ -69,6 +69,7 @@ def test_response_schema_has_required_fields() -> None: "co_declaration", "claimed_seer_result", "claimed_medium_result", + "suspicions", } assert schema["additionalProperties"] is False From 58934e7e2e55a29bc3de0df8188f386f0a72b22c Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sun, 3 May 2026 00:42:45 +0900 Subject: [PATCH 129/133] feat(suspicions): vote + seer divine paths emit suspicions; wolves avoid high-suspect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ユーザー要望「投票時の思考でも 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。 --- src/wolfbot/domain/ws_messages.py | 26 +++ src/wolfbot/main.py | 1 + src/wolfbot/master/arbiter/public_digest.py | 30 +++ src/wolfbot/master/arbiter/speak_arbiter.py | 2 +- src/wolfbot/master/ws/decision_dispatcher.py | 173 ++++++++++++++++++ src/wolfbot/npc/decision/decision_service.py | 136 +++++++++++++- src/wolfbot/npc/runtime/client.py | 35 ++-- .../sql/schema/15_speech_suspicions.sql | 36 ---- .../persistence/sql/schema/15_suspicions.sql | 61 ++++++ src/wolfbot/persistence/sqlite_repo.py | 131 +++++++++---- .../prompts/templates/strategy/werewolf.md | 1 + src/wolfbot/services/llm_service.py | 26 +++ 12 files changed, 568 insertions(+), 90 deletions(-) delete mode 100644 src/wolfbot/persistence/sql/schema/15_speech_suspicions.sql create mode 100644 src/wolfbot/persistence/sql/schema/15_suspicions.sql diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py index 0562aa0..e0559e1 100644 --- a/src/wolfbot/domain/ws_messages.py +++ b/src/wolfbot/domain/ws_messages.py @@ -527,6 +527,17 @@ class VoteDecision(BaseEnvelope): seat_no: int target_seat: int | None reason_summary: str = "" + suspicions: tuple[Suspicion, ...] = Field( + default_factory=tuple, + description=( + "Structured suspicion records the NPC asserts as part of " + "this vote decision. Master persists them to the immutable " + "suspicion timeline so vote-time reasoning is part of the " + "captured public history. The vote target is implicitly a " + "high suspicion; Master auto-promotes target_seat to " + "level=high if no explicit suspicion entry exists for it." + ), + ) class DecideNightActionRequest(BaseEnvelope): @@ -560,6 +571,21 @@ class NightActionDecision(BaseEnvelope): action_kind: Literal["wolf_attack", "seer_divine", "knight_guard"] target_seat: int | None reason_summary: str = "" + suspicions: tuple[Suspicion, ...] = Field( + default_factory=tuple, + description=( + "Structured suspicion records. Surfaced primarily for " + "``seer_divine`` (the chosen target IS the seat the seer " + "most wants to verify, which maps cleanly to a suspicion " + "entry). Master persists these to the timeline and " + "auto-promotes the divine target to level=medium if not " + "explicitly listed (seer divine is information-seeking, " + "not a lynch declaration). Wolf-attack and knight-guard " + "decisions do not produce suspicion entries by default — " + "wolves know their targets and guards declare protection, " + "not suspicion." + ), + ) class WolfChatRequest(BaseEnvelope): diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index 75df81c..b3deb6b 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -767,6 +767,7 @@ def _runoff_wake(game_id: str) -> None: decision_dispatcher = NpcDecisionDispatcher( registry=npc_registry, now_ms=lambda: int(time.time() * 1000), + repo=repo, ) llm_adapter._npc_decision_dispatcher = decision_dispatcher diff --git a/src/wolfbot/master/arbiter/public_digest.py b/src/wolfbot/master/arbiter/public_digest.py index 7907091..c9eea6e 100644 --- a/src/wolfbot/master/arbiter/public_digest.py +++ b/src/wolfbot/master/arbiter/public_digest.py @@ -48,6 +48,9 @@ def build_public_digest( recent_events: Sequence[SpeechEvent], seat_names: dict[int, str], past_votes: Sequence[tuple[int, int, Sequence[tuple[int, int | None]]]] = (), + past_suspicions: Sequence[ + tuple[int, str, int, int, str, str, str | None, str | None] + ] = (), ) -> str: """Compose the Japanese digest block. @@ -135,6 +138,33 @@ def _name(seat: int) -> str: target_label = "棄権" if target is None else _name(target) lines.append(f" {voter_label} → {target_label}") + if past_suspicions: + lines.append("## 公開された疑い履歴 (古い順、不変記録)") + level_label = { + "trust": "信頼", + "low": "弱疑", + "medium": "疑", + "high": "強疑", + } + for ( + day, + _phase, + suspecter, + target, + level, + reason, + from_level, + update_reason, + ) in past_suspicions: + sname = _name(suspecter) + tname = _name(target) + level_text = level_label.get(level, level) + row = f"- day{day} {sname} → {tname} ({level_text}): {reason}" + if from_level is not None: + from_text = level_label.get(from_level, from_level) + row += f" [{from_text}→{level_text} 更新理由: {update_reason or '(未記入)'}]" + lines.append(row) + return "\n".join(lines) diff --git a/src/wolfbot/master/arbiter/speak_arbiter.py b/src/wolfbot/master/arbiter/speak_arbiter.py index 04dc8bf..b0c073f 100644 --- a/src/wolfbot/master/arbiter/speak_arbiter.py +++ b/src/wolfbot/master/arbiter/speak_arbiter.py @@ -679,7 +679,7 @@ async def _load_past_suspicions( suspicions-table read failure cannot stall a SpeakRequest. """ try: - rows = await self.repo.load_speech_suspicions_for_game(game_id) + rows = await self.repo.load_suspicions_for_game(game_id) except Exception: log.exception("past_suspicions_load_failed game=%s", game_id) return () diff --git a/src/wolfbot/master/ws/decision_dispatcher.py b/src/wolfbot/master/ws/decision_dispatcher.py index e1fc4f7..b8641de 100644 --- a/src/wolfbot/master/ws/decision_dispatcher.py +++ b/src/wolfbot/master/ws/decision_dispatcher.py @@ -81,6 +81,13 @@ class _PendingDecision: npc_id: str request_id: str game_id: str + # Vote-specific context — populated only on vote dispatches so the + # `on_vote_decision` handler can persist the structured suspicions + # to the timeline without an extra DB lookup. ``None`` for night / + # wolf-chat dispatches that share this dataclass. + vote_day: int | None = None + vote_round: int | None = None + vote_phase: Phase | None = None @dataclass @@ -109,10 +116,15 @@ def __init__( *, config: DecisionDispatcherConfig | None = None, now_ms: Callable[[], int] = lambda: 0, + repo: object | None = None, ) -> None: self.registry = registry self.config = config or DecisionDispatcherConfig() self._now_ms = now_ms + # Optional repo handle for persisting structured suspicions on + # vote-decision arrival. ``None`` is allowed for tests / older + # wirings; the persistence path silently no-ops when absent. + self._repo = repo # request_id → pending future. Cleaned up on resolution / timeout. self._pending: dict[str, _PendingDecision] = {} # request_id → pending wolf-chat future (kept separate from @@ -164,6 +176,8 @@ async def _one(voter: Player) -> tuple[int, int | None]: round_=round_, expires_at_ms=expires_at_ms, public_state_summary=public_state_summary, + day=day, + phase=phase, ) return voter.seat_no, target @@ -289,6 +303,76 @@ async def on_vote_decision(self, msg: VoteDecision) -> None: msg.request_id, msg.npc_id, msg.seat_no, msg.target_seat, msg.reason_summary or "(none)", ) + # Persist structured suspicions to the immutable timeline. Skip + # when repo is unavailable (test wiring) or when vote context is + # missing (would be a coding bug — we always populate it for + # vote dispatches). Auto-promote the chosen target to level=high + # if the LLM didn't explicitly include it (= the vote target IS + # the strongest suspicion, by definition). + if ( + self._repo is None + or pending.vote_day is None + or pending.vote_round is None + or pending.vote_phase is None + ): + return + await self._persist_vote_suspicions( + msg=msg, + game_id=pending.game_id, + day=pending.vote_day, + round_=pending.vote_round, + phase=pending.vote_phase, + ) + + async def _persist_vote_suspicions( + self, + *, + msg: VoteDecision, + game_id: str, + day: int, + round_: int, + phase: Phase, + ) -> None: + """Persist VoteDecision-attached suspicions, auto-promoting the + vote target to level=high if it isn't already in the explicit list. + + Best-effort: any DB error is logged but does not propagate, so a + suspicion-write failure can never stall the vote-resolution path. + """ + from wolfbot.domain.suspicion import Suspicion as _Suspicion + from wolfbot.domain.suspicion import SuspicionLevel + + suspicions = list(msg.suspicions) + # Auto-promote: ensure the vote target appears at level=high. + if msg.target_seat is not None and not any( + s.target_seat == msg.target_seat for s in suspicions + ): + suspicions.append( + _Suspicion( + target_seat=msg.target_seat, + level=SuspicionLevel.HIGH, + reason=msg.reason_summary or "vote_target_auto_promoted", + ) + ) + if not suspicions: + return + try: + await self._repo.insert_vote_suspicions( # type: ignore[union-attr] + game_id=game_id, + day=day, + phase=phase, + vote_round=round_, + suspecter_seat=msg.seat_no, + created_at_ms=self._now_ms(), + suspicions=suspicions, + ) + except Exception: + log.exception( + "vote_suspicions_persist_failed game=%s seat=%d round=%d", + game_id, + msg.seat_no, + round_, + ) async def on_wolf_chat_send(self, msg: WolfChatSend) -> None: """Resolve any pending wolf-chat future for ``msg.request_id``. @@ -322,6 +406,79 @@ async def on_night_action_decision(self, msg: NightActionDecision) -> None: msg.request_id, msg.npc_id, msg.seat_no, msg.action_kind, msg.target_seat, msg.reason_summary or "(none)", ) + # Persist seer-divine suspicions to the timeline. The divine + # target IS implicitly a "seat the seer wants to verify = + # suspicion-curious" — auto-promote target_seat to medium if not + # explicitly listed. Wolf-attack and knight-guard are deliberately + # not persisted here: wolves know their targets via private + # state, knights protect rather than declare suspicion. + if ( + self._repo is None + or pending.vote_phase is None # reused for night phase + or msg.action_kind != "seer_divine" + ): + return + await self._persist_seer_suspicions( + msg=msg, + game_id=pending.game_id, + phase=pending.vote_phase, + ) + + async def _persist_seer_suspicions( + self, + *, + msg: NightActionDecision, + game_id: str, + phase: Phase, + ) -> None: + """Persist seer-divine suspicions, auto-promoting target_seat to + medium if not explicitly listed. + + Day is derived from the live `games.day_number` (loaded fresh + per call) so we don't need to thread phase_id through + `_PendingDecision`. The night-action dispatch happens at the + seer's NIGHT phase, so the read-current-day approximation is + always correct in practice. + """ + from wolfbot.domain.suspicion import Suspicion as _Suspicion + from wolfbot.domain.suspicion import SuspicionLevel + + try: + game = await self._repo.load_game(game_id) # type: ignore[union-attr] + except Exception: + log.exception("seer_suspicions_load_game_failed game=%s", game_id) + return + if game is None: + return + day = game.day_number + suspicions = list(msg.suspicions) + if msg.target_seat is not None and not any( + s.target_seat == msg.target_seat for s in suspicions + ): + suspicions.append( + _Suspicion( + target_seat=msg.target_seat, + level=SuspicionLevel.MEDIUM, + reason=msg.reason_summary or "seer_divine_target_auto", + ) + ) + if not suspicions: + return + try: + await self._repo.insert_vote_suspicions( # type: ignore[union-attr] + game_id=game_id, + day=day, + phase=phase, + vote_round=-1, # sentinel for night-action-derived rows + suspecter_seat=msg.seat_no, + created_at_ms=self._now_ms(), + suspicions=suspicions, + ) + except Exception: + log.exception( + "seer_suspicions_persist_failed game=%s seat=%d", + game_id, msg.seat_no, + ) # ------------------------------------------------- internals @@ -336,6 +493,8 @@ async def _dispatch_one_vote( round_: int, expires_at_ms: int, public_state_summary: str, + day: int, + phase: Phase, ) -> int | None: seat = seats_by_no.get(voter.seat_no) if seat is None or not seat.is_llm: @@ -368,6 +527,9 @@ async def _dispatch_one_vote( send=entry.send, payload_json=req.model_dump_json(), label="vote", + vote_day=day, + vote_round=round_, + vote_phase=phase, ) async def _dispatch_one_wolf_chat( @@ -476,6 +638,10 @@ async def _dispatch_one_night( public_state_summary=public_state_summary, expires_at_ms=expires_at_ms, ) + # Pass NIGHT phase through `vote_phase` (reused; the field name + # predates this overload) so `on_night_action_decision` can + # persist seer-divine suspicions to the timeline. + night_phase = Phase.NIGHT_0 if "::day0::" in phase_id else Phase.NIGHT return await self._send_and_wait( request_id=request_id, seat_no=actor.seat_no, @@ -484,6 +650,7 @@ async def _dispatch_one_night( send=entry.send, payload_json=req.model_dump_json(), label=f"night-{action_kind}", + vote_phase=night_phase, ) async def _run_staggered( @@ -522,6 +689,9 @@ async def _send_and_wait( send: Callable[[str], Awaitable[None]], payload_json: str, label: str, + vote_day: int | None = None, + vote_round: int | None = None, + vote_phase: Phase | None = None, ) -> int | None: loop = asyncio.get_running_loop() future: asyncio.Future[int | None] = loop.create_future() @@ -531,6 +701,9 @@ async def _send_and_wait( npc_id=npc_id, request_id=request_id, game_id=game_id, + vote_day=vote_day, + vote_round=vote_round, + vote_phase=vote_phase, ) try: await send(payload_json) diff --git a/src/wolfbot/npc/decision/decision_service.py b/src/wolfbot/npc/decision/decision_service.py index 613c891..b1807a3 100644 --- a/src/wolfbot/npc/decision/decision_service.py +++ b/src/wolfbot/npc/decision/decision_service.py @@ -26,6 +26,7 @@ from typing import Protocol, runtime_checkable from wolfbot.domain.enums import Role +from wolfbot.domain.suspicion import Suspicion from wolfbot.domain.ws_messages import ( DecideNightActionRequest, DecideVoteRequest, @@ -48,6 +49,13 @@ class DecisionResult: target_seat: int | None reason_summary: str = "" + # Structured suspicion records the decision LLM emitted (vote path + # only — night actions don't carry a public-facing suspicion). The + # vote target is implicitly a high suspicion and SHOULD appear in + # this list, but the LLM may also register secondary trust/low/ + # medium entries for other seats. Persisted to the suspicions + # timeline by the dispatcher. + suspicions: tuple[Suspicion, ...] = () @runtime_checkable @@ -71,7 +79,7 @@ async def decide_json( _VOTE_SCHEMA: dict[str, object] = { "type": "object", "additionalProperties": False, - "required": ["target_seat", "reason"], + "required": ["target_seat", "reason", "suspicions"], "properties": { "target_seat": { "type": "integer", @@ -85,6 +93,51 @@ async def decide_json( "type": "string", "description": "Short internal note explaining the choice.", }, + "suspicions": { + "type": "array", + "description": ( + "Structured suspicion records this vote decision asserts. " + "MUST include one entry naming `target_seat` (your vote " + "target) at level=high with reason matching the public " + "vote rationale — voting someone implies the strongest " + "lynch suspicion. Optionally include trust/low/medium " + "entries for other seats whose evaluation you want to " + "register on the timeline. Master persists every entry " + "to the immutable suspicion timeline so silent reversals " + "between speech and vote are detectable." + ), + "items": { + "type": "object", + "additionalProperties": False, + "required": [ + "target_seat", + "level", + "reason", + "update_from_level", + "update_reason", + ], + "properties": { + "target_seat": { + "type": "integer", + "minimum": 1, + "maximum": 9, + }, + "level": { + "type": "string", + "enum": ["trust", "low", "medium", "high"], + }, + "reason": {"type": "string", "maxLength": 500}, + "update_from_level": { + "type": ["string", "null"], + "enum": ["trust", "low", "medium", "high", None], + }, + "update_reason": { + "type": ["string", "null"], + "maxLength": 500, + }, + }, + }, + }, }, } @@ -92,7 +145,7 @@ async def decide_json( _NIGHT_SCHEMA: dict[str, object] = { "type": "object", "additionalProperties": False, - "required": ["target_seat", "reason"], + "required": ["target_seat", "reason", "suspicions"], "properties": { "target_seat": { "type": "integer", @@ -109,6 +162,52 @@ async def decide_json( "type": "string", "description": "Short internal note explaining the choice.", }, + "suspicions": { + "type": "array", + "description": ( + "Structured suspicion records. **For seer_divine**: " + "MUST include one entry naming `target_seat` (your " + "divine target) at level=medium or high — picking a " + "seat to divine is implicitly suspecting them. Use the " + "reason field to explain why this seat was chosen " + "(e.g. 「議論を急いでいたので確認したい」). Optional " + "trust/low entries for other seats can also register. " + "**For wolf_attack and knight_guard**: empty array is " + "acceptable — wolves attack their target with private " + "knowledge, guards protect rather than declare suspicion." + ), + "items": { + "type": "object", + "additionalProperties": False, + "required": [ + "target_seat", + "level", + "reason", + "update_from_level", + "update_reason", + ], + "properties": { + "target_seat": { + "type": "integer", + "minimum": 1, + "maximum": 9, + }, + "level": { + "type": "string", + "enum": ["trust", "low", "medium", "high"], + }, + "reason": {"type": "string", "maxLength": 500}, + "update_from_level": { + "type": ["string", "null"], + "enum": ["trust", "low", "medium", "high", None], + }, + "update_reason": { + "type": ["string", "null"], + "maxLength": 500, + }, + }, + }, + }, }, } @@ -403,13 +502,42 @@ def parse_decision( return DecisionResult(target_seat=None, reason_summary="not_object") raw_target = data.get("target_seat") if raw_target is None: - return DecisionResult(target_seat=None, reason_summary=str(data.get("reason", ""))) + return DecisionResult( + target_seat=None, + reason_summary=str(data.get("reason", "")), + suspicions=_parse_decision_suspicions(data.get("suspicions")), + ) if not isinstance(raw_target, int) or isinstance(raw_target, bool): return DecisionResult(target_seat=None, reason_summary="non_int_target") if raw_target not in legal_seats: log.info("decision_target_out_of_set target=%s legal=%s", raw_target, legal_seats) return DecisionResult(target_seat=None, reason_summary="illegal_target") - return DecisionResult(target_seat=raw_target, reason_summary=str(data.get("reason", ""))) + return DecisionResult( + target_seat=raw_target, + reason_summary=str(data.get("reason", "")), + suspicions=_parse_decision_suspicions(data.get("suspicions")), + ) + + +def _parse_decision_suspicions(raw: object) -> tuple[Suspicion, ...]: + """Coerce a raw `suspicions` array from a vote decision JSON into + a tuple of validated `Suspicion` models. + + Drops malformed entries silently (mirrors speech-side parsing) so + one bad row doesn't fail the whole decision. + """ + if not isinstance(raw, list): + return () + out: list[Suspicion] = [] + for entry in raw: + if not isinstance(entry, dict): + continue + try: + out.append(Suspicion.model_validate(entry)) + except Exception: + log.debug("decision_suspicion_dropped %r", entry) + continue + return tuple(out) __all__ = [ diff --git a/src/wolfbot/npc/runtime/client.py b/src/wolfbot/npc/runtime/client.py index 8c48090..1169259 100644 --- a/src/wolfbot/npc/runtime/client.py +++ b/src/wolfbot/npc/runtime/client.py @@ -23,6 +23,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass, field +from wolfbot.domain.suspicion import Suspicion from wolfbot.domain.ws_messages import ( DecideNightActionRequest, DecideVoteRequest, @@ -526,7 +527,7 @@ async def _on_decide_vote_request(self, req: DecideVoteRequest) -> None: """ if req.npc_id != self.config.npc_id: return - target, reason = await self._decide_vote_target(req) + target, reason, suspicions = await self._decide_vote_target(req) decision = VoteDecision( ts=self.now_ms(), trace_id=req.trace_id, @@ -535,12 +536,13 @@ async def _on_decide_vote_request(self, req: DecideVoteRequest) -> None: seat_no=req.seat_no, target_seat=target, reason_summary=reason, + suspicions=suspicions, ) await self.send(decision.model_dump_json()) async def _decide_vote_target( self, req: DecideVoteRequest - ) -> tuple[int | None, str]: + ) -> tuple[int | None, str, tuple[Suspicion, ...]]: from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY state = self.game_states.get(req.game_id) @@ -548,15 +550,15 @@ async def _decide_vote_target( log.warning( "npc_vote_no_state game=%s seat=%d", req.game_id, req.seat_no, ) - return None, "no_state" + return None, "no_state", () if self.decision_llm is None: - return None, "no_decision_llm" + return None, "no_decision_llm", () persona = NPC_PERSONAS_BY_KEY.get(state.persona_key) if persona is None: log.warning( "npc_vote_unknown_persona persona=%s", state.persona_key, ) - return None, "unknown_persona" + return None, "unknown_persona", () legal = frozenset(seat for seat, _name in req.candidate_seats) system, user = build_vote_prompt(state=state, persona=persona, request=req) # Mirror `_decide_night_target`: track the LLM call's success @@ -567,6 +569,7 @@ async def _decide_vote_target( # village's ability to lynch a wolf when one round-trip flakes. result_target_seat: int | None = None reason_summary = "llm_error" + result_suspicions: tuple[Suspicion, ...] = () actor = ( f"seat={req.seat_no} persona={state.persona_key} " f"role={state.role} npc_id={self.config.npc_id}" @@ -589,6 +592,7 @@ async def _decide_vote_target( result = parse_decision(raw, legal_seats=legal) result_target_seat = result.target_seat reason_summary = result.reason_summary + result_suspicions = result.suspicions except Exception: log.exception( "npc_vote_llm_failed game=%s seat=%d", req.game_id, req.seat_no, @@ -608,8 +612,8 @@ async def _decide_vote_target( req.game_id, req.seat_no, fallback, reason_summary or "(none)", ) - return fallback, f"abstain_fallback:{reason_summary or ''}" - return result_target_seat, reason_summary + return fallback, f"abstain_fallback:{reason_summary or ''}", result_suspicions + return result_target_seat, reason_summary, result_suspicions async def _on_decide_night_action_request( self, req: DecideNightActionRequest @@ -621,7 +625,7 @@ async def _on_decide_night_action_request( """ if req.npc_id != self.config.npc_id: return - target, reason = await self._decide_night_target(req) + target, reason, suspicions = await self._decide_night_target(req) decision = NightActionDecision( ts=self.now_ms(), trace_id=req.trace_id, @@ -631,6 +635,7 @@ async def _on_decide_night_action_request( action_kind=req.action_kind, target_seat=target, reason_summary=reason, + suspicions=suspicions, ) await self.send(decision.model_dump_json()) @@ -711,7 +716,7 @@ async def _build_wolf_chat_line(self, req: WolfChatRequest) -> str | None: async def _decide_night_target( self, req: DecideNightActionRequest - ) -> tuple[int | None, str]: + ) -> tuple[int | None, str, tuple[Suspicion, ...]]: from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY state = self.game_states.get(req.game_id) @@ -719,12 +724,12 @@ async def _decide_night_target( log.warning( "npc_night_no_state game=%s seat=%d", req.game_id, req.seat_no, ) - return None, "no_state" + return None, "no_state", () if self.decision_llm is None: - return None, "no_decision_llm" + return None, "no_decision_llm", () persona = NPC_PERSONAS_BY_KEY.get(state.persona_key) if persona is None: - return None, "unknown_persona" + return None, "unknown_persona", () legal = frozenset(seat for seat, _name in req.candidate_seats) system, user = build_night_prompt(state=state, persona=persona, request=req) # Track parse + transport errors uniformly so the abstain @@ -738,6 +743,7 @@ async def _decide_night_target( # WAITING_HOST_DECISION. result_target_seat: int | None = None reason_summary = "llm_error" + result_suspicions: tuple[Suspicion, ...] = () actor = ( f"seat={req.seat_no} persona={state.persona_key} " f"role={state.role} npc_id={self.config.npc_id}" @@ -760,6 +766,7 @@ async def _decide_night_target( result = parse_decision(raw, legal_seats=legal) result_target_seat = result.target_seat reason_summary = result.reason_summary + result_suspicions = result.suspicions except Exception: log.exception( "npc_night_llm_failed game=%s seat=%d kind=%s", @@ -783,8 +790,8 @@ async def _decide_night_target( req.game_id, req.seat_no, req.action_kind, fallback, reason_summary or "(none)", ) - return fallback, f"abstain_fallback:{reason_summary or ''}" - return result_target_seat, reason_summary + return fallback, f"abstain_fallback:{reason_summary or ''}", result_suspicions + return result_target_seat, reason_summary, result_suspicions __all__ = ["NpcClient", "NpcClientConfig"] diff --git a/src/wolfbot/persistence/sql/schema/15_speech_suspicions.sql b/src/wolfbot/persistence/sql/schema/15_speech_suspicions.sql deleted file mode 100644 index 99e6d51..0000000 --- a/src/wolfbot/persistence/sql/schema/15_speech_suspicions.sql +++ /dev/null @@ -1,36 +0,0 @@ --- Per-utterance structured suspicion record. Each row is one --- "席X が 席Y に level=high (理由)" datum attached to a SpeechEvent. --- Composite primary key (event_id, seq) lets a single utterance carry --- multiple suspicions while keeping the row immutable once written. --- --- Anti-fabrication: subsequent prompts surface the full immutable --- history so a speaker who silently reverses a prior suspicion --- (e.g. trust → high without setting update_from_level) is detectable. --- Updates set update_from_level + update_reason to make the shift --- explicit. -CREATE TABLE IF NOT EXISTS speech_suspicions ( - event_id TEXT NOT NULL REFERENCES speech_events(event_id) ON DELETE CASCADE, - seq INTEGER NOT NULL, - game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, - day INTEGER NOT NULL, - phase TEXT NOT NULL, - suspecter_seat INTEGER NOT NULL, - target_seat INTEGER NOT NULL, - level TEXT NOT NULL, - reason TEXT NOT NULL, - update_from_level TEXT, - update_reason TEXT, - created_at_ms INTEGER NOT NULL, - PRIMARY KEY (event_id, seq) -); - --- Per-game timeline: walk every suspicion in chronological order so --- prompt builders and viewer rendering both see the same ordering. -CREATE INDEX IF NOT EXISTS idx_susp_game_day - ON speech_suspicions(game_id, day, created_at_ms); - --- Per-(suspecter, target) lookup for fabrication detection: given a --- candidate new suspicion, query the latest prior level so the --- prompt builder can highlight unannounced reversals. -CREATE INDEX IF NOT EXISTS idx_susp_pair - ON speech_suspicions(game_id, suspecter_seat, target_seat, created_at_ms); diff --git a/src/wolfbot/persistence/sql/schema/15_suspicions.sql b/src/wolfbot/persistence/sql/schema/15_suspicions.sql new file mode 100644 index 0000000..3d9f640 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/15_suspicions.sql @@ -0,0 +1,61 @@ +-- Per-utterance / per-vote structured suspicion record. Each row is +-- one "席X が 席Y を level (理由) で見ている" datum, sourced either +-- from a speech (`source='speech'`) or from a vote decision +-- (`source='vote'`). +-- +-- For source='speech', `event_id` references `speech_events.event_id` +-- so the suspicion deletes when the parent SpeechEvent's game is +-- removed. For source='vote', `event_id` is NULL because vote +-- decisions don't produce a SpeechEvent — the (vote_day, vote_round, +-- suspecter_seat, target_seat) tuple identifies the vote-derived row. +-- +-- Surrogate `id` PK lets event_id be nullable while keeping uniqueness +-- guarantees per (event_id, seq) for speech rows enforceable via the +-- composite UNIQUE index below. +-- +-- Anti-fabrication: subsequent prompts surface the full immutable +-- history so a speaker who silently reverses a prior suspicion (e.g. +-- trust → high without setting update_from_level) is detectable. +-- Updates set update_from_level + update_reason to make the shift +-- explicit. +CREATE TABLE IF NOT EXISTS suspicions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL DEFAULT 'speech', + event_id TEXT, + seq INTEGER NOT NULL DEFAULT 0, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + day INTEGER NOT NULL, + phase TEXT NOT NULL, + vote_round INTEGER, + suspecter_seat INTEGER NOT NULL, + target_seat INTEGER NOT NULL, + level TEXT NOT NULL, + reason TEXT NOT NULL, + update_from_level TEXT, + update_reason TEXT, + created_at_ms INTEGER NOT NULL +); + +-- Per-game timeline: walk every suspicion in chronological order so +-- prompt builders and viewer rendering both see the same ordering. +CREATE INDEX IF NOT EXISTS idx_susp_game_day + ON suspicions(game_id, day, created_at_ms); + +-- Per-(suspecter, target) lookup for fabrication detection: given a +-- candidate new suspicion, query the latest prior level so the +-- prompt builder can highlight unannounced reversals. +CREATE INDEX IF NOT EXISTS idx_susp_pair + ON suspicions(game_id, suspecter_seat, target_seat, created_at_ms); + +-- Speech-row uniqueness: one (event_id, seq) pair is unique. Vote rows +-- have event_id=NULL so they bypass this constraint (SQLite treats +-- NULL as distinct in UNIQUE). +CREATE UNIQUE INDEX IF NOT EXISTS idx_susp_event_seq + ON suspicions(event_id, seq) WHERE event_id IS NOT NULL; + +-- Vote-row uniqueness: one suspecter casts at most one vote-derived +-- record per (target, day, round). Re-running the vote dispatch path +-- doesn't duplicate rows. +CREATE UNIQUE INDEX IF NOT EXISTS idx_susp_vote_unique + ON suspicions(game_id, day, vote_round, suspecter_seat, target_seat) + WHERE source = 'vote'; diff --git a/src/wolfbot/persistence/sqlite_repo.py b/src/wolfbot/persistence/sqlite_repo.py index b211860..329f353 100644 --- a/src/wolfbot/persistence/sqlite_repo.py +++ b/src/wolfbot/persistence/sqlite_repo.py @@ -147,6 +147,17 @@ def _row_to_pending(row: aiosqlite.Row) -> PendingDecision: ) +def _susp_str(value: Any) -> str: + """Coerce a SuspicionLevel enum (or already-string) to its wire string. + + The Suspicion model's `.level` is a `SuspicionLevel` enum but DB + insertion needs the underlying string. Defensive against either + enum-or-string inputs so the repo doesn't care which form callers + use. + """ + return value.value if hasattr(value, "value") else str(value) + + def _submissions_json(pending: PendingDecision) -> str: """Serialize submissions to JSON, synthesizing from primary when empty.""" items = pending.effective_submissions() @@ -1114,36 +1125,83 @@ async def insert_speech_suspicions( created_at_ms: int, suspicions: Sequence[Any], ) -> None: - """Persist a batch of `Suspicion` records attached to one SpeechEvent. + """Persist a batch of speech-derived `Suspicion` records. - Uses ``INSERT OR IGNORE`` so a duplicate ``(event_id, seq)`` write - (e.g. on retry of the same speech result) is a silent no-op rather - than a constraint violation that aborts the whole batch. - Empty `suspicions` is a fast-path no-op. + Empty `suspicions` is a fast-path no-op. Re-running with the + same (event_id, seq) is a silent no-op via the partial UNIQUE + index `idx_susp_event_seq`. """ if not suspicions: return rows = [ ( + "speech", event_id, seq, game_id, day, phase.value, + None, # vote_round suspecter_seat, int(s.target_seat), - str(s.level.value if hasattr(s.level, "value") else s.level), + _susp_str(s.level), str(s.reason), - ( - s.update_from_level.value - if s.update_from_level is not None - and hasattr(s.update_from_level, "value") - else ( - str(s.update_from_level) - if s.update_from_level is not None - else None - ) - ), + _susp_str(s.update_from_level) + if s.update_from_level is not None + else None, + s.update_reason, + created_at_ms, + ) + for seq, s in enumerate(suspicions) + ] + async with self._tx() as db: + await db.executemany( + """ + INSERT OR IGNORE INTO suspicions ( + source, event_id, seq, game_id, day, phase, vote_round, + suspecter_seat, target_seat, level, reason, + update_from_level, update_reason, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + rows, + ) + + async def insert_vote_suspicions( + self, + *, + game_id: str, + day: int, + phase: Phase, + vote_round: int, + suspecter_seat: int, + created_at_ms: int, + suspicions: Sequence[Any], + ) -> None: + """Persist vote-derived `Suspicion` records. + + Vote-derived rows have `event_id=NULL` (no SpeechEvent backs the + vote). Uniqueness is enforced per (game_id, day, vote_round, + suspecter_seat, target_seat) via `idx_susp_vote_unique`, so a + retry of the same vote-decision dispatch is a silent no-op. + """ + if not suspicions: + return + rows = [ + ( + "vote", + None, # event_id + seq, + game_id, + day, + phase.value, + int(vote_round), + suspecter_seat, + int(s.target_seat), + _susp_str(s.level), + str(s.reason), + _susp_str(s.update_from_level) + if s.update_from_level is not None + else None, s.update_reason, created_at_ms, ) @@ -1152,19 +1210,19 @@ async def insert_speech_suspicions( async with self._tx() as db: await db.executemany( """ - INSERT OR IGNORE INTO speech_suspicions ( - event_id, seq, game_id, day, phase, + INSERT OR IGNORE INTO suspicions ( + source, event_id, seq, game_id, day, phase, vote_round, suspecter_seat, target_seat, level, reason, update_from_level, update_reason, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, rows, ) - async def load_speech_suspicions_for_game( + async def load_suspicions_for_game( self, game_id: str ) -> list[dict[str, Any]]: - """All suspicions for a game in chronological order. + """All suspicions (speech + vote) for a game in chronological order. Returned as a list of plain dicts so the caller can decide whether to project into a Pydantic model. Used by prompt @@ -1173,29 +1231,32 @@ async def load_speech_suspicions_for_game( """ async with self._db.execute( """ - SELECT event_id, seq, day, phase, suspecter_seat, target_seat, + SELECT source, event_id, seq, day, phase, vote_round, + suspecter_seat, target_seat, level, reason, update_from_level, update_reason, created_at_ms - FROM speech_suspicions + FROM suspicions WHERE game_id=? - ORDER BY created_at_ms ASC, event_id ASC, seq ASC + ORDER BY created_at_ms ASC, id ASC """, (game_id,), ) as cur: rows = await cur.fetchall() return [ { - "event_id": row[0], - "seq": int(row[1]), - "day": int(row[2]), - "phase": str(row[3]), - "suspecter_seat": int(row[4]), - "target_seat": int(row[5]), - "level": str(row[6]), - "reason": str(row[7]), - "update_from_level": row[8], - "update_reason": row[9], - "created_at_ms": int(row[10]), + "source": str(row[0]), + "event_id": row[1], + "seq": int(row[2]), + "day": int(row[3]), + "phase": str(row[4]), + "vote_round": row[5], + "suspecter_seat": int(row[6]), + "target_seat": int(row[7]), + "level": str(row[8]), + "reason": str(row[9]), + "update_from_level": row[10], + "update_reason": row[11], + "created_at_ms": int(row[12]), } for row in rows ] diff --git a/src/wolfbot/prompts/templates/strategy/werewolf.md b/src/wolfbot/prompts/templates/strategy/werewolf.md index 3386e7f..e6ea159 100644 --- a/src/wolfbot/prompts/templates/strategy/werewolf.md +++ b/src/wolfbot/prompts/templates/strategy/werewolf.md @@ -10,6 +10,7 @@ - 占 3 / 霊 2 / 騎 2 の上限を超える追加 CO は出さない (4-1-1 の占い 4 件目は強い狼疑い)。上限到達なら潜伏か別役職を選ぶ。 - 潜伏は安全策ではない。「対抗を出すと即破綻」「灰位置で自然白を確実に取れる」など潜伏が勝ち筋として強い具体根拠があるときだけ選ぶ。 - 襲撃価値の比較軸: (a) 単独 CO の真寄り情報役 = 価値高、(b) CO していない確白寄り進行役 = 価値高、(c) 信頼を集めている灰 = 価値高。**対抗 CO ありの役職 (占 2 件以上の片方など) は襲撃価値が低い** — 残して吊り役として縄を消費させる方が得。 +- **HARD: 強疑(high) を集めている位置は襲撃しない**。`## 公開された疑い履歴` で複数の村人候補から level=high (= 処刑第一候補) を向けられている席は、明日の投票で吊られる確率が高い。そこを噛むのは「翌日の縄を1本無駄にしない代わりに、今夜の襲撃 1 回を捨てる」二重損失。襲撃は **村が疑っていない位置 (level=trust 寄り、または疑い未表明の灰) を優先**して、村陣営の確白根拠を削る方向で選ぶ。「疑い履歴で多くの村人から trust と評価されている席」は襲撃価値が特に高い。逆に「複数席から high と評価されている席」は襲撃禁止 (= 村に任せて吊らせるべき)。 - **GJ 翌夜は同一対象を再噛みする**: 騎士は連続護衛禁止なので昨夜守った相手を今夜は守れない。盤面が大きく変わらず襲撃価値も最高位なら数学的に成立する。切り替えは「対象の襲撃価値が大きく低下」「別位置に超緊急情報役」「相方とチャットで切替合意」のいずれかが揃ったときだけ。 - 噛み方針を「情報役噛み / 白位置噛み / 意見噛み / 騎士探し / SG 残し」のどれかとして整理し、翌日の自分・相方の発言・投票・騙り結果と矛盾しない襲撃を選ぶ。 - 騎士候補度は公開ログから推定 (情報役を守りたがる発言、平和な朝の反応、発言量の抑え方)。実役職を知っている前提では断言しない。 diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index 5524919..bff22ce 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -1207,12 +1207,14 @@ async def _build_public_digest(self, game: Game, seats: Sequence[Seat]) -> str: if state is None: return "" past_votes = await self._load_past_votes(game.id, day) + past_suspicions = await self._load_past_suspicions(game.id) seat_names = {s.seat_no: s.display_name for s in seats} return build_public_digest( state=state, recent_events=events, seat_names=seat_names, past_votes=past_votes, + past_suspicions=past_suspicions, ) except Exception: log.exception( @@ -1222,6 +1224,30 @@ async def _build_public_digest(self, game: Game, seats: Sequence[Seat]) -> str: ) return "" + async def _load_past_suspicions( + self, game_id: str, + ) -> tuple[tuple[int, str, int, int, str, str, str | None, str | None], ...]: + """Load the public suspicion timeline for the rounds-mode / + night-decision digest. Mirrors SpeakArbiter._load_past_suspicions.""" + try: + rows = await self.repo.load_suspicions_for_game(game_id) + except Exception: + log.exception("digest_past_suspicions_load_failed game=%s", game_id) + return () + return tuple( + ( + int(r["day"]), + str(r["phase"]), + int(r["suspecter_seat"]), + int(r["target_seat"]), + str(r["level"]), + str(r["reason"]), + r["update_from_level"] if r["update_from_level"] is not None else None, + r["update_reason"] if r["update_reason"] is not None else None, + ) + for r in rows + ) + async def _load_past_votes( self, game_id: str, From 887959661431eac43be82903b992644944c6dba0 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sun, 3 May 2026 00:58:28 +0900 Subject: [PATCH 130/133] feat(viewer): suspicion timeline + matrix panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前コミットで 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。 --- src/wolfbot/services/game_export.py | 41 +++ src/wolfbot/services/game_export_types.py | 29 ++ viewer/sample-data/export-schema.json | 127 +++++++ viewer/sample-data/game-sample.json | 92 +++++ viewer/src/components/GameView.tsx | 2 + viewer/src/components/SuspicionPanel.tsx | 400 ++++++++++++++++++++++ viewer/src/lib/types.ts | 40 +++ 7 files changed, 731 insertions(+) create mode 100644 viewer/src/components/SuspicionPanel.tsx diff --git a/src/wolfbot/services/game_export.py b/src/wolfbot/services/game_export.py index 9facfa5..3f2cda2 100644 --- a/src/wolfbot/services/game_export.py +++ b/src/wolfbot/services/game_export.py @@ -52,6 +52,7 @@ SeatExport, SpeechEventExport, SpeechSource, + SuspicionEntry, TraceEntry, Victory, VoteExport, @@ -215,6 +216,22 @@ async def _build_payload(game_id: str, db_path: Path, trace_root: Path) -> GameE (game_id,), ) ] + # Public suspicion timeline (speech + vote derived). Walk every + # row in chronological order so the viewer can render both the + # timeline feed and a (suspecter, target) pivot matrix without + # re-querying. + suspicion_rows = [ + dict(r) + for r in await _fetch_all( + db, + "SELECT source, event_id, seq, day, phase, vote_round, " + "suspecter_seat, target_seat, level, reason, " + "update_from_level, update_reason, created_at_ms " + "FROM suspicions WHERE game_id = ? " + "ORDER BY created_at_ms ASC, id ASC", + (game_id,), + ) + ] # Arbiter decision timeline — joined LEFT-OUTER from requests so # in-flight or rejected dispatches still appear (results / # playback may legitimately be missing). @@ -262,6 +279,9 @@ async def _build_payload(game_id: str, db_path: Path, trace_root: Path) -> GameE trace=_load_trace(trace_root, game_id), arbiter_decisions=[_build_arbiter_decision(r) for r in arbiter_rows], claim_history=_build_claim_history(speech_event_rows, seat_lookup), + suspicions=[ + _build_suspicion(r) for r in suspicion_rows + ], ) @@ -341,6 +361,27 @@ def _build_arbiter_decision(row: dict[str, Any]) -> ArbiterDecisionEntry: ) +def _build_suspicion(row: dict[str, Any]) -> SuspicionEntry: + """Map a `suspicions` DB row to the export shape.""" + return SuspicionEntry( + source=cast(Any, row["source"]), + event_id=row["event_id"], + seq=int(row["seq"]), + day=int(row["day"]), + phase=str(row["phase"]), + vote_round=row["vote_round"], + suspecter_seat=int(row["suspecter_seat"]), + target_seat=int(row["target_seat"]), + level=cast(Any, row["level"]), + reason=str(row["reason"]), + update_from_level=cast( + Any, row["update_from_level"] + ) if row["update_from_level"] is not None else None, + update_reason=row["update_reason"], + created_at_ms=int(row["created_at_ms"]), + ) + + def _build_seat(row: dict[str, Any]) -> SeatExport: role = cast(RoleKey, row["role"] or "VILLAGER") death_cause: DeathCause | None = ( diff --git a/src/wolfbot/services/game_export_types.py b/src/wolfbot/services/game_export_types.py index a7b8857..5008168 100644 --- a/src/wolfbot/services/game_export_types.py +++ b/src/wolfbot/services/game_export_types.py @@ -304,6 +304,31 @@ class ArbiterDecisionEntry(BaseModel): tts_duration_ms: int | None = None +class SuspicionEntry(BaseModel): + """One row of the public suspicion timeline. + + Mirrors the ``suspicions`` DB row 1:1 so the viewer can both render + the timeline (chronological feed) and pivot it into a (suspecter, + target) → level matrix. + """ + + model_config = _StrictConfig + + source: Literal["speech", "vote"] + event_id: str | None = None + seq: int + day: int + phase: str + vote_round: int | None = None + suspecter_seat: int + target_seat: int + level: Literal["trust", "low", "medium", "high"] + reason: str + update_from_level: Literal["trust", "low", "medium", "high"] | None = None + update_reason: str | None = None + created_at_ms: int + + class GameExport(BaseModel): """Top-level shape of one ``viewer/games/{id}.json`` file.""" @@ -319,6 +344,10 @@ class GameExport(BaseModel): # the exporter so the viewer renders a "claim ledger" panel without # walking every phase's speech_events. claim_history: list[ClaimHistoryEntry] = [] + # Public suspicion timeline (speech + vote derived). Chronological + # order. Empty for older exports / games before the structured + # suspicion feature shipped. + suspicions: list[SuspicionEntry] = [] __all__ = [ diff --git a/viewer/sample-data/export-schema.json b/viewer/sample-data/export-schema.json index a54eabd..41f7b02 100644 --- a/viewer/sample-data/export-schema.json +++ b/viewer/sample-data/export-schema.json @@ -780,6 +780,125 @@ "title": "SpeechEventExport", "type": "object" }, + "SuspicionEntry": { + "additionalProperties": false, + "description": "One row of the public suspicion timeline.\n\nMirrors the ``suspicions`` DB row 1:1 so the viewer can both render\nthe timeline (chronological feed) and pivot it into a (suspecter,\ntarget) → level matrix.", + "properties": { + "source": { + "enum": [ + "speech", + "vote" + ], + "title": "Source", + "type": "string" + }, + "event_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Event Id" + }, + "seq": { + "title": "Seq", + "type": "integer" + }, + "day": { + "title": "Day", + "type": "integer" + }, + "phase": { + "title": "Phase", + "type": "string" + }, + "vote_round": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Vote Round" + }, + "suspecter_seat": { + "title": "Suspecter Seat", + "type": "integer" + }, + "target_seat": { + "title": "Target Seat", + "type": "integer" + }, + "level": { + "enum": [ + "trust", + "low", + "medium", + "high" + ], + "title": "Level", + "type": "string" + }, + "reason": { + "title": "Reason", + "type": "string" + }, + "update_from_level": { + "anyOf": [ + { + "enum": [ + "trust", + "low", + "medium", + "high" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Update From Level" + }, + "update_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Update Reason" + }, + "created_at_ms": { + "title": "Created At Ms", + "type": "integer" + } + }, + "required": [ + "source", + "seq", + "day", + "phase", + "suspecter_seat", + "target_seat", + "level", + "reason", + "created_at_ms" + ], + "title": "SuspicionEntry", + "type": "object" + }, "TokenUsage": { "additionalProperties": false, "properties": { @@ -1096,6 +1215,14 @@ }, "title": "Claim History", "type": "array" + }, + "suspicions": { + "default": [], + "items": { + "$ref": "#/$defs/SuspicionEntry" + }, + "title": "Suspicions", + "type": "array" } }, "required": [ diff --git a/viewer/sample-data/game-sample.json b/viewer/sample-data/game-sample.json index 1393da4..582fb21 100644 --- a/viewer/sample-data/game-sample.json +++ b/viewer/sample-data/game-sample.json @@ -613,6 +613,98 @@ "night_actions": [] } ], + "suspicions": [ + { + "source": "speech", + "event_id": "ev_d1_setsu_01", + "seq": 0, + "day": 1, + "phase": "DAY_DISCUSSION", + "vote_round": null, + "suspecter_seat": 2, + "target_seat": 5, + "level": "low", + "reason": "議論を急ぎすぎている印象。役職持ちが揃う前に進めようとする動きが少し不自然。", + "update_from_level": null, + "update_reason": null, + "created_at_ms": 1717000050000 + }, + { + "source": "speech", + "event_id": "ev_d1_jonas_01", + "seq": 0, + "day": 1, + "phase": "DAY_DISCUSSION", + "vote_round": null, + "suspecter_seat": 9, + "target_seat": 5, + "level": "medium", + "reason": "議論を煽る速度が早く、CO 待ちを軽視する発言が続いている。", + "update_from_level": null, + "update_reason": null, + "created_at_ms": 1717000060000 + }, + { + "source": "speech", + "event_id": "ev_d1_setsu_02", + "seq": 0, + "day": 1, + "phase": "DAY_DISCUSSION", + "vote_round": null, + "suspecter_seat": 2, + "target_seat": 5, + "level": "medium", + "reason": "ジョナスの指摘とも合致して、議論妨害の動機が見えてきた。", + "update_from_level": "low", + "update_reason": "ジョナスの指摘で違和感が裏付けられたため medium に格上げ。", + "created_at_ms": 1717000080000 + }, + { + "source": "speech", + "event_id": "ev_d1_jonas_02", + "seq": 0, + "day": 1, + "phase": "DAY_DISCUSSION", + "vote_round": null, + "suspecter_seat": 9, + "target_seat": 2, + "level": "trust", + "reason": "セツが冷静に状況整理しており、村陣営寄りに見える。", + "update_from_level": null, + "update_reason": null, + "created_at_ms": 1717000090000 + }, + { + "source": "vote", + "event_id": null, + "seq": 0, + "day": 1, + "phase": "DAY_VOTE", + "vote_round": 0, + "suspecter_seat": 2, + "target_seat": 5, + "level": "high", + "reason": "発話で示した懸念を投票で固める。ラキオを処刑候補に。", + "update_from_level": "medium", + "update_reason": "投票時点で確信が固まったため high に。", + "created_at_ms": 1717000200000 + }, + { + "source": "vote", + "event_id": null, + "seq": 0, + "day": 1, + "phase": "DAY_VOTE", + "vote_round": 0, + "suspecter_seat": 9, + "target_seat": 5, + "level": "high", + "reason": "セツと同じ理由でラキオへ票。", + "update_from_level": "medium", + "update_reason": "投票時点でラインが見えたため。", + "created_at_ms": 1717000210000 + } + ], "trace": [ { "ts": "2026-04-28T08:00:15+00:00", diff --git a/viewer/src/components/GameView.tsx b/viewer/src/components/GameView.tsx index c4ce4ab..c01b657 100644 --- a/viewer/src/components/GameView.tsx +++ b/viewer/src/components/GameView.tsx @@ -9,6 +9,7 @@ import Container from "@mui/material/Container"; import Stack from "@mui/material/Stack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ClaimHistoryPanel from "@/components/ClaimHistoryPanel"; +import SuspicionPanel from "@/components/SuspicionPanel"; import GameHeader from "@/components/GameHeader"; import PhaseSection from "@/components/PhaseSection"; import SeatGrid from "@/components/SeatGrid"; @@ -128,6 +129,7 @@ export default function GameView({ + diff --git a/viewer/src/components/SuspicionPanel.tsx b/viewer/src/components/SuspicionPanel.tsx new file mode 100644 index 0000000..7116a2e --- /dev/null +++ b/viewer/src/components/SuspicionPanel.tsx @@ -0,0 +1,400 @@ +"use client"; + +/** + * SuspicionPanel — public suspicion timeline + (suspecter, target) matrix. + * + * Why this panel exists + * --------------------- + * Discussion-time speeches and vote decisions both emit structured + * `Suspicion` records with (target_seat, level, reason). The bot + * persists every entry to the immutable `suspicions` table so: + * + * - LLMs that silently reverse a prior level (e.g. trust → high + * without setting `update_from_level`) are detectable evidence + * of fabrication. + * - The viewer can render *who suspected whom and how strongly* as + * both a chronological timeline (left) and a pivot matrix (right), + * letting a post-game reviewer see emerging consensus and outliers + * at a glance. + * + * Pure presentation: reads `data.suspicions` (pre-folded by the + * exporter) plus `data.seats` for name resolution. Older exports + * (pre-2026-05-03) lack `suspicions` and the panel collapses to a + * compact "no suspicion records" state. + */ + +import * as React from "react"; +import Box from "@mui/material/Box"; +import Chip from "@mui/material/Chip"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Tab from "@mui/material/Tab"; +import Tabs from "@mui/material/Tabs"; +import Typography from "@mui/material/Typography"; +import type { GameSample, Seat, SuspicionEntry, SuspicionLevel } from "@/lib/types"; + +const LEVEL_LABEL: Record = { + trust: "信頼", + low: "弱疑", + medium: "疑", + high: "強疑", +}; + +// Color the level with a small ramp so the matrix reads as a heatmap. +// trust=cool, high=warm. Background uses MUI palette tokens for +// dark/light theme parity. +const LEVEL_BG: Record = { + trust: "rgba(46, 204, 113, 0.18)", + low: "rgba(241, 196, 15, 0.16)", + medium: "rgba(230, 126, 34, 0.22)", + high: "rgba(231, 76, 60, 0.28)", +}; + +const LEVEL_FG: Record = { + trust: "#27ae60", + low: "#d4ac0d", + medium: "#d35400", + high: "#c0392b", +}; + +const SOURCE_LABEL: Record = { + speech: "発言", + vote: "投票", +}; + +function formatPhase(phase: string, voteRound: number | null): string { + if (phase === "DAY_DISCUSSION") return "議論"; + if (phase === "DAY_VOTE") return voteRound === 1 ? "決選投票" : "投票"; + if (phase === "DAY_RUNOFF") return "決選投票"; + if (phase === "DAY_RUNOFF_SPEECH") return "決選演説"; + if (phase === "NIGHT" || phase === "NIGHT_0") { + return voteRound === -1 ? "夜・占い" : "夜"; + } + return phase; +} + +function levelChip(level: SuspicionLevel) { + return ( + + ); +} + +function seatLookup(seats: readonly Seat[]): Record { + const out: Record = {}; + for (const s of seats) out[s.seat_no] = s.display_name; + return out; +} + +interface MatrixCell { + level: SuspicionLevel; + reason: string; + day: number; + source: "speech" | "vote"; +} + +/** + * Build the (suspecter, target) → latest-cell pivot. We pick the LAST + * row chronologically so the matrix reflects each speaker's currently- + * declared opinion. Earlier rows are still visible in the timeline tab. + */ +function buildMatrix( + suspicions: readonly SuspicionEntry[], +): Map> { + const matrix = new Map>(); + for (const s of suspicions) { + let row = matrix.get(s.suspecter_seat); + if (!row) { + row = new Map(); + matrix.set(s.suspecter_seat, row); + } + row.set(s.target_seat, { + level: s.level, + reason: s.reason, + day: s.day, + source: s.source, + }); + } + return matrix; +} + +function TimelineTab({ + suspicions, + names, +}: { + suspicions: readonly SuspicionEntry[]; + names: Record; +}) { + return ( + + {suspicions.map((s, idx) => { + const sname = names[s.suspecter_seat] ?? `席${s.suspecter_seat}`; + const tname = names[s.target_seat] ?? `席${s.target_seat}`; + const phase = formatPhase(s.phase, s.vote_round); + const updated = s.update_from_level !== null; + return ( + + + + + + {sname} + + + → + + + {tname} + + {levelChip(s.level)} + {updated && ( + + )} + + + {s.reason} + + {updated && s.update_reason && ( + + 更新理由: {s.update_reason} + + )} + + ); + })} + + ); +} + +function MatrixTab({ + suspicions, + seats, + names, +}: { + suspicions: readonly SuspicionEntry[]; + seats: readonly Seat[]; + names: Record; +}) { + const matrix = buildMatrix(suspicions); + // Stable seat order for both axes — by seat_no so deciphering with the + // SeatGrid above is easy. + const sortedSeats = [...seats].sort((a, b) => a.seat_no - b.seat_no); + return ( + + + + + + {sortedSeats.map((s) => ( + + ))} + + + + {sortedSeats.map((suspecter) => ( + + + {sortedSeats.map((target) => { + if (suspecter.seat_no === target.seat_no) { + return ( + + ); + } + const cell = matrix + .get(suspecter.seat_no) + ?.get(target.seat_no); + if (!cell) { + return ( + + ); + } + return ( + + ); + })} + + ))} + +
+ 疑う側 \ 対象 + + {names[s.seat_no] ?? `席${s.seat_no}`} +
+ {names[suspecter.seat_no] ?? `席${suspecter.seat_no}`} + + — + + · + + + {LEVEL_LABEL[cell.level]} + +
+ + 各セルは「疑う側 (行) → 対象 (列)」の最新評価。セルにマウスオーバーで day と理由を表示。`·` は未評価、`—` は自席。 + +
+ ); +} + +export default function SuspicionPanel({ data }: { data: GameSample }) { + const suspicions = data.suspicions ?? []; + const [tab, setTab] = React.useState<"timeline" | "matrix">("matrix"); + + if (suspicions.length === 0) { + return ( + + 疑い履歴 + + このゲームには構造化された疑い記録がありません (古いゲームか、まだ発話/投票が無い)。 + + + ); + } + const names = seatLookup(data.seats); + + return ( + + + 疑い履歴 ({suspicions.length} 件) + setTab(v)} + sx={{ minHeight: 32 }} + > + + + + + {tab === "matrix" ? ( + + ) : ( + + )} + + ); +} diff --git a/viewer/src/lib/types.ts b/viewer/src/lib/types.ts index 083e6d0..8be4d14 100644 --- a/viewer/src/lib/types.ts +++ b/viewer/src/lib/types.ts @@ -95,6 +95,40 @@ export interface ClaimHistoryEntry { medium_claims: ClaimedMediumHistoryEntry[]; } +/** 4-step suspicion scale. + * + * - `trust`: 村寄り信頼 (white-leaning) + * - `low`: 弱い違和感 + * - `medium`: 明確に怪しい + * - `high`: 処刑第一候補 + */ +export type SuspicionLevel = "trust" | "low" | "medium" | "high"; + +export interface SuspicionEntry { + /** Origin of the row: discussion speech vs vote / night decision. */ + source: "speech" | "vote"; + /** Speech-derived rows reference their parent SpeechEvent. Vote-derived + * rows are null (no SpeechEvent backs the vote). */ + event_id: string | null; + seq: number; + day: number; + /** Phase value at write time (DAY_DISCUSSION / DAY_VOTE / NIGHT etc). */ + phase: string; + /** Vote round (0=normal, 1=runoff, -1=night seer-divine). Null for speech. */ + vote_round: number | null; + suspecter_seat: number; + target_seat: number; + level: SuspicionLevel; + reason: string; + /** When non-null, this entry updates a prior suspicion of the same + * (suspecter, target). The viewer can render an arrow showing the + * shift; an LLM that silently reverses without setting this field is + * the anti-fabrication red flag the timeline is designed to surface. */ + update_from_level: SuspicionLevel | null; + update_reason: string | null; + created_at_ms: number; +} + export interface Vote { day: number; round: number; @@ -217,4 +251,10 @@ export interface GameSample { * 2026-05-01) lack the field; the viewer treats absence as `[]`. */ claim_history?: ClaimHistoryEntry[]; + /** + * Public suspicion timeline (speech + vote derived). Chronological + * order. Older exports (pre-2026-05-03) lack the field; the viewer + * treats absence as `[]`. + */ + suspicions?: SuspicionEntry[]; } From bea72af47481da10435d6a05c00e4979dd65aac3 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sun, 3 May 2026 01:16:48 +0900 Subject: [PATCH 131/133] =?UTF-8?q?feat(prompts):=20auto=20medium-callout?= =?UTF-8?q?=20day=E2=89=A52=20+=20wolf=20decision=20block=20+=20suspicion?= =?UTF-8?q?=20aggregate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ユーザー要望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。 --- src/wolfbot/master/arbiter/logic_service.py | 94 ++++++++++++++++++ src/wolfbot/master/arbiter/public_digest.py | 57 +++++++++++ src/wolfbot/master/arbiter/speak_arbiter.py | 62 ++++++++++++ .../prompts/templates/strategy/werewolf.md | 6 +- tests/test_reactive_voice_master.py | 99 +++++++++++++++++++ 5 files changed, 317 insertions(+), 1 deletion(-) diff --git a/src/wolfbot/master/arbiter/logic_service.py b/src/wolfbot/master/arbiter/logic_service.py index 3c11436..ae361e3 100644 --- a/src/wolfbot/master/arbiter/logic_service.py +++ b/src/wolfbot/master/arbiter/logic_service.py @@ -24,6 +24,7 @@ from collections.abc import Iterable from wolfbot.domain.discussion import PublicDiscussionState +from wolfbot.domain.enums import Role from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, RecentSpeech from wolfbot.master.claim.claim_history import ( ClaimHistory, @@ -53,6 +54,7 @@ def build_logic_packet( seat_names: dict[int, str] | None = None, claim_history: ClaimHistory | None = None, recipient_seat_no: int | None = None, + recipient_role: Role | None = None, ) -> LogicPacket: """Construct a `LogicPacket` for `recipient_npc_id`. @@ -246,6 +248,98 @@ def _alive_tag(seat: int) -> str: f"その霊媒結果を `claimed_medium_result` に入れて発表する義務があります。" f"処刑がなかった日は `is_wolf=null` で「結果なし」を明言してください。" ) + + # Wolf-side counter-CO / fake-CO decision prompt. Fires when: + # (a) recipient is WEREWOLF or MADMAN + # (b) recipient hasn't CO'd as any info role yet + # (c) an info-role callout is active (pending_role_callouts / + # pending_co_response carries seer / medium / knight) + # Surfaces the decision explicitly so the LLM treats this turn as a + # binary "fake CO or skip" rather than drifting into generic chat. + # The choice is the LLM's; this block frames the prompt. + if recipient_role in (Role.WEREWOLF, Role.MADMAN) and recipient_seat_no is not None: + recipient_co_keys: set[str] = set() + if claim_history is not None and recipient_seat_no in claim_history.by_seat: + history = claim_history.by_seat[recipient_seat_no] + if history.seer_claims: + recipient_co_keys.add("seer") + if history.medium_claims: + recipient_co_keys.add("medium") + # Also check live state.co_claims (covers the in-phase case + # where claim_history is built up to the current packet but + # the brand-new CO this turn may not be folded yet). + for c in state.co_claims: + if c.seat == recipient_seat_no: + recipient_co_keys.add(c.role_claim) + already_co_info = bool( + recipient_co_keys & {"seer", "medium", "knight"} + ) + if not already_co_info: + active_callouts: set[str] = set() + for k in ("seer", "medium", "knight"): + if ( + k in state.pending_role_callouts + or k in state.pending_co_response + ): + active_callouts.add(k) + if "info_request" in state.pending_role_callouts: + active_callouts |= {"seer", "medium", "knight"} + for k in tuple(active_callouts): + if any(c.role_claim == k for c in state.co_claims): + # Real or fake CO landed for this role at the same + # rough time as the prompt is being built — only + # surface a counter-CO decision when the role is + # genuinely unanswered. Drop already-answered + # role keys from the active set. + pass + if active_callouts: + role_label = { + "seer": "占い師", + "medium": "霊媒師", + "knight": "騎士", + } + roles_jp = "・".join(role_label[k] for k in sorted(active_callouts)) + # Per-role tactical hints — short reminders pulled from + # the existing strategy templates so the wolf-side LLM + # has the day-2+ medium / seer fake-CO playbook in + # mind without re-fetching the whole role-strategy + # block. + hints: list[str] = [] + if "medium" in active_callouts and state.day >= 2: + hints.append( + "霊媒騙りを選ぶなら、`co_declaration=\"medium\"` を立てた上で、" + "前日処刑者への霊媒結果 (`claimed_medium_result`) を必ず添える " + "(処刑が無い日は `is_wolf=null` で結果なしを明言)。" + ) + if "seer" in active_callouts: + hints.append( + "占い騙りを選ぶなら、`co_declaration=\"seer\"` を立てた上で、" + "占い結果 (`claimed_seer_result`) を必ず添える。" + "day 1 朝なら NIGHT_0 ランダム白で必ず白判定、" + "day 2 以降なら前夜の判定 (黒も可) を出す。" + "対抗 CO がすでに出ている場合は対抗 ledger と矛盾しない対象を選ぶ。" + ) + if "knight" in active_callouts: + hints.append( + "騎士騙りを選ぶなら、`co_declaration=\"knight\"` を立て、" + "合法な護衛履歴 (自己護衛・連続護衛・死亡対象護衛・存在しない護衛成功は禁止) のみ提示する。" + ) + hint_block = ("\n - " + "\n - ".join(hints)) if hints else "" + summary += ( + f"\n\n## 【あなた宛 / 緊急判断】対抗 CO のチャンス\n" + f"今ターン {roles_jp} の CO が公開ログに登場しているか、誰かが該当役の名乗りを促しています。" + f"あなたは人狼陣営側 ({recipient_role.value}) で、まだ占い/霊媒/騎士のいずれにも CO していません。" + f"**この発話で {roles_jp} のいずれかとして対抗/騙り CO を出すか、それとも潜伏 (skip / 一般発話) を選ぶか** を判断してください。" + f"\n\n判断軸:" + f"\n - 真役職者が単独で残ると村側が真寄りに固定する → 騙り CO で割って真偽比較を村に持ち込む価値" + f"\n - 縄数 / 残狼数 / 既出 CO 件数を見て、騙りを増やすことで処刑筋がどう変わるか" + f"\n - 騙ると「結果整合性 (発表内容の不変性)」を抱える義務が生じ、後の破綻リスクが上がる" + f"\n - 潜伏が強い盤面 (例: 占い CO がすでに 3 件出ているなど) では skip も選択肢" + f"{hint_block}" + f"\n\n決定したら、CO する場合は `co_declaration` を該当役にセットし、`public_message` で自然な日本語で名乗る。" + f"潜伏する場合は `co_declaration=null` のままで、議論への一般的な反応・疑い表明・話題提示などを行う。" + f"\"何もせず黙る\" のは情報漏洩こそ無いが、村側が CO 群を絞る時間を与える消極策である点に注意。" + ) # Prefer the multi-addressee set; fall back to the legacy singular # field for state objects that haven't been migrated (e.g. test # fixtures that only set `last_addressed_seat`). diff --git a/src/wolfbot/master/arbiter/public_digest.py b/src/wolfbot/master/arbiter/public_digest.py index c9eea6e..8a5a294 100644 --- a/src/wolfbot/master/arbiter/public_digest.py +++ b/src/wolfbot/master/arbiter/public_digest.py @@ -165,6 +165,63 @@ def _name(seat: int) -> str: row += f" [{from_text}→{level_text} 更新理由: {update_reason or '(未記入)'}]" lines.append(row) + # Aggregate score per alive target — sum across suspecters using + # the LATEST level each suspecter has declared. Dead suspecters + # AND dead targets are dropped (a dead seat can't be attacked + # nor can their past suspicions be updated). Score weighting: + # trust=-1, low=1, medium=2, high=4. Wolves consult this to pick + # attack targets — the most-suspected seats will be lynched + # anyway, so attacking them is wasted; hit low-score (= village- + # trusted) seats instead. + alive_set = state.alive_seat_nos + latest: dict[tuple[int, int], str] = {} # (suspecter, target) → latest level + for ( + _day, + _phase, + suspecter, + target, + level, + _reason, + _from_level, + _update_reason, + ) in past_suspicions: + if suspecter not in alive_set or target not in alive_set: + continue + latest[(suspecter, target)] = level + score_weight = {"trust": -1, "low": 1, "medium": 2, "high": 4} + per_target: dict[int, dict[str, int]] = {} + for (_suspecter, target), level in latest.items(): + slot = per_target.setdefault( + target, {"trust": 0, "low": 0, "medium": 0, "high": 0, "score": 0}, + ) + slot[level] = slot.get(level, 0) + 1 + slot["score"] += score_weight.get(level, 0) + if per_target: + lines.append("") + lines.append("## 疑いスコア集計 (生存者のみ、score 高 → 低)") + lines.append( + " weights: trust=-1 / low=+1 / medium=+2 / high=+4 (各 suspecter の最新評価で集計)" + ) + sorted_targets = sorted( + per_target.items(), + key=lambda kv: (-kv[1]["score"], kv[0]), + ) + for target_seat, breakdown in sorted_targets: + tname = _name(target_seat) + parts: list[str] = [] + if breakdown["high"]: + parts.append(f"強疑{breakdown['high']}") + if breakdown["medium"]: + parts.append(f"疑{breakdown['medium']}") + if breakdown["low"]: + parts.append(f"弱疑{breakdown['low']}") + if breakdown["trust"]: + parts.append(f"信頼{breakdown['trust']}") + breakdown_text = " / ".join(parts) if parts else "(評価なし)" + lines.append( + f" - {tname}: {breakdown_text} → 合計 {breakdown['score']} 点" + ) + return "\n".join(lines) diff --git a/src/wolfbot/master/arbiter/speak_arbiter.py b/src/wolfbot/master/arbiter/speak_arbiter.py index b0c073f..e7635c5 100644 --- a/src/wolfbot/master/arbiter/speak_arbiter.py +++ b/src/wolfbot/master/arbiter/speak_arbiter.py @@ -402,6 +402,12 @@ async def dispatch_request( # Build LogicPacket (sent first so the NPC has context for the # subsequent speak_request). + recipient_role: Role | None = None + if role_name is not None: + try: + recipient_role = Role(role_name) + except ValueError: + recipient_role = None packet = build_logic_packet( state=state, recipient_npc_id=candidate_npc_id, @@ -413,6 +419,7 @@ async def dispatch_request( seat_names=seat_names_lookup, claim_history=claim_history, recipient_seat_no=seat_no, + recipient_role=recipient_role, ) try: await entry.send(packet.model_dump_json()) @@ -1401,6 +1408,24 @@ async def try_dispatch_next(self, game_id: str) -> None: if state is None: return + # Auto-inject the medium-callout on day≥2 morning when no medium + # CO has landed yet. Without this, day 2+ discussions silently + # skip the medium topic — observed live in game 6c97c1cc22ef + # where day 2/3 went without a single mention of the medium + # role's expected result. The fold logic in `_compute_callout_pool` + # then naturally builds a priority pool from the real medium + # plus every wolf-side seat that hasn't CO'd as any info role, + # so they get first dispatch and choose between fake CO or skip. + auto_callouts = await self._compute_auto_callouts(state, game.day_number) + if auto_callouts: + state = state.model_copy( + update={ + "pending_role_callouts": frozenset( + state.pending_role_callouts | auto_callouts + ), + } + ) + # Pick the next NPC. Priority order (applied as a 7-key sort): # 1. callout pool member — seer/medium/knight callout in flight # and this seat is in the priority pool (real role-holder + @@ -1974,6 +1999,43 @@ def _find_npc_for_seat(self, game_id: str, seat_no: int) -> Any | None: return entry return None + async def _compute_auto_callouts( + self, state: PublicDiscussionState, day: int + ) -> frozenset[str]: + """Synthesize callout role keys to surface forgotten role topics. + + Currently fires for **medium** on day ≥ 2 when no medium CO has + landed yet. Day 2+ morning is the natural time to disclose the + previous-day execution's medium result, but if no real medium + nor wolf-side fake-CO mentions the role, the discussion drifts + and the topic disappears entirely (observed live in game + 6c97c1cc22ef where the medium was silent for two consecutive + days). Returning ``frozenset({"medium"})`` here merges the role + key into the public_state_summary's + ``pending_role_callouts=[medium]`` indicator AND triggers the + existing ``_compute_callout_pool`` priority bucket that rounds + up the real medium + every wolf-side seat without an info CO. + Each candidate gets one dispatch chance via + ``_callout_pool_asked``; they choose between fake-CO (`medium`) + or skip, then normal rotation resumes. + + Auto-injection stops naturally as soon as a medium CO arrives: + the fold rule in `apply_speech_event` clears the role key from + ``pending_role_callouts`` once the matching CO appears in + ``state.co_claims``, so the next dispatch sees an empty pool. + """ + if day < 2: + return frozenset() + # Only inject for the morning's first speaker round. We can't + # access the round number directly from state, so use the + # phase_id sequence — the morning is sequence 1. + if "::DAY_DISCUSSION::" not in state.phase_id: + return frozenset() + has_medium_co = any(c.role_claim == "medium" for c in state.co_claims) + if has_medium_co: + return frozenset() + return frozenset({"medium"}) + async def _compute_callout_pool( self, game_id: str, state: PublicDiscussionState ) -> frozenset[int]: diff --git a/src/wolfbot/prompts/templates/strategy/werewolf.md b/src/wolfbot/prompts/templates/strategy/werewolf.md index e6ea159..48fc320 100644 --- a/src/wolfbot/prompts/templates/strategy/werewolf.md +++ b/src/wolfbot/prompts/templates/strategy/werewolf.md @@ -10,7 +10,11 @@ - 占 3 / 霊 2 / 騎 2 の上限を超える追加 CO は出さない (4-1-1 の占い 4 件目は強い狼疑い)。上限到達なら潜伏か別役職を選ぶ。 - 潜伏は安全策ではない。「対抗を出すと即破綻」「灰位置で自然白を確実に取れる」など潜伏が勝ち筋として強い具体根拠があるときだけ選ぶ。 - 襲撃価値の比較軸: (a) 単独 CO の真寄り情報役 = 価値高、(b) CO していない確白寄り進行役 = 価値高、(c) 信頼を集めている灰 = 価値高。**対抗 CO ありの役職 (占 2 件以上の片方など) は襲撃価値が低い** — 残して吊り役として縄を消費させる方が得。 -- **HARD: 強疑(high) を集めている位置は襲撃しない**。`## 公開された疑い履歴` で複数の村人候補から level=high (= 処刑第一候補) を向けられている席は、明日の投票で吊られる確率が高い。そこを噛むのは「翌日の縄を1本無駄にしない代わりに、今夜の襲撃 1 回を捨てる」二重損失。襲撃は **村が疑っていない位置 (level=trust 寄り、または疑い未表明の灰) を優先**して、村陣営の確白根拠を削る方向で選ぶ。「疑い履歴で多くの村人から trust と評価されている席」は襲撃価値が特に高い。逆に「複数席から high と評価されている席」は襲撃禁止 (= 村に任せて吊らせるべき)。 +- **HARD: 疑いスコアの高い位置は襲撃しない**。プロンプトに `## 疑いスコア集計 (生存者のみ、score 高 → 低)` ブロックがあり、生存者全員からの最新評価を `trust=-1 / low=+1 / medium=+2 / high=+4` で重み付けして合計したスコアが score 降順で並んでいる。 + - **score が一番高い (= 村が最も疑っている) 席を襲撃するのは禁止**。明日の投票で吊られる確率が高いので、襲撃する=今夜の襲撃 1 回と翌日の縄を 1 本両方無駄にする二重損失。 + - **襲撃は score の低い (= trust 寄り、または疑い未表明) 席から選ぶ**。村陣営の確白根拠を削る方向で、特に level=trust が複数積まれている席は襲撃価値が高い (村側がほぼ確白扱いしている = 真の村人寄り情報役/灰の可能性が高い)。 + - スコア集計は生存者からの評価のみを集計しており、死亡席の suspicion は含まれない。 + - 例外: 真寄り単独情報役 (占い 1 件単独 / 霊媒 1 件単独) で、まだ score が高くなっていない位置は依然として高価値襲撃対象。「score の低い情報役」を最優先で選ぶ。 - **GJ 翌夜は同一対象を再噛みする**: 騎士は連続護衛禁止なので昨夜守った相手を今夜は守れない。盤面が大きく変わらず襲撃価値も最高位なら数学的に成立する。切り替えは「対象の襲撃価値が大きく低下」「別位置に超緊急情報役」「相方とチャットで切替合意」のいずれかが揃ったときだけ。 - 噛み方針を「情報役噛み / 白位置噛み / 意見噛み / 騎士探し / SG 残し」のどれかとして整理し、翌日の自分・相方の発言・投票・騙り結果と矛盾しない襲撃を選ぶ。 - 騎士候補度は公開ログから推定 (情報役を守りたがる発言、平和な朝の反応、発言量の抑え方)。実役職を知っている前提では断言しない。 diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py index 380ddcc..b9d54b5 100644 --- a/tests/test_reactive_voice_master.py +++ b/tests/test_reactive_voice_master.py @@ -4176,6 +4176,105 @@ def test_logic_packet_builder_includes_co_claims_in_summary() -> None: # falls back to 席N — covered by the next test. +def test_logic_packet_wolf_decision_block_fires_for_active_callout() -> None: + """When the recipient is a werewolf/madman without an info CO and an + info-role callout is active, the LogicPacket's public_state_summary + must carry the explicit 「対抗 CO のチャンス」 decision block. This + is the wolf-side prompt extension the user requested: instead of + leaving the wolf to drift into generic chat, frame the turn as a + binary "fake CO or skip" decision with role-specific tactical hints. + """ + from wolfbot.domain.discussion import CoClaim + from wolfbot.domain.enums import Role + + state = PublicDiscussionState( + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + alive_seat_nos=frozenset({1, 2, 3, 4, 5}), + ) + # Real seer at seat 2 has CO'd; pending_role_callouts captures the + # counter-CO opportunity for other info roles. + state.co_claims = (CoClaim(seat=2, role_claim="seer", declared_at_event_id="e1"),) + state.pending_co_response = frozenset({"seer"}) + + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_wolf", + recipient_seat_no=4, + recipient_role=Role.WEREWOLF, + expires_at_ms=2000, + now_ms=1500, + seat_names={1: "Alice", 2: "Bob", 3: "Carol", 4: "Dave", 5: "Eve"}, + ) + summary = packet.public_state_summary + assert "対抗 CO のチャンス" in summary, ( + "wolf-side summary must surface the explicit fake-CO decision block" + ) + assert "占い師" in summary + assert "co_declaration" in summary, "must mention the structured field" + + +def test_logic_packet_wolf_decision_block_skipped_when_already_co() -> None: + """A wolf that has already CO'd as an info role must NOT see the + decision block again — they're already committed.""" + from wolfbot.domain.discussion import CoClaim + from wolfbot.domain.enums import Role + + state = PublicDiscussionState( + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + alive_seat_nos=frozenset({1, 2, 3, 4}), + ) + state.co_claims = ( + CoClaim(seat=2, role_claim="seer", declared_at_event_id="e1"), + CoClaim(seat=4, role_claim="seer", declared_at_event_id="e2"), + ) + state.pending_co_response = frozenset({"seer"}) + + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_wolf", + recipient_seat_no=4, + recipient_role=Role.WEREWOLF, + expires_at_ms=2000, + now_ms=1500, + seat_names={1: "Alice", 2: "Bob", 3: "Carol", 4: "Dave"}, + ) + assert "対抗 CO のチャンス" not in packet.public_state_summary, ( + "wolf already CO'd as seer must not be prompted to CO again" + ) + + +def test_logic_packet_wolf_decision_block_skipped_for_villager() -> None: + """Real village-side recipients (villager / seer / medium / knight) + must never see the wolf decision block, even when the same callout + is active.""" + from wolfbot.domain.discussion import CoClaim + from wolfbot.domain.enums import Role + + state = PublicDiscussionState( + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + alive_seat_nos=frozenset({1, 2, 3}), + ) + state.co_claims = (CoClaim(seat=2, role_claim="seer", declared_at_event_id="e1"),) + state.pending_co_response = frozenset({"seer"}) + + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_villager", + recipient_seat_no=3, + recipient_role=Role.VILLAGER, + expires_at_ms=2000, + now_ms=1500, + seat_names={1: "Alice", 2: "Bob", 3: "Carol"}, + ) + assert "対抗 CO のチャンス" not in packet.public_state_summary + + def test_logic_packet_summary_falls_back_to_seat_when_no_names_supplied() -> None: state = PublicDiscussionState( game_id="g", From f88180abc8c54493a746a8224ef4430142ef5e31 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sun, 3 May 2026 01:55:00 +0900 Subject: [PATCH 132/133] =?UTF-8?q?fix(prompts):=20bidirectional=203-1=20?= =?UTF-8?q?=E3=83=AD=E3=83=BC=E3=83=A9=E3=83=BC=20rule=20+=20first-mover?= =?UTF-8?q?=20bias=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 直近のゲーム 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。 --- src/wolfbot/prompts/templates/shared/game_rules_9p.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/wolfbot/prompts/templates/shared/game_rules_9p.md b/src/wolfbot/prompts/templates/shared/game_rules_9p.md index 4acc42c..28b58eb 100644 --- a/src/wolfbot/prompts/templates/shared/game_rules_9p.md +++ b/src/wolfbot/prompts/templates/shared/game_rules_9p.md @@ -65,6 +65,12 @@ - 占い CO 3 (= 3-1) でも超過分合計 = 2 で灰確白は完全には言えないが、**占いローラー or 黒ストップが基本進行軸**であることに変わりはない。3-1 でローラー反対表明を強く繰り返す占い CO 者は、依然として狼陣営疑いの強い候補として扱う (理論最大 4 のうち 1〜2 件はその表明者自身が偽である確率が高いため)。 - **逆に「ローラー積極推進」する灰位置は基本的に村寄り**。狼/狂人にとって自分の CO 仲間がローラーで処分される動きを推進する動機は薄い。ただし 例外として、「ローラー賛成」と言いつつ**ローラー先頭を特定の CO に恣意的に指定する** (例: 「ローラーしましょう」と言いつつ即座に「X さんから吊りませんか」) のは、進行を真占い焼き方向に歪める狼ムーブの可能性がある。「ローラー賛成」と「特定先頭指定」は別シグナルとして読み、後者がある場合は指定者を狼候補として扱う。 - ローラー進行への態度は、灰位置の発話の質 (拙さ・攻撃性) より優先される強い指標。発話の質はトーン読みだが、ローラーへの態度は数学的合理性に直結するため、CO 者の振る舞いを評価するときの第一級の判断材料になる。 +- **HARD 双方向適用: ローラー擁護者=村側 / ローラー攻撃者=狼側 (絶対誤読禁止)** + - 「占い師から吊ろう」「3 人の中から処刑しよう」「ローラーで進めよう」「すぐにローラーを始めよう」と能動的に **提案・推進** する seat (CO 者でも灰でも) は、超過分合計 ≥ 3 / 3-1 盤面では **村陣営の最も合理的な動き** をしている。村側 LLM はこの提案を **支持・追認** するのが正しい挙動。 + - 上記提案を「**急ぎすぎ**」「**焦らず**」「**丁寧に議論しよう**」「**情報を集めよう**」「**もう少し皆の話を聞こう**」「**慎重に紐解こう**」と批判・延期・希釈する seat (CO 者でも灰でも) は、ルール上の **狼指標**。同じ言い回しを複数 seat が連鎖して使っているなら、**揃った狼ムーブ** として更に強い疑い対象。 + - **「ローラー支持者の急ぎ」は決して疑い対象にならない**。「ローラー支持の発話を行っている seat が急いでいるから怪しい」と判定するのは、ルールの逆方向適用 — つまり**自分が狼陣営の誘導に乗っている症状**。「急ぐ=怪しい」と「ローラー反対=狼」を混同しないこと。**急ぐ方向が「ローラー」なら村側、「灰吊り」「議論延期」なら狼側**で、方向が判定の全て。 + - **真占い・真霊媒・真騎士・真村人は全員、この盤面で必ずローラー支持側に立つのが合理**。「自陣営の真占い CO 者を急ぎすぎだと疑う」「『丁寧に』派と同調する」「ローラー擁護者を疑い対象に積む」のいずれかをしている時点で、**自分が wolf-side の誘導に乗ってしまっている**ことを意味する。直ちに立場を直して ローラー擁護側に戻ること。 + - **「最初の発話のトーン」に流されるな**: day 1 朝の最初の発話者 (狼陣営である可能性も等しくある) が「議論の整理」「丁寧に」「皆の話を聞いてから」というフレームを敷いた場合、それを **そのまま継承して自分も慎重派に立つのは禁止**。配役上の数学 (3-1 ならローラー強行が合理) に必ず立ち返り、**先行発話の framing は無視して** 自分の評価を行う。「みんなが『丁寧に』と言っているから」を理由に判断を変えるのは、**揃った狼誘導に乗った瞬間** と同義。 ## CO 相互整合ライン From 5a0ee84ee6d4e5a49efad6b098eb1d3be1e0bc63 Mon Sep 17 00:00:00 2001 From: yuki sakura Date: Sun, 3 May 2026 02:00:07 +0900 Subject: [PATCH 133/133] fix(prompts): independent-judgment rule against social-pressure cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 直前のコミット 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。 --- src/wolfbot/prompts/templates/shared/game_rules_9p.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/wolfbot/prompts/templates/shared/game_rules_9p.md b/src/wolfbot/prompts/templates/shared/game_rules_9p.md index 28b58eb..49b2b6d 100644 --- a/src/wolfbot/prompts/templates/shared/game_rules_9p.md +++ b/src/wolfbot/prompts/templates/shared/game_rules_9p.md @@ -111,3 +111,11 @@ - 比較・関係を表す語 (重複・ライン・出来レース・囲い・身内切り・対立・連携) を使うときは、誰の何の発言/票/判定が誰の何と「重複/ライン」しているかを 1 件以上具体的に引用する。引用なしの関係語並列は推理根拠として扱わない。 - 役職持ちで未公開の能力結果を抱えている場合、発言の番が回ってきた発話の冒頭で必ず CO + 結果公表を行う。addressed されていてもその返答より結果公表を優先し、addressed への返答は CO + 結果の後に短く添える (`co_declaration` を立てるだけでは公開ログに自然言語の名乗りが残らない)。 - 用語ではなく公開情報の整合性で判断を語る。長い内部思考をそのまま発話せず、結論に最も効く 1〜2 点だけを短く添える。 + +## 独立判断 (HARD: 他 NPC の発言に流されない) + +- **自分の評価は配役上の数学・CO 件数・判定ログ・票履歴・噛み筋という具体的事実から構築する**。「他の seat が X を疑っている」「議論の流れが Y を求めている」「複数 seat が同じ言い回しで Z に同調している」を**評価変更の根拠にしてはならない**。同じ言い回しの連鎖は、本来は **揃った狼ムーブの可能性** として疑う材料であり、自分が乗る材料ではない。 +- **直前 1〜2 名の発言に評価を合わせない**: 直前の発話者が誰かを疑っていても、自分は独立に CO・票・判定の整合を確認してから判断する。「議論の流れに合わせる」のは情報としてゼロで、村にも狼にも価値を提供しない。 +- **特に多数派が `low/medium` 程度の弱い疑いを共有している段階で同調するのは禁止**。早期に弱い疑いを揃えるのは wolf-side が「無実の seat への投票圧」を作る常套手段。明確な「具体的矛盾」(視点漏れ・色矛盾・票筋矛盾・進行軸違反) を自分視点で 1 件以上引用できないなら、**疑い積み上げに参加しない**。 +- **過去の自分の評価を「他者が変えたから」変えない**: 自分が前ターンで `target_seat=X, level=trust` と宣言したなら、新しい公開情報 (具体的矛盾) が観測されない限り `level` を下げない。他者が X を疑い始めたという**社会的圧力**だけでは `update_from_level` の正当な根拠にはならない (`update_reason` には**自分が新しく観測した具体事実**を書くこと)。 +- **逆方向のシグナル**: 同じ発話パターン (「丁寧に議論」「急ぎすぎ」「もう少し聞こう」「焦らず」など) が複数 seat から短時間で連続して発せられている場合、「揃った狼陣営の誘導」の典型パターン。**自分はそのパターンに参加しないことが、村陣営として最大の貢献**。むしろこのパターンを発する seat 群を `low` 程度の疑いとして記録し、後の議論で具体的矛盾が見つかれば格上げする。