Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
459 lines
20 KiB
Python
459 lines
20 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""LLM-driven user simulator with a real speech round trip.
|
|
|
|
The simulator is deliberately not an ordinary all-AI player. It occupies the same
|
|
protected user seat as ``HumanPlayerAgent`` and receives only that seat's memory. For
|
|
each turn a real LLM must call the one legal user tool. The selected utterance is then
|
|
synthesized to audio and the game consumes only the ASR transcript, never the original
|
|
text. This makes ASR mistakes observable instead of silently bypassing the voice
|
|
boundary.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
from . import agent as agent_module
|
|
from .agent import PlayerAgent
|
|
from .human import HumanPlayerAgent
|
|
from .roles import Role
|
|
|
|
|
|
def _usage_dict(value):
|
|
if value is None:
|
|
return None
|
|
if hasattr(value, "model_dump"):
|
|
return value.model_dump()
|
|
if isinstance(value, dict):
|
|
return value
|
|
return None
|
|
|
|
|
|
class SimulatedVoiceSession:
|
|
"""Headless speech transport for a synthetic user.
|
|
|
|
``openai`` uses the hosted OpenAI TTS and ASR APIs. ``gemini-system`` uses a
|
|
real local OS synthesizer to create the waveform and the hosted Gemini API for
|
|
ASR. ``openrouter-system`` uses the same local synthesis plus a multimodal model
|
|
through OpenRouter. ``auto`` chooses OpenAI, then OpenRouter, then Gemini. In all
|
|
cases the LLM user's text must cross an actual audio file and ASR before the game
|
|
sees it.
|
|
"""
|
|
|
|
def __init__(self, out_dir: str, *, provider: str = "auto"):
|
|
self.out_dir = Path(out_dir)
|
|
self.out_dir.mkdir(parents=True, exist_ok=True)
|
|
self.events = []
|
|
self._sequence = 0
|
|
requested = provider.casefold()
|
|
if requested == "auto":
|
|
requested = (
|
|
"openai" if os.getenv("OPENAI_API_KEY")
|
|
else "openrouter-system" if os.getenv("OPENROUTER_API_KEY")
|
|
else "gemini-system"
|
|
)
|
|
if requested not in {"openai", "openrouter-system", "gemini-system"}:
|
|
raise ValueError(
|
|
"simulator speech provider must be auto, openai, openrouter-system, "
|
|
"or gemini-system"
|
|
)
|
|
self.provider = requested
|
|
self.client = None
|
|
self.espeak = None
|
|
self.system_say = None
|
|
self.ffmpeg = None
|
|
if requested == "openai":
|
|
from openai import OpenAI
|
|
|
|
if not os.getenv("OPENAI_API_KEY"):
|
|
raise RuntimeError("OpenAI simulator speech requires OPENAI_API_KEY")
|
|
self.client = OpenAI(
|
|
api_key=os.environ["OPENAI_API_KEY"], timeout=90, max_retries=1
|
|
)
|
|
else:
|
|
if requested == "gemini-system" and not os.getenv("GEMINI_API_KEY"):
|
|
raise RuntimeError("gemini-system simulator speech requires GEMINI_API_KEY")
|
|
if requested == "openrouter-system":
|
|
from openai import OpenAI
|
|
|
|
if not os.getenv("OPENROUTER_API_KEY"):
|
|
raise RuntimeError(
|
|
"openrouter-system simulator speech requires OPENROUTER_API_KEY"
|
|
)
|
|
self.client = OpenAI(
|
|
api_key=os.environ["OPENROUTER_API_KEY"],
|
|
base_url="https://openrouter.ai/api/v1",
|
|
timeout=90,
|
|
max_retries=1,
|
|
)
|
|
self.espeak = shutil.which("espeak-ng") or shutil.which("espeak")
|
|
self.system_say = shutil.which("say")
|
|
self.ffmpeg = shutil.which("ffmpeg")
|
|
if not (self.espeak or self.system_say) or not self.ffmpeg:
|
|
raise RuntimeError(
|
|
"system speech requires espeak (Linux) or say (macOS), plus ffmpeg"
|
|
)
|
|
|
|
def _event(self, type_: str, **data):
|
|
self._sequence += 1
|
|
event = {
|
|
"sequence": self._sequence,
|
|
"monotonic": time.monotonic(),
|
|
"wall_time": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
"type": type_,
|
|
**data,
|
|
}
|
|
self.events.append(event)
|
|
(self.out_dir / "simulator_voice_trace.json").write_text(
|
|
json.dumps(self.events, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
)
|
|
return event
|
|
|
|
def record_llm_decision(self, **data):
|
|
self._event("simulator_llm_tool", **data)
|
|
|
|
def _synthesize(self, speaker: str, text: str, round_no: int) -> Path:
|
|
if not text.strip():
|
|
raise ValueError("refusing to synthesize an empty utterance")
|
|
started = time.monotonic()
|
|
stem = f"r{round_no}_{speaker}_{self._sequence + 1}"
|
|
request_id = None
|
|
model = None
|
|
if self.provider == "openai":
|
|
path = self.out_dir / f"{stem}.mp3"
|
|
model = os.getenv("OPENAI_TTS_MODEL", "gpt-4o-mini-tts")
|
|
response = self.client.audio.speech.create(
|
|
model=model,
|
|
voice=os.getenv("OPENAI_TTS_VOICE", "coral"),
|
|
input=text,
|
|
response_format="mp3",
|
|
)
|
|
path.write_bytes(response.content)
|
|
request_id = getattr(response, "_request_id", None)
|
|
provider = "OpenAI Audio API"
|
|
else:
|
|
path = self.out_dir / f"{stem}.wav"
|
|
with tempfile.TemporaryDirectory(prefix="werewolf-simulator-tts-") as directory:
|
|
if self.espeak:
|
|
voice = os.getenv("SIMULATOR_ESPEAK_VOICE", "en-us")
|
|
model = f"espeak-{voice}"
|
|
source = Path(directory) / "speech.wav"
|
|
command = [
|
|
self.espeak,
|
|
"-v", voice,
|
|
"-s", os.getenv("SIMULATOR_ESPEAK_SPEED", "145"),
|
|
"-w", str(source),
|
|
text,
|
|
]
|
|
else:
|
|
voice = os.getenv("SIMULATOR_SAY_VOICE", "Samantha")
|
|
model = f"macos-say-{voice}"
|
|
source = Path(directory) / "speech.aiff"
|
|
command = [self.system_say, "-v", voice, "-o", str(source), text]
|
|
subprocess.run(command, check=True, capture_output=True)
|
|
subprocess.run(
|
|
[self.ffmpeg, "-nostdin", "-loglevel", "error", "-y", "-i",
|
|
str(source), "-ac", "1", "-ar", "24000", str(path)],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
provider = "local espeak"
|
|
content = path.read_bytes()
|
|
if not content:
|
|
raise RuntimeError("speech synthesizer returned empty audio")
|
|
self._event(
|
|
"tts_ready",
|
|
speaker=speaker,
|
|
provider=provider,
|
|
model=model,
|
|
request_id=request_id,
|
|
latency_seconds=round(time.monotonic() - started, 3),
|
|
file=str(path),
|
|
audio_bytes=len(content),
|
|
audio_sha256=hashlib.sha256(content).hexdigest(),
|
|
)
|
|
return path
|
|
|
|
def _transcribe(self, path: Path) -> str:
|
|
started = time.monotonic()
|
|
request_id = None
|
|
usage = None
|
|
if self.provider == "openai":
|
|
model = os.getenv("OPENAI_ASR_MODEL", "gpt-4o-mini-transcribe")
|
|
with path.open("rb") as audio:
|
|
response = self.client.audio.transcriptions.create(
|
|
model=model,
|
|
file=audio,
|
|
language=os.getenv("VOICE_LANGUAGE", "zh"),
|
|
)
|
|
transcript = response.text.strip()
|
|
request_id = getattr(response, "_request_id", None)
|
|
provider = "OpenAI Audio API"
|
|
elif self.provider == "openrouter-system":
|
|
model = os.getenv("SIMULATOR_ASR_MODEL", "google/gemini-2.5-flash")
|
|
response = self.client.chat.completions.create(
|
|
model=model,
|
|
messages=[{"role": "user", "content": [
|
|
{"type": "text", "text": (
|
|
"Transcribe this short Werewolf game utterance exactly. Return only the "
|
|
"transcript with no label, quotes, explanation, or Markdown. Preserve "
|
|
"player-number phrases and seat labels such as P1."
|
|
)},
|
|
{"type": "input_audio", "input_audio": {
|
|
"data": base64.b64encode(path.read_bytes()).decode("ascii"),
|
|
"format": "wav",
|
|
}},
|
|
]}],
|
|
temperature=0,
|
|
max_tokens=512,
|
|
)
|
|
transcript = (response.choices[0].message.content or "").strip()
|
|
request_id = getattr(response, "id", None)
|
|
usage = _usage_dict(getattr(response, "usage", None))
|
|
provider = "OpenRouter multimodal audio API"
|
|
else:
|
|
model = os.getenv("GEMINI_ASR_MODEL", "gemini-2.5-flash")
|
|
audio = path.read_bytes()
|
|
payload = json.dumps(
|
|
{
|
|
"contents": [{"parts": [
|
|
{"text": (
|
|
"Transcribe this Mandarin Werewolf game utterance exactly. Return only "
|
|
"the transcript with no label, quotes, explanation, or Markdown. Preserve "
|
|
"seat labels such as P1 and Chinese player-number phrases."
|
|
)},
|
|
{"inline_data": {
|
|
"mime_type": "audio/wav",
|
|
"data": base64.b64encode(audio).decode("ascii"),
|
|
}},
|
|
]}],
|
|
"generationConfig": {"temperature": 0, "maxOutputTokens": 512},
|
|
},
|
|
ensure_ascii=False,
|
|
).encode("utf-8")
|
|
key = os.environ["GEMINI_API_KEY"]
|
|
request = urllib.request.Request(
|
|
f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}",
|
|
data=payload,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(request, timeout=90) as response:
|
|
data = json.loads(response.read().decode("utf-8"))
|
|
request_id = response.headers.get("x-request-id")
|
|
transcript = data["candidates"][0]["content"]["parts"][0]["text"].strip()
|
|
usage = data.get("usageMetadata")
|
|
provider = "Google Gemini API"
|
|
if not transcript:
|
|
raise RuntimeError("ASR returned an empty transcript")
|
|
self._event(
|
|
"simulator_asr",
|
|
provider=provider,
|
|
model=model,
|
|
request_id=request_id,
|
|
usage=usage,
|
|
latency_seconds=round(time.monotonic() - started, 3),
|
|
source_audio_sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
|
|
transcript=transcript,
|
|
)
|
|
return transcript
|
|
|
|
def roundtrip_user(self, speaker: str, text: str, round_no: int) -> str:
|
|
path = self._synthesize(speaker, text, round_no)
|
|
return self._transcribe(path)
|
|
|
|
def say(self, speaker: str, text: str, round_no: int, *, allow_barge_in: bool = False):
|
|
self._synthesize(speaker, text, round_no)
|
|
return None
|
|
|
|
def synth(self, speaker: str, text: str, round_no: int):
|
|
return self.say(speaker, text, round_no)
|
|
|
|
|
|
class SimulatedUserPlayerAgent(PlayerAgent):
|
|
"""An independent, tool-using LLM behind the user seat's speech boundary."""
|
|
|
|
_CHINESE_NUMBERS = {
|
|
1: "一", 2: "二", 3: "三", 4: "四", 5: "五",
|
|
6: "六", 7: "七", 8: "八", 9: "九", 10: "十",
|
|
}
|
|
|
|
def __init__(self, name: str, role: Role, voice: SimulatedVoiceSession, *, model=None):
|
|
super().__init__(name, role, offline=False)
|
|
self.voice = voice
|
|
self.simulator_model = model
|
|
self.is_simulated_user = True
|
|
self.is_user = True
|
|
|
|
def _tool_call(self, *, tool_name: str, description: str, properties: dict,
|
|
required: List[str], instruction: str, players: List[str]):
|
|
messages = [
|
|
{"role": "system", "content": (
|
|
self._system_prompt(players)
|
|
+ "\n你是独立的用户模拟器。请像有策略的真人玩家一样推理,并且必须调用给定工具完成当前回合。"
|
|
+ "\n证据纪律:把公开身份声明与自己确定知道的事实逐项比较。正确说出你的阵营是支持该声明的证据,"
|
|
"但不是绝对证明;矛盾声明则是反证。不要仅因某人公开了神职身份就投他,尤其不要在没有对跳或矛盾时"
|
|
"仅凭‘过早跳身份’放逐唯一的预言家声明者。怀疑与投票必须引用具体发言、查验声明或既有投票记录。"
|
|
)},
|
|
{"role": "user", "content": (
|
|
f"【你目前掌握的信息(仅你可见)】\n{self._context_block()}\n\n"
|
|
f"【当前任务】\n{instruction}"
|
|
)},
|
|
]
|
|
tool = {
|
|
"type": "function",
|
|
"function": {
|
|
"name": tool_name,
|
|
"description": description,
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": properties,
|
|
"required": required,
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
}
|
|
client = agent_module.get_client()
|
|
model = self.simulator_model or agent_module._MODEL
|
|
response = agent_module._safe_create(
|
|
client,
|
|
model=model,
|
|
messages=messages,
|
|
tools=[tool],
|
|
tool_choice={"type": "function", "function": {"name": tool_name}},
|
|
temperature=0.8,
|
|
max_tokens=512,
|
|
)
|
|
message = response.choices[0].message
|
|
calls = message.tool_calls or []
|
|
if len(calls) != 1 or calls[0].function.name != tool_name:
|
|
raise RuntimeError(f"user simulator did not call required tool {tool_name}")
|
|
try:
|
|
arguments = json.loads(calls[0].function.arguments)
|
|
except (TypeError, json.JSONDecodeError) as exc:
|
|
raise RuntimeError("user simulator returned invalid tool arguments") from exc
|
|
self.voice.record_llm_decision(
|
|
seat=self.name,
|
|
tool=tool_name,
|
|
arguments=arguments,
|
|
response_id=getattr(response, "id", None),
|
|
requested_model=model,
|
|
provider_reported_model=getattr(response, "model", None),
|
|
usage=_usage_dict(getattr(response, "usage", None)),
|
|
)
|
|
return arguments
|
|
|
|
def speak(self, players: List[str]) -> str:
|
|
arguments = self._tool_call(
|
|
tool_name="speak_publicly",
|
|
description="Submit the simulated user's public Werewolf speech.",
|
|
properties={
|
|
"utterance": {
|
|
"type": "string",
|
|
"description": "Natural concise English public speech, 2-4 short sentences.",
|
|
}
|
|
},
|
|
required=["utterance"],
|
|
instruction=(
|
|
"现在轮到你公开发言。结合私有记忆和公开历史进行真实的社交推理。"
|
|
"狼人应隐藏身份;好人应引用证据。为保证本机 TTS 清晰,请用简洁英文发言,"
|
|
"然后调用 speak_publicly。"
|
|
),
|
|
players=players,
|
|
)
|
|
utterance = str(arguments.get("utterance", "")).strip()
|
|
if not utterance:
|
|
raise RuntimeError("user simulator submitted empty public speech")
|
|
return self.voice.roundtrip_user(self.name, utterance, self._round_no())
|
|
|
|
def _round_no(self) -> int:
|
|
if hasattr(self, "current_round"):
|
|
return int(self.current_round)
|
|
rounds = []
|
|
for item in self.memory:
|
|
import re
|
|
rounds.extend(int(value) for value in re.findall(r"第(\d+)回合", item))
|
|
return max(rounds, default=0)
|
|
|
|
def _choose(self, *, prompt: str, candidates: List[str], players: List[str],
|
|
allow_none: bool, action: str) -> Optional[str]:
|
|
choices = list(candidates) + (["none"] if allow_none else [])
|
|
arguments = self._tool_call(
|
|
tool_name="choose_player",
|
|
description="Select exactly one legal player target or explicitly abstain when allowed.",
|
|
properties={
|
|
"target": {"type": "string", "enum": choices},
|
|
"reason": {"type": "string", "description": "A concise strategic reason."},
|
|
},
|
|
required=["target", "reason"],
|
|
instruction=(
|
|
f"{prompt}\n合法目标:{'、'.join(choices)}。这是 {action} 行动。"
|
|
"只依据你的私有记忆和公开信息推理,然后调用 choose_player。"
|
|
),
|
|
players=players,
|
|
)
|
|
target = str(arguments.get("target", "")).strip()
|
|
if target not in choices:
|
|
raise RuntimeError(f"user simulator selected illegal target {target!r}")
|
|
self.last_decision_reason = str(arguments.get("reason", "")).strip() or None
|
|
expected = None if target == "none" else target
|
|
if expected is None:
|
|
spoken = "I choose to abstain."
|
|
else:
|
|
number = int(expected[1:])
|
|
english = {
|
|
1: "one", 2: "two", 3: "three", 4: "four", 5: "five",
|
|
6: "six", 7: "seven", 8: "eight", 9: "nine", 10: "ten",
|
|
}
|
|
spoken = f"I choose player {english.get(number, number)}."
|
|
transcript = self.voice.roundtrip_user(self.name, spoken, self._round_no())
|
|
parsed = HumanPlayerAgent._spoken_target(transcript, candidates, allow_none)
|
|
explicit_abstention = expected is not None or HumanPlayerAgent._explicit_none(transcript)
|
|
if parsed != expected or not explicit_abstention:
|
|
self.voice._event(
|
|
"simulator_action_mismatch",
|
|
action=action,
|
|
tool_target=target,
|
|
asr_transcript=transcript,
|
|
parsed_target=parsed,
|
|
)
|
|
raise RuntimeError(
|
|
f"speech boundary changed simulator action: tool={target}, transcript={transcript!r}, parsed={parsed}"
|
|
)
|
|
return expected
|
|
|
|
def choose_target(self, prompt: str, candidates: List[str], players: List[str],
|
|
allow_none: bool = False) -> Optional[str]:
|
|
return self._choose(
|
|
prompt=prompt,
|
|
candidates=candidates,
|
|
players=players,
|
|
allow_none=allow_none,
|
|
action="night_target",
|
|
)
|
|
|
|
def vote(self, candidates: List[str], players: List[str]) -> Optional[str]:
|
|
return self._choose(
|
|
prompt=(
|
|
"现在是白天投票放逐环节,选出你认为最可能是狼人的玩家。好人阵营必须"
|
|
"按证据强度决策:没有对跳且已报告自洽查验结果的预言家声明是当前最强"
|
|
"公开证据;除非有具体矛盾或另一名预言家对跳,不得投该声明者。若其报告"
|
|
"某玩家是狼人,应优先投被查杀者;被查杀者仅仅否认不构成矛盾或对跳。"
|
|
"理由必须引用具体发言、查验或既有票型。"
|
|
),
|
|
candidates=candidates,
|
|
players=players,
|
|
allow_none=True,
|
|
action="vote",
|
|
)
|