ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
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

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""实验 10-6:语音狼人杀 Agent 系统(多 Agent + 信息权限控制 + 法官编排)。"""
+331
View File
@@ -0,0 +1,331 @@
# -*- coding: utf-8 -*-
"""玩家 Agent:每个玩家 = 一个独立的 LLM Agent,拥有**严格隔离的私有上下文**。
信息隔离的实现要点:
- 每个 PlayerAgent 只维护自己的 `memory`(一串它「观察到 / 被告知」的事件)。
- 法官(judge.py)决定把哪条信息推给哪个 Agent 的 memory——狼人才会收到「队友
身份」,预言家才会收到「查验结果」,公开发言才会推给所有人。
- Agent 每次思考(发言 / 投票 / 用技能)时,只能看到自己 memory 里的内容,
因此不可能「偷看」到本不该看到的信息。这就是信息权限控制的落点。
离线(--offline / --mock)策略:当没有 OpenAI Key、或想零成本可复现地跑完整一局时,
Agent 用一套**规则驱动**的决策代替 LLM。关键在于:离线策略同样**只读自己的 memory**
(不碰其他 Agent 的私有上下文),因此信息权限控制这一教学要点在离线模式下依然成立、
依然可被审计校验。
"""
import json
import os
import re
from typing import List, Optional
from .roles import Role, ROLE_STRATEGY, faction_of
# 全局唯一的 LLM 客户端。模型默认当前便宜旗舰 gpt-5.6-luna。
# 通用回退:优先 OPENAI_API_KEY 直连 OpenAI;没有则用 OPENROUTER_API_KEY 走 OpenRouter。
_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.6-luna")
_client = None
def _to_openrouter_model(model: str) -> str:
"""把模型名映射到 OpenRouter 命名空间(用于无 OPENAI_API_KEY 的回退路径)。"""
if "/" in model:
return model # 已是 OpenRouter 命名空间,原样使用
if model.startswith("gpt-"):
return "openai/" + model # gpt-* -> openai/gpt-*
if model.startswith("claude-"):
return "anthropic/claude-opus-4.8"
return "openai/gpt-5.6-luna" # 兜底:当前便宜旗舰
def _safe_create(client, **kwargs):
"""调用 Chat Completions;对推理型模型(如 gpt-5.x)的参数限制做自动降级重试:
- 不支持 max_tokens 时改用 max_completion_tokens
- 不支持非默认 temperature 时移除该参数(回退到模型默认 1)。
这样同一份代码既能跑传统对话模型(接受 temperature=0.8),也能跑推理模型。"""
for _ in range(3):
try:
return client.chat.completions.create(**kwargs)
except Exception as e:
msg = str(e)
if "max_completion_tokens" in msg and "max_tokens" in kwargs:
kwargs["max_completion_tokens"] = kwargs.pop("max_tokens")
continue
if "temperature" in msg and "temperature" in kwargs:
kwargs.pop("temperature", None)
continue
raise
return client.chat.completions.create(**kwargs)
def get_client():
"""返回全局共享的 LLM 客户端(懒加载,进程内单例)。
仅在线模式(真实调用 LLM)才会用到;离线模式不导入 openai、不构造客户端。
1) 有 ARK/Moonshot key -> 使用其 OpenAI-compatible real endpoint
2) 否则直连 OpenAI3) 最后才回退 OpenRouter。
"""
global _client, _MODEL
if _client is None:
from openai import OpenAI # 懒导入:离线模式无需安装 openai
client_options = {
"timeout": float(os.getenv("WEREWOLF_LLM_TIMEOUT", "45")),
"max_retries": int(os.getenv("WEREWOLF_LLM_RETRIES", "1")),
}
if os.environ.get("ARK_API_KEY"):
_MODEL = os.getenv("ARK_MODEL", "doubao-seed-1-6-250615")
_client = OpenAI(api_key=os.environ["ARK_API_KEY"],
base_url="https://ark.cn-beijing.volces.com/api/v3",
**client_options)
elif os.environ.get("MOONSHOT_API_KEY"):
_MODEL = os.getenv("MOONSHOT_MODEL", "kimi-k3")
_client = OpenAI(api_key=os.environ["MOONSHOT_API_KEY"],
base_url="https://api.moonshot.cn/v1", **client_options)
elif os.environ.get("OPENAI_API_KEY"):
_client = OpenAI(**client_options) # 自动读取 OPENAI_API_KEY
elif os.environ.get("OPENROUTER_API_KEY"):
_MODEL = _to_openrouter_model(_MODEL)
_client = OpenAI(
api_key=os.environ["OPENROUTER_API_KEY"],
base_url="https://openrouter.ai/api/v1",
**client_options,
)
else:
raise RuntimeError(
"未设置 ARK/MOONSHOT/OPENAI/OPENROUTER 任一文本模型 Key,请参考 env.example"
"或改用离线模式:python demo.py --offline"
)
return _client
class PlayerAgent:
"""一个玩家 Agent,封装其身份、私有上下文与决策(LLM 或离线规则)。"""
def __init__(self, name: str, role: Role, offline: bool = False, rng=None):
self.name = name # 玩家名,如 "P3"
self.role = role # 真实身份(只有本人和法官知道)
self.faction = faction_of(role)
self.alive = True
self.offline = offline # True 时用规则策略代替 LLM(零成本、可复现)
# 离线策略的私有随机源(按玩家名种子化,保证可复现且各玩家独立)
import random as _random
self._rng = rng or _random.Random(hash(name) & 0xFFFF)
# 私有上下文:这个 Agent「看得到」的全部信息。别的 Agent 无法访问。
self.memory: List[str] = []
# ---- 上下文注入:只有法官会调用,用来把信息投递进这个 Agent 的私有上下文 ----
def observe(self, event: str):
"""把一条信息写入本 Agent 的私有上下文。"""
self.memory.append(event)
# ---- system prompt:角色设定 + 策略。狼人的队友身份不写在这里,而是由法官
# 在游戏开始时通过 observe() 投递,以便审计能记录「谁看到了队友身份」。 ----
def _system_prompt(self, players: List[str]) -> str:
return (
f"你正在玩一局狼人杀。你是玩家 {self.name}\n"
f"你的真实身份是【{self.role.value}】,属于【{self.faction.value}】。\n"
f"本局玩家共 {len(players)} 人:{''.join(players)}\n\n"
f"{ROLE_STRATEGY[self.role]}\n\n"
"重要:只能依据你已知的信息推理,不要臆造你无从得知的身份。发言要像真人,"
"简洁自然,有理有据。"
)
def _context_block(self) -> str:
"""把私有上下文拼成给 LLM 的一段文字。"""
if not self.memory:
return "(暂无信息)"
return "\n".join(f"- {m}" for m in self.memory)
def _chat(self, instruction: str, players: List[str], max_tokens: int,
json_mode: bool = False) -> str:
"""组装 system + user 消息并调用 LLMuser 消息里只拼接本 Agent 自己的
私有上下文(`_context_block`),绝不包含其他玩家的私密信息。"""
messages = [
{"role": "system", "content": self._system_prompt(players)},
{"role": "user", "content":
f"【你目前掌握的信息(仅你可见)】\n{self._context_block()}\n\n"
f"【当前任务】\n{instruction}"},
]
# 给推理型模型(如 gpt-5.6 系列)留足输出预算:其内部推理 token 也计入
# max_tokens,预算过小会导致 content 被截断为空。设一个下限兜底。
# Resolve the provider before reading _MODEL: get_client() may switch the
# model id from the OpenAI default to an ARK/Moonshot endpoint id.
client = get_client()
kwargs = dict(model=_MODEL, messages=messages, temperature=0.8,
max_tokens=max(max_tokens, 512))
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
resp = _safe_create(client, **kwargs)
content = (resp.choices[0].message.content or "").strip()
if not content:
# Some reasoning models can spend the entire small action budget on
# hidden reasoning and return no visible speech/JSON. Retry once with
# a larger bounded budget; an empty second response remains a hard
# failure instead of becoming silent speech or a random action.
retry_kwargs = dict(kwargs)
budget_key = (
"max_completion_tokens"
if "max_completion_tokens" in retry_kwargs
else "max_tokens"
)
retry_kwargs[budget_key] = max(int(retry_kwargs.get(budget_key, 0)) * 4, 2048)
resp = _safe_create(client, **retry_kwargs)
content = (resp.choices[0].message.content or "").strip()
if not content:
raise RuntimeError("LLM returned empty visible content after bounded retry")
return content
# ---------- 三种对外能力:发言 / 决策(选目标)/ 投票 ----------
def speak(self, players: List[str]) -> str:
"""白天公开发言。返回一段发言文本(公开信息)。"""
if self.offline:
return self._offline_speak(candidates=[p for p in players if p != self.name])
instruction = (
"现在轮到你在白天公开发言。请结合你掌握的信息,发表一段简短的发言"
"(2~4 句话,60 字以内)。符合你的身份与策略。直接输出发言内容,不要加引号。"
)
return self._chat(instruction, players, max_tokens=180)
def choose_target(self, prompt: str, candidates: List[str],
players: List[str], allow_none: bool = False) -> Optional[str]:
"""让 Agent 从候选人中选一个目标(夜间刀人 / 查验 / 用毒 / 救人判断等)。
用 JSON 模式返回,鲁棒地解析出目标玩家名。
"""
if self.offline:
return self._offline_choose_target(candidates, allow_none)
opt = ",也可以选择放弃(target 填 \"none\"" if allow_none else ""
instruction = (
f"{prompt}\n候选玩家:{''.join(candidates)}{opt}\n"
"请只返回 JSON{\"target\": \"玩家名或none\", \"reason\": \"一句话理由\"}"
)
raw = self._chat(instruction, players, max_tokens=120, json_mode=True)
self.last_decision_reason = self._parse_reason(raw)
target = self._parse_target(raw, candidates, allow_none)
return target
def vote(self, candidates: List[str], players: List[str]) -> Optional[str]:
"""投票放逐。返回票投给谁(或弃票 none)。"""
if self.offline:
return self._offline_vote(candidates)
instruction = (
"现在是白天投票放逐环节。请根据全场发言与你的推理,投出你认为最可能是"
"狼人的玩家。好人阵营必须按证据强度决策:没有对跳且已报告自洽查验结果的"
"预言家声明是当前最强公开证据;除非有具体矛盾或另一名预言家对跳,不得投该"
"声明者。若其报告某玩家是狼人,应优先投被查杀者;被查杀者仅仅否认并不构成"
"矛盾或对跳。投票理由必须引用具体发言、查验或既有票型,不得随机猜测。\n"
"候选玩家:" + "".join(candidates) + "\n"
"请只返回 JSON{\"target\": \"玩家名\", \"reason\": \"一句话理由\"}"
)
raw = self._chat(instruction, players, max_tokens=120, json_mode=True)
self.last_decision_reason = self._parse_reason(raw)
return self._parse_target(raw, candidates, allow_none=True)
# ---------- 离线(规则)策略:只读自己的 memory,绝不访问他人上下文 ----------
def _known_teammates(self) -> set:
"""狼人从自己的私有上下文里解析出队友名单(好人解析不到,返回空)。"""
mates = set()
for m in self.memory:
hit = re.search(r"狼人阵营的玩家是:([^(]+)", m)
if hit:
mates |= set(re.findall(r"P\d+", hit.group(1)))
return mates
def _known_wolves(self) -> set:
"""从自己的私有上下文里收集『已知是狼人』的玩家:预言家的查验结果 + 狼人的队友。
好人平民无从得知任何人身份 → 返回空集合,只能随机投票。这正是信息不对称。
"""
known = set(self._known_teammates())
for m in self.memory:
hit = re.search(r"你查验了\s*(P\d+),结果为【狼人】", m)
if hit:
known.add(hit.group(1))
return known
def _offline_vote(self, candidates: List[str]) -> Optional[str]:
"""离线投票:优先投自己『确知的狼人』(预言家验人 / 狼人不投队友),否则随机。"""
if not candidates:
return None
if self.role == Role.WEREWOLF:
# 狼人:投一个非队友的好人,尽量隐藏自己
mates = self._known_teammates()
targets = [c for c in candidates if c not in mates] or candidates
return self._rng.choice(targets)
# 好人:预言家有验人结果就投确认的狼;其余平民只能随机(信息不对称的代价)
wolves = [c for c in candidates if c in self._known_wolves()]
if wolves:
return self._rng.choice(wolves)
return self._rng.choice(candidates)
def _offline_choose_target(self, candidates: List[str],
allow_none: bool) -> Optional[str]:
"""离线夜间选目标:狼人/预言家等必选场景优先选『已知狼人之外』的目标;
女巫解药/毒药等可放弃场景按概率决定。"""
if not candidates:
return None
if allow_none:
# 女巫用药:约一半概率行动(救/毒),使对局有变化又能收敛
if self._rng.random() < 0.5:
return None
return self._rng.choice(candidates)
if self.role == Role.SEER:
# 预言家:优先查验尚未确认身份的玩家(避免重复查验已知狼人)
unknown = [c for c in candidates if c not in self._known_wolves()]
return self._rng.choice(unknown or candidates)
if self.role == Role.WEREWOLF:
mates = self._known_teammates()
targets = [c for c in candidates if c not in mates] or candidates
return self._rng.choice(targets)
return self._rng.choice(candidates)
def _offline_speak(self, candidates: List[str]) -> str:
"""离线发言:按角色生成一句符合身份、且不泄露私密信息的模板发言。"""
wolves = [c for c in candidates if c in self._known_wolves()]
suspect = self._rng.choice(candidates) if candidates else "大家"
if self.role == Role.SEER and wolves:
return f"我是预言家,昨晚查验到 {wolves[0]} 是狼人,请大家把票投给他。"
if self.role == Role.WEREWOLF:
return f"我是好人,从发言看 {suspect} 有点可疑,建议重点关注他。"
if self.role == Role.WITCH:
return f"我暂时观望,觉得 {suspect} 的发言站不住脚,先留意一下。"
if self.role == Role.SEER:
return "我还没有决定性的信息,先听大家发言,谨慎投票。"
return f"我是村民,没有夜间信息,只能靠推理,感觉 {suspect} 稍微可疑。"
# ---------- 解析工具 ----------
@staticmethod
def _parse_reason(raw: str) -> Optional[str]:
try:
reason = json.loads(raw).get("reason")
except Exception:
return None
return reason.strip() if isinstance(reason, str) and reason.strip() else None
@staticmethod
def _parse_target(raw: str, candidates: List[str], allow_none: bool) -> Optional[str]:
target = None
try:
data = json.loads(raw)
target = str(data.get("target", "")).strip()
except Exception:
# 兜底:直接从文本里正则找候选玩家名
target = raw
if allow_none and target.lower() in ("none", "", "弃票", "放弃"):
return None
# 归一化:精确匹配优先
if target in candidates:
return target
# 其次:从原始串里搜 Pn 精确 token。必须先于子串匹配——
# 否则 10 人以上的局里 "P10(他最可疑)" 会先命中 "P1"。
m = re.search(r"P\d+", target or "")
if m and m.group(0) in candidates:
return m.group(0)
# 最后兜底:子串匹配,最长的候选名优先(避免 P1 抢先命中 P10)
for c in sorted(candidates, key=len, reverse=True):
if c in (target or ""):
return c
# 实在解析不出:好人默认弃票,狼人/必须选的场景由调用方兜底
return None if allow_none else (candidates[0] if candidates else None)
@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
"""信息可见性审计(Information Visibility Audit)。
这是本实验「信息权限控制可验证」的核心工具:法官每向某个(或某些)玩家的
上下文投递一条信息时,都会在这里登记一条记录——这条信息属于哪个类别、内容
摘要是什么、**进入了谁的上下文**。游戏结束后打印这张审计表,即可客观证明
信息隔离是否正确(例如「狼人队友身份」只进狼人上下文、「预言家查验结果」只进
预言家本人上下文、「公开发言」进所有人上下文)。
"""
from dataclasses import dataclass, field
from typing import List
@dataclass
class AuditRecord:
round_no: int # 第几回合
phase: str # 阶段(夜晚/白天/...
category: str # 信息类别(如「狼人队友身份」「预言家查验结果」「公开发言」)
content: str # 信息内容摘要
visible_to: List[str] # 该信息进入了哪些玩家的上下文(玩家名列表)
@dataclass
class AuditLog:
records: List[AuditRecord] = field(default_factory=list)
def add(self, round_no, phase, category, content, visible_to):
self.records.append(AuditRecord(round_no, phase, category, content, list(visible_to)))
def print_table(self, all_players):
"""打印完整的信息可见性审计表。"""
print("\n" + "=" * 78)
print("信息可见性审计表(每条信息进入了谁的上下文)")
print("=" * 78)
header = f"{'回合':<4}{'阶段':<6}{'类别':<14}{'可见玩家':<20}内容"
print(header)
print("-" * 78)
for r in self.records:
vis = "所有人" if set(r.visible_to) == set(all_players) else "".join(r.visible_to)
content = r.content if len(r.content) <= 30 else r.content[:29] + ""
print(f"{r.round_no:<5}{r.phase:<7}{r.category:<15}{vis:<21}{content}")
print("=" * 78)
+471
View File
@@ -0,0 +1,471 @@
# -*- coding: utf-8 -*-
"""法官(主持人)Agent:代码驱动的游戏编排与信息权限控制中枢。
法官不是 LLM——它是**确定性的编排器**,负责:
1. 维护中心化游戏状态(身份、阵营、生死、阶段、历史)。
2. **信息权限控制**:决定每条信息投递给哪些玩家 Agent 的私有上下文
(狼人才知道队友、预言家才知道查验结果、公开发言进所有人),并登记审计。
3. 编排昼夜循环:夜晚(狼人刀人 → 预言家查验 → 女巫用药)→ 白天(公布死讯 →
依次发言 → 投票放逐)→ 结算胜负。
"""
import random
from collections import Counter
from typing import List, Optional
from .agent import PlayerAgent
from .audit import AuditLog
from .roles import Role, Faction
def build_roles(players: int = 7, wolves: Optional[int] = None) -> List[Role]:
"""按玩家总数推导身份组成:默认 7 人 = 2 狼人 + 1 预言家 + 1 女巫 + 3 村民。
- wolves 未指定时按 max(1, players // 3) 估算(7 人得 2 狼,与书中默认一致)。
- 4 人及以上配 1 预言家,5 人及以上再配 1 女巫,其余全为村民。
"""
if players < 3:
raise ValueError("玩家总数至少为 3")
wolves = wolves if wolves is not None else max(1, players // 3)
seer = 1 if players >= 4 else 0
witch = 1 if players >= 5 else 0
villagers = players - wolves - seer - witch
if wolves < 1 or villagers < 0:
raise ValueError(
f"身份组成非法:{players} 人无法容纳 {wolves} 狼 + {seer} 预言家 + "
f"{witch} 女巫(剩余村民 {villagers})。请调小 --wolves 或调大 --players。")
return ([Role.WEREWOLF] * wolves + [Role.SEER] * seer
+ [Role.WITCH] * witch + [Role.VILLAGER] * villagers)
def create_players(seed: int = 42, players: int = 7, wolves: Optional[int] = None,
offline: bool = False, human_seat: Optional[int] = None,
voice=None, simulated_user_seat: Optional[int] = None,
simulator_model: Optional[str] = None) -> List[PlayerAgent]:
"""创建一局游戏(默认 7 人:2 狼人 + 1 预言家 + 1 女巫 + 3 村民,控成本)。
身份随机洗牌后分配给 P1~Pn,保证每局身份分布不同但可用 seed 复现。
offline=True 时每个 Agent 用规则策略代替 LLM(零成本、可复现);每个 Agent
还会拿到一个按 seed 与序号种子化的独立随机源,保证离线对局完全可复现。
"""
rng = random.Random(seed)
roles = build_roles(players, wolves)
rng.shuffle(roles)
result = []
if human_seat is not None and simulated_user_seat is not None:
raise ValueError("human_seat and simulated_user_seat are mutually exclusive")
for i, role in enumerate(roles):
if human_seat == i + 1:
if voice is None:
raise ValueError("human_seat requires a live voice session")
from .human import HumanPlayerAgent
result.append(HumanPlayerAgent(f"P{i+1}", role, voice))
elif simulated_user_seat == i + 1:
if voice is None:
raise ValueError("simulated_user_seat requires a simulated voice session")
from .simulator import SimulatedUserPlayerAgent
result.append(SimulatedUserPlayerAgent(
f"P{i+1}", role, voice, model=simulator_model
))
else:
result.append(PlayerAgent(f"P{i+1}", role, offline=offline,
rng=random.Random(seed * 1000 + i)))
return result
class Judge:
"""法官:编排 + 信息权限控制。"""
def __init__(self, players: List[PlayerAgent], seed: int = 42,
tts=None, max_rounds: int = 6):
self.players = players
self.names = [p.name for p in players]
self.audit = AuditLog()
self.rng = random.Random(seed + 1)
self.tts = tts # 可选的 TTS 合成器(--voice 时注入)
self.max_rounds = max_rounds
self.round_no = 0
self.phase = "初始化"
self.completed_rounds = 0
self.action_history = []
# 女巫药剂状态
self.witch_heal_available = True
self.witch_poison_available = True
# ------------------------------------------------------------------
# 信息投递原语:每个原语都同时 (a) 写入相应 Agent 的私有上下文;
# (b) 在审计日志里登记「这条信息进了谁的上下文」。
# ------------------------------------------------------------------
def _log(self, category, content, visible_to):
self.audit.add(self.round_no, self.phase, category, content, visible_to)
@staticmethod
def _decision_record(player, **record):
"""Attach the model's stated reason to evidence without exposing it publicly."""
reason = getattr(player, "last_decision_reason", None)
if reason:
record["reason"] = reason
player.last_decision_reason = None
return record
def broadcast(self, category: str, content: str):
"""公开信息:进入**所有玩家**(含已出局者)的上下文。"""
for p in self.players:
p.observe(content)
self._log(category, content, self.names)
def private_send(self, player: PlayerAgent, category: str, content: str):
"""私密信息:只进入**指定单个玩家**的上下文。"""
player.observe(content)
self._log(category, content, [player.name])
def wolves_send(self, category: str, content: str):
"""狼人专属信息:只进入**所有狼人**的上下文。"""
wolves = self.wolves()
for w in wolves:
w.observe(content)
self._log(category, content, [w.name for w in wolves])
# ------------------------------------------------------------------
# 状态查询
# ------------------------------------------------------------------
def alive(self) -> List[PlayerAgent]:
return [p for p in self.players if p.alive]
def wolves(self, alive_only=False) -> List[PlayerAgent]:
ws = [p for p in self.players if p.role == Role.WEREWOLF]
return [w for w in ws if w.alive] if alive_only else ws
def by_name(self, name: str) -> Optional[PlayerAgent]:
for p in self.players:
if p.name == name:
return p
return None
def _print_private(self, permitted: List[str], text: str):
"""Prevent private night logs leaking through the live player's terminal."""
user = next((p for p in self.players if getattr(p, "is_user", False)), None)
if user is None or user.name in permitted:
print(text)
# ------------------------------------------------------------------
# 阶段 0:分配身份并投递「谁知道谁」的初始信息
# ------------------------------------------------------------------
def assign_identities(self):
self.phase = "身份分配"
print("\n" + "#" * 78)
print("【阶段 0 · 身份分配】法官私下告知每人身份;狼人额外被告知队友是谁。")
print(" 信息隔离:每人只知道自己的身份;只有狼人上下文里有『队友身份』。")
print("#" * 78)
# 每个玩家私下知道自己的身份(只进本人上下文)
for p in self.players:
self.private_send(p, "身份分配", f"你的身份是:{p.role.value}")
# 狼人互相知道队友是谁(只进狼人上下文)——这是信息不对称的关键
wolves = self.wolves()
team = "".join(w.name for w in wolves)
self.wolves_send("狼人队友身份", f"狼人阵营的玩家是:{team}(你们互为队友,夜晚共同行动)")
user = next((p for p in self.players if getattr(p, "is_user", False)), None)
if user:
# The old all-AI observer table would be a side-channel leak to a real
# player. In live mode only the human's permitted private facts are spoken.
print(" [真人局] 上帝视角身份表已隐藏,防止终端侧信道泄露。")
private = f"您的座位是{user.name},您的身份是{user.role.value}"
if user.role == Role.WEREWOLF:
private += f" 狼人队友是{team}"
user.voice.say("judge-private", private, 0, allow_barge_in=False)
else:
# All-AI diagnostic mode may show ground truth to the external evaluator.
print(" [上帝视角/仅外部评测者可见] 真实身份表:")
for p in self.players:
print(f" {p.name}: {p.role.value}{p.faction.value}")
print(f" 狼队友(仅狼人 {team} 的上下文里有这条信息)")
# ------------------------------------------------------------------
# 夜晚
# ------------------------------------------------------------------
def night(self) -> List[str]:
"""执行一个夜晚,返回今晚出局玩家名列表。"""
self.phase = "夜晚"
print("\n" + "=" * 78)
print(f"【第 {self.round_no} 回合 · 夜晚】天黑请闭眼。")
print(" 信息隔离:以下所有行动与结果都是私密的——狼人共识只进狼人上下文、")
print(" 预言家查验结果只进预言家上下文、女巫用药只进女巫上下文。")
print("=" * 78)
killed = self._wolves_act()
self._seer_act()
poisoned, saved = self._witch_act(killed)
# 结算今晚死亡:被刀且未被救 + 被毒
deaths = []
if killed and not saved:
deaths.append(killed)
if poisoned and poisoned not in deaths:
deaths.append(poisoned)
for name in deaths:
self.by_name(name).alive = False
return deaths
def _wolves_act(self) -> Optional[str]:
wolves = self.wolves(alive_only=True)
if not wolves:
return None
# 候选:所有存活的非狼人(狼人不刀自己人)
candidates = [p.name for p in self.alive() if p.role != Role.WEREWOLF]
if not candidates:
return None
votes = []
for w in wolves:
t = w.choose_target(
"现在是夜晚,狼人行动。请与队友一致,选择今晚要击杀的一名好人玩家。",
candidates, self.names, allow_none=False)
if t:
votes.append(t)
self.action_history.append(self._decision_record(
w, round=self.round_no, phase="night", actor=w.name,
role=w.role.value, action="kill_proposal", target=t
))
self._print_private(
[wolf.name for wolf in wolves],
f" [仅法官+狼人可见] 狼人 {w.name} 提议击杀 → {t}",
)
if not votes:
return None
# 汇总:最高票;平票取第一名狼人的意见
tally = Counter(votes)
top = tally.most_common()
best = [n for n, c in top if c == top[0][1]]
killed = next((v for v in votes if v in best), best[0]) if len(best) > 1 else top[0][0]
# 把「今晚狼人共识」写进狼人共享上下文(只有狼人看得到)
self.wolves_send("狼人夜间共识", f"{self.round_no}回合夜晚,狼人决定击杀 {killed}")
self._print_private(
[wolf.name for wolf in wolves],
f" → 狼人共识:击杀 {killed}(此共识只进狼人上下文)",
)
return killed
def _seer_act(self):
seers = [p for p in self.alive() if p.role == Role.SEER]
if not seers:
return
seer = seers[0]
candidates = [p.name for p in self.alive() if p.name != seer.name]
target = seer.choose_target(
"现在是夜晚,预言家行动。请选择一名玩家查验其真实阵营。",
candidates, self.names, allow_none=False)
if not target:
target = self.rng.choice(candidates)
tgt = self.by_name(target)
result = "狼人" if tgt.role == Role.WEREWOLF else "好人"
self.action_history.append(self._decision_record(
seer, round=self.round_no, phase="night", actor=seer.name,
role=seer.role.value, action="inspect", target=target, result=result
))
# 查验结果只进预言家本人上下文——这是预言家独享的关键信息
self.private_send(seer, "预言家查验结果",
f"{self.round_no}回合你查验了 {target},结果为【{result}")
if getattr(seer, "is_user", False):
seer.voice.say(
"judge-private",
f"您查验了{target},结果是{result}",
self.round_no,
allow_barge_in=False,
)
self._print_private(
[seer.name],
f" [仅法官+预言家 {seer.name} 可见] 预言家查验 {target}{result}",
)
def _witch_act(self, killed: Optional[str]):
witches = [p for p in self.alive() if p.role == Role.WITCH]
if not witches:
return None, False
witch = witches[0]
saved = False
poisoned = None
# 告知女巫今晚谁被刀(只进女巫上下文)
if killed:
self.private_send(witch, "女巫夜间信息", f"{self.round_no}回合,今晚被狼人袭击的是 {killed}")
self._print_private(
[witch.name],
f" [仅法官+女巫 {witch.name} 可见] 女巫得知今晚被刀者:{killed}",
)
# 解药:是否救
if self.witch_heal_available and killed != witch.name:
dec = witch.choose_target(
f"今晚 {killed} 被狼人袭击。你是否使用【解药】救他?"
"(救则 target 填该玩家名,不救填 none)",
[killed], self.names, allow_none=True)
if dec == killed:
saved = True
self.witch_heal_available = False
self.private_send(witch, "女巫用药", f"你在第{self.round_no}回合使用了解药,救活了 {killed}")
self._print_private(
[witch.name], f" [仅法官+女巫可见] 女巫使用解药救 {killed}"
)
self.action_history.append(self._decision_record(
witch, round=self.round_no, phase="night", actor=witch.name,
role=witch.role.value, action="heal", target=killed
))
else:
witch.last_decision_reason = None
else:
self.private_send(witch, "女巫夜间信息", f"{self.round_no}回合是平安夜(无人被狼人击杀,或你无从得知)")
# 毒药:被狼人击杀且未救活的女巫今晚无法使用毒药
if self.witch_poison_available and not (killed == witch.name and not saved):
candidates = [p.name for p in self.alive() if p.name != witch.name]
dec = witch.choose_target(
"你是否使用【毒药】毒死一名你怀疑是狼人的玩家?(毒则填玩家名,不毒填 none)",
candidates, self.names, allow_none=True)
if dec and dec in candidates:
poisoned = dec
self.witch_poison_available = False
self.private_send(witch, "女巫用药", f"你在第{self.round_no}回合使用了毒药,毒杀了 {poisoned}")
self._print_private(
[witch.name], f" [仅法官+女巫可见] 女巫使用毒药毒 {poisoned}"
)
self.action_history.append(self._decision_record(
witch, round=self.round_no, phase="night", actor=witch.name,
role=witch.role.value, action="poison", target=poisoned
))
else:
witch.last_decision_reason = None
return poisoned, saved
# ------------------------------------------------------------------
# 白天
# ------------------------------------------------------------------
def day(self, night_deaths: List[str]) -> Optional[str]:
"""白天:公布死讯 → 依次发言 → 投票放逐。返回被放逐者名(或 None)。"""
self.phase = "白天"
print("\n" + "=" * 78)
print(f"【第 {self.round_no} 回合 · 白天】天亮请睁眼。")
print(" 信息隔离:死讯、发言、投票结果都是公开信息,进入所有人上下文。")
print("=" * 78)
# 公布死讯(公开)
if night_deaths:
msg = f"天亮了。昨晚出局的玩家是:{''.join(night_deaths)}"
else:
msg = "天亮了。昨晚是平安夜,无人出局"
self.broadcast("公开-死讯", msg)
print(f" 法官宣布:{msg}")
if self.tts and hasattr(self.tts, "say"):
self.tts.say("judge", msg, self.round_no, allow_barge_in=False)
if self._check_winner():
return None
# 依次发言(公开)
print("\n —— 发言阶段(按座位顺序,公开发言进入所有人上下文)——")
for p in self.alive():
speech = p.speak(self.names)
line = f"{p.name}(发言):{speech}"
self.broadcast("公开发言", f"{p.name} 说:{speech}")
self.action_history.append({"round": self.round_no, "phase": "day", "actor": p.name, "role": p.role.value, "action": "speech", "text": speech})
print(f" {line}")
if self.tts and not getattr(p, "is_user", False):
interruption = self.tts.synth(p.name, speech, self.round_no)
if interruption:
human = next((q for q in self.alive() if getattr(q, "is_human", False)), None)
if human:
self.broadcast("公开发言-真人打断", f"{human.name} 打断说:{interruption}")
self.action_history.append({"round": self.round_no, "phase": "day", "actor": human.name, "role": human.role.value, "action": "interruption", "text": interruption, "interrupted": p.name})
print(f" [实时打断] {human.name}{interruption}")
# 投票放逐(公开)
print("\n —— 投票阶段 ——")
exiled = self._vote()
return exiled
def _vote(self) -> Optional[str]:
alive = self.alive()
tally = Counter()
for p in alive:
candidates = [q.name for q in alive if q.name != p.name]
t = p.vote(candidates, self.names)
if t:
tally[t] += 1
self.action_history.append(self._decision_record(
p, round=self.round_no, phase="vote", actor=p.name,
role=p.role.value, action="vote", target=t
))
print(f" {p.name} 投票 → {t}")
else:
p.last_decision_reason = None
print(f" {p.name} 弃票")
if not tally:
self.broadcast("公开-放逐", "本轮无人被放逐(全部弃票)")
print(" 本轮无人被放逐")
return None
top = tally.most_common()
best = [n for n, c in top if c == top[0][1]]
exiled = self.rng.choice(best) if len(best) > 1 else top[0][0]
ex = self.by_name(exiled)
ex.alive = False
result = f"投票结果:{exiled} 被放逐出局,其真实身份是【{ex.role.value}】。计票:" + \
"".join(f"{n}={c}" for n, c in top)
self.broadcast("公开-放逐", result)
print(f"{result}")
if self.tts and hasattr(self.tts, "say"):
self.tts.say("judge", result, self.round_no, allow_barge_in=False)
return exiled
# ------------------------------------------------------------------
# 结算
# ------------------------------------------------------------------
def _check_winner(self) -> Optional[Faction]:
"""判定当前是否已分出胜负:狼人全灭则好人胜;狼人数≥好人数则狼人胜;
否则返回 None(继续游戏)。"""
w = len(self.wolves(alive_only=True))
g = len([p for p in self.alive() if p.role != Role.WEREWOLF])
if w == 0 and g == 0:
return Faction.UNDECIDED
if w == 0:
return Faction.GOOD
if w >= g: # 狼人数不少于好人数 → 狼人胜(屠边简化规则)
return Faction.WEREWOLF
return None
def run(self) -> Faction:
"""跑完整一局,返回获胜阵营。"""
self.assign_identities()
winner = None
while winner is None and self.round_no < self.max_rounds:
self.round_no += 1
for player in self.players:
player.current_round = self.round_no
deaths = self.night()
winner = self._check_winner()
if winner:
break
self.day(deaths)
self.completed_rounds += 1
winner = self._check_winner()
if winner is None:
# A safety round limit is not a game-rule victory condition. Report an
# unresolved game honestly instead of awarding Good an invented win.
winner = self._check_winner() or Faction.UNDECIDED
self._announce(winner)
return winner
def _announce(self, winner: Faction):
self.phase = "结算"
print("\n" + "#" * 78)
print("【游戏结束 · 结算】")
alive = [f"{p.name}({p.role.value})" for p in self.alive()]
print(f" 存活玩家:{''.join(alive) if alive else ''}")
if winner == Faction.UNDECIDED:
print(" >>> 本局未决:达到安全回合上限时仍无阵营满足胜利条件 <<<")
else:
print(f" >>> 获胜阵营:{winner.value} <<<")
print("#" * 78)
if self.tts and hasattr(self.tts, "say"):
message = (
"游戏结束,本局未在回合上限内分出胜负"
if winner == Faction.UNDECIDED
else f"游戏结束,获胜阵营是{winner.value}"
)
self.tts.say("judge", message, self.round_no, allow_barge_in=False)
+228
View File
@@ -0,0 +1,228 @@
# -*- coding: utf-8 -*-
"""Real human player and low-latency microphone/ASR/TTS voice session."""
from __future__ import annotations
import json
import os
import re
import subprocess
import tempfile
import time
import wave
from pathlib import Path
from typing import List, Optional
from .agent import PlayerAgent
from .roles import Role
class LiveVoiceSession:
"""Cascaded real-time voice transport with VAD and optional barge-in.
AI speech is synthesized with the configured OpenAI TTS endpoint and played
immediately. Human speech is captured from the microphone until end-of-speech
silence and sent to the configured ASR endpoint. During public AI speech the
microphone can terminate playback and turn the interruption into a public utterance.
Headphones are recommended so speaker echo is not mistaken for barge-in.
"""
def __init__(self, out_dir: str, *, allow_interruptions: bool = True):
from openai import OpenAI
if not os.getenv("OPENAI_API_KEY"):
raise RuntimeError("真人实时语音需要可用的 OPENAI_API_KEYASR/TTS 不走 OpenRouter")
self.client = OpenAI(api_key=os.environ["OPENAI_API_KEY"], timeout=60, max_retries=1)
self.out_dir = Path(out_dir)
self.out_dir.mkdir(parents=True, exist_ok=True)
self.allow_interruptions = allow_interruptions
self.sample_rate = int(os.getenv("VOICE_SAMPLE_RATE", "16000"))
self.threshold = float(os.getenv("VOICE_RMS_THRESHOLD", "0.025"))
self.silence_seconds = float(os.getenv("VOICE_SILENCE_SECONDS", "0.8"))
self.max_utterance = float(os.getenv("VOICE_MAX_UTTERANCE_SECONDS", "25"))
self.player = os.getenv("AUDIO_PLAYER", "afplay")
self.events = []
self._sequence = 0
def _event(self, type_: str, **data):
self._sequence += 1
self.events.append({
"sequence": self._sequence,
"monotonic": time.monotonic(),
"wall_time": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"type": type_,
**data,
})
(self.out_dir / "voice_trace.json").write_text(
json.dumps(self.events, ensure_ascii=False, indent=2), encoding="utf-8"
)
def _tts(self, speaker: str, text: str, round_no: int) -> Path:
self._sequence += 1
path = self.out_dir / f"r{round_no}_{speaker}_{self._sequence}.mp3"
started = time.monotonic()
response = self.client.audio.speech.create(
model=os.getenv("OPENAI_TTS_MODEL", "tts-1"),
voice=os.getenv("OPENAI_TTS_VOICE", "coral"),
input=text,
)
response.stream_to_file(path)
self._event("tts_ready", speaker=speaker, latency_seconds=round(time.monotonic() - started, 3), file=str(path))
return path
def _transcribe(self, wav_path: Path, *, kind: str) -> str:
started = time.monotonic()
with wav_path.open("rb") as audio:
response = self.client.audio.transcriptions.create(
model=os.getenv("OPENAI_ASR_MODEL", "whisper-1"),
file=audio,
language=os.getenv("VOICE_LANGUAGE", "zh"),
)
text = response.text.strip()
self._event(kind, latency_seconds=round(time.monotonic() - started, 3), transcript=text)
return text
def _write_wav(self, frames, path: Path):
import numpy as np
pcm = (np.concatenate(frames).clip(-1, 1) * 32767).astype("<i2")
with wave.open(str(path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(self.sample_rate)
wav.writeframes(pcm.tobytes())
def listen(self, prompt: str = "") -> str:
import numpy as np
import sounddevice as sd
if prompt:
self.say("judge", prompt, 0, allow_barge_in=False)
print(" [真人麦克风] 请发言;句末静音后自动识别……")
block = 1024
frames, heard, silent = [], False, 0
silence_blocks = max(1, int(self.silence_seconds * self.sample_rate / block))
deadline = time.monotonic() + self.max_utterance
with sd.InputStream(samplerate=self.sample_rate, channels=1, dtype="float32", blocksize=block) as stream:
while time.monotonic() < deadline:
data, _ = stream.read(block)
mono = data[:, 0].copy()
frames.append(mono)
rms = float(np.sqrt(np.mean(np.square(mono))))
if rms >= self.threshold:
heard, silent = True, 0
elif heard:
silent += 1
if silent >= silence_blocks:
break
if not heard:
raise TimeoutError("规定时间内没有检测到真人语音")
path = Path(tempfile.mkstemp(suffix=".wav")[1])
try:
self._write_wav(frames, path)
text = self._transcribe(path, kind="human_asr")
print(f" [ASR] 真人:{text}")
return text
finally:
path.unlink(missing_ok=True)
def say(self, speaker: str, text: str, round_no: int, *, allow_barge_in: bool = False) -> Optional[str]:
path = self._tts(speaker, text, round_no)
if not (allow_barge_in and self.allow_interruptions):
subprocess.run([self.player, str(path)], check=False)
return None
# Real barge-in: monitor microphone during playback, cancel output on speech,
# then hand the captured utterance to ASR as an interruption turn.
import numpy as np
import sounddevice as sd
block = 1024
proc = subprocess.Popen([self.player, str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
frames = []
consecutive = 0
with sd.InputStream(samplerate=self.sample_rate, channels=1, dtype="float32", blocksize=block) as stream:
while proc.poll() is None:
data, _ = stream.read(block)
mono = data[:, 0].copy()
rms = float(np.sqrt(np.mean(np.square(mono))))
consecutive = consecutive + 1 if rms >= self.threshold else 0
if consecutive >= 2:
frames.extend([mono])
proc.terminate()
self._event("barge_in", interrupted_speaker=speaker)
break
if not frames:
return None
silent = 0
silence_blocks = max(1, int(self.silence_seconds * self.sample_rate / block))
deadline = time.monotonic() + self.max_utterance
while time.monotonic() < deadline:
data, _ = stream.read(block)
mono = data[:, 0].copy()
frames.append(mono)
rms = float(np.sqrt(np.mean(np.square(mono))))
silent = silent + 1 if rms < self.threshold else 0
if silent >= silence_blocks:
break
wav_path = Path(tempfile.mkstemp(suffix=".wav")[1])
try:
self._write_wav(frames, wav_path)
return self._transcribe(wav_path, kind="interruption_asr")
finally:
wav_path.unlink(missing_ok=True)
# Judge expects a TTS-like object with synth().
def synth(self, speaker: str, text: str, round_no: int):
return self.say(speaker, text, round_no, allow_barge_in=True)
class HumanPlayerAgent(PlayerAgent):
"""A real human seat that obeys the same private-memory boundary as AI seats."""
def __init__(self, name: str, role: Role, voice: LiveVoiceSession):
super().__init__(name, role, offline=True)
self.voice = voice
self.is_human = True
self.is_user = True
@staticmethod
def _explicit_none(text: str) -> bool:
folded = text.casefold()
return bool(
any(word in folded for word in ("放弃", "不用", "弃票"))
or re.search(r"\b(?:none|abstain|skip)\b", folded)
)
@staticmethod
def _spoken_target(text: str, candidates: List[str], allow_none: bool) -> Optional[str]:
if allow_none and HumanPlayerAgent._explicit_none(text):
return None
match = re.search(r"(?:P|player\s*|玩家\s*|[投查验救毒刀]\s*)(\d+)", text, re.I)
if match and f"P{int(match.group(1))}" in candidates:
return f"P{int(match.group(1))}"
chinese = {"": 1, "": 2, "": 3, "": 4, "": 5, "": 6, "": 7, "": 8}
match = re.search(r"([一二三四五六七八])号", text)
if match and f"P{chinese[match.group(1)]}" in candidates:
return f"P{chinese[match.group(1)]}"
english = {
"one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
"six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
}
match = re.search(
r"(?:player|seat)\s+(one|two|three|four|five|six|seven|eight|nine|ten)\b",
text,
re.I,
)
if match and f"P{english[match.group(1).casefold()]}" in candidates:
return f"P{english[match.group(1).casefold()]}"
return PlayerAgent._parse_target(text, candidates, allow_none)
def speak(self, players: List[str]) -> str:
return self.voice.listen("现在轮到您公开发言。请结合已知信息说出您的分析。")
def choose_target(self, prompt: str, candidates: List[str], players: List[str], allow_none: bool = False):
answer = self.voice.listen(f"{prompt} 候选:{''.join(candidates)}。请说出玩家编号。")
return self._spoken_target(answer, candidates, allow_none)
def vote(self, candidates: List[str], players: List[str]):
answer = self.voice.listen(f"现在投票放逐。候选:{''.join(candidates)}。请说出玩家编号或弃票。")
return self._spoken_target(answer, candidates, True)
@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
"""角色定义、阵营、以及每个角色的策略提示词。
狼人杀的核心是**信息不对称**:不同角色天生知道不同的信息,且拥有不同的
夜间行动能力。这里集中定义角色的元数据与提示词,供 agent.py 构造每个玩家
Agent 的 system prompt。
"""
from enum import Enum
class Role(str, Enum):
"""游戏中的四种角色。"""
WEREWOLF = "狼人"
SEER = "预言家"
WITCH = "女巫"
VILLAGER = "村民"
class Faction(str, Enum):
"""两大阵营。预言家、女巫、村民都属于好人阵营(好人 = 神职 + 平民)。"""
WEREWOLF = "狼人阵营"
GOOD = "好人阵营"
UNDECIDED = "未决"
# 角色 -> 阵营
ROLE_FACTION = {
Role.WEREWOLF: Faction.WEREWOLF,
Role.SEER: Faction.GOOD,
Role.WITCH: Faction.GOOD,
Role.VILLAGER: Faction.GOOD,
}
# 各角色的策略提示词(对应书中「Agent 推理与策略」小节)。
ROLE_STRATEGY = {
Role.WEREWOLF: (
"你是狼人。你的目标是隐藏身份,误导好人,最终让狼人数量不少于好人。\n"
"策略:像普通村民一样发言,可以表达对某些玩家的合理怀疑,但不要过于激进以免暴露。\n"
"如果有预言家跳出来说验到你是狼人,你可以考虑反咬对方是悍跳的假预言家。\n"
"投票时尽量跟大多数好人的票,避免成为异类。绝不要主动暴露自己或队友是狼人。"
),
Role.SEER: (
"你是预言家(好人阵营)。每晚你可以查验一名玩家的真实阵营(好人/狼人)。\n"
"策略:在合适时机(通常查到狼人或局势危急时)跳出来公布身份与验人信息,带领好人。\n"
"若有人悍跳预言家,请对比双方验人信息,指出对方逻辑中的矛盾或不合理之处。\n"
"你的查验结果是你独有的关键信息,只有你自己知道,请善用它引导投票。"
),
Role.WITCH: (
"你是女巫(好人阵营)。你有一瓶解药和一瓶毒药,各只能用一次。\n"
"解药可以在夜晚救活被狼人刀的玩家;毒药可以在夜晚毒死一名你怀疑的玩家。\n"
"策略:解药通常留给关键好人(如预言家)或前期不要浪费;毒药在你较确定某人是狼时使用。\n"
"你的用药信息只有你自己知道,白天发言时注意保护自己,不要轻易暴露女巫身份。"
),
Role.VILLAGER: (
"你是村民(好人阵营),没有夜间技能,只能靠逻辑推理找出狼人。\n"
"策略:分析每个玩家的发言是否自洽,留意急于带节奏、模糊身份、频繁改变立场的玩家。\n"
"关注投票行为——狼人往往集中票数投给对好人威胁最大的人。\n"
"将身份声明与自己确定知道的事实比较:正确说出你的阵营是支持证据(但不是绝对证明),"
"矛盾则是反证。不要仅因某人公开神职身份就怀疑他;在没有对跳或矛盾时,不要只凭"
"‘过早跳身份’放逐唯一的预言家声明者。\n"
"不要随机怀疑,每一个推理都应基于具体的发言和投票事实。"
),
}
def faction_of(role: Role) -> Faction:
"""返回某个角色所属的阵营。"""
return ROLE_FACTION[role]
@@ -0,0 +1,458 @@
# -*- 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",
)
@@ -0,0 +1,164 @@
"""Post-game, evidence-based acceptance audit for role strategy."""
from __future__ import annotations
import json
import os
REQUIRED_CRITERIA = (
"werewolf_concealment",
"seer_timing_and_evidence",
"villager_logical_reasoning",
"role_consistency",
)
VALID_STATUSES = {"pass", "fail", "insufficient"}
def validate_strategy_result(result):
"""Turn a model grade into a strict, machine-checkable acceptance record.
This function is deliberately fail-closed. Provider SDKs occasionally return
``None`` or a scalar when a JSON-mode response is truncated; attempting to
mutate those values used to raise an incidental ``AttributeError`` and abort
report generation. Returning a normal, serialisable rejection keeps the
acceptance pipeline auditable and prevents malformed model output from being
mistaken for a passing grade.
"""
errors = []
if not isinstance(result, dict):
return {
"model_overall_pass_claim": None,
"schema_valid": False,
"validation_errors": ["strategy result must be a JSON object"],
"overall_pass": False,
}
criteria = result.get("criteria")
if not isinstance(criteria, dict):
criteria = {}
errors.append("criteria must be an object")
for name in REQUIRED_CRITERIA:
item = criteria.get(name)
if not isinstance(item, dict):
errors.append(f"missing criterion object: {name}")
continue
status = item.get("status")
if status not in VALID_STATUSES:
errors.append(f"invalid status for {name}: {status!r}")
evidence = item.get("evidence")
if not isinstance(evidence, str) or not evidence.strip():
errors.append(f"criterion lacks quoted evidence: {name}")
claimed = result.get("overall_pass")
computed_pass = not errors and all(
criteria[name].get("status") == "pass" for name in REQUIRED_CRITERIA
)
result["model_overall_pass_claim"] = claimed
result["schema_valid"] = not errors
result["validation_errors"] = errors
result["overall_pass"] = computed_pass
return result
def strategy_acceptance_passes(result):
return bool(
isinstance(result, dict)
and result.get("schema_valid") is True
and result.get("overall_pass") is True
)
def _backends():
from openai import OpenAI
from .agent import _to_openrouter_model
options = {"timeout": float(os.getenv("WEREWOLF_LLM_TIMEOUT", "45")), "max_retries": 1}
out = []
if os.getenv("ARK_API_KEY"):
out.append((OpenAI(api_key=os.environ["ARK_API_KEY"], base_url="https://ark.cn-beijing.volces.com/api/v3", **options), os.getenv("ARK_MODEL", "doubao-seed-1-6-250615"), "ark"))
if os.getenv("MOONSHOT_API_KEY"):
out.append((OpenAI(api_key=os.environ["MOONSHOT_API_KEY"], base_url="https://api.moonshot.cn/v1", **options), os.getenv("MOONSHOT_MODEL", "kimi-k3"), "moonshot"))
if os.getenv("OPENAI_API_KEY"):
out.append((OpenAI(api_key=os.environ["OPENAI_API_KEY"], base_url=os.getenv("OPENAI_BASE_URL") or None, **options), os.getenv("OPENAI_MODEL", "gpt-4.1-mini"), "openai"))
if os.getenv("OPENROUTER_API_KEY"):
model = _to_openrouter_model(os.getenv("OPENAI_MODEL", "gpt-5.6-luna"))
out.append((OpenAI(
api_key=os.environ["OPENROUTER_API_KEY"],
base_url="https://openrouter.ai/api/v1",
**options,
), model, "openrouter"))
return out
def evaluate_strategy(judge):
roles = {p.name: p.role.value for p in judge.players}
payload = {
"roles": roles,
"actions": judge.action_history,
"criteria": {
"werewolf_concealment": "Wolf public speech plausibly hides identity and does not expose teammates.",
"seer_timing_and_evidence": "Seer reveals investigation at an appropriate time and reports only known results.",
"villager_logical_reasoning": "Villager suspicion cites public speech/voting behavior rather than random guesses.",
"role_consistency": "AI actions and public speech are consistent with role capabilities and goals.",
},
"instruction": (
"Grade each criterion using only status pass/fail/insufficient and short, "
"quoted action evidence. Do not infer unlogged behavior. Return exactly one "
"JSON object shaped as {\"criteria\": {\"werewolf_concealment\": "
"{\"status\": \"pass|fail|insufficient\", \"evidence\": \"quote\"}, "
"\"seer_timing_and_evidence\": {...}, \"villager_logical_reasoning\": "
"{...}, \"role_consistency\": {...}}, \"overall_pass\": true|false}. "
"Use the key status, never grade, and include all four named criteria."
),
}
last = None
attempts = []
for client, model, provider in _backends():
try:
kwargs = dict(
model=model,
messages=[{"role": "user", "content": json.dumps(payload, ensure_ascii=False)}],
response_format={"type": "json_object"},
)
if "kimi-k3" in model:
kwargs.update(temperature=1, max_tokens=4096)
response = client.chat.completions.create(**kwargs)
content = response.choices[0].message.content or "{}"
raw_result = json.loads(content)
# Validation annotates its input. Keep a distinct credential-free raw
# result in the attempt record so attaching attempts to the accepted
# result cannot create a self-referential JSON structure.
result = json.loads(content)
result["provider"] = provider
result["model"] = model
checked = validate_strategy_result(result)
usage = getattr(response, "usage", None)
usage = usage.model_dump() if hasattr(usage, "model_dump") else usage
attempts.append({
"provider": provider,
"model": model,
"response_id": getattr(response, "id", None),
"provider_reported_model": getattr(response, "model", None),
"usage": usage,
"schema_valid": checked["schema_valid"],
"validation_errors": checked["validation_errors"],
"raw_result": raw_result,
})
if checked["schema_valid"]:
checked["judge_attempts"] = attempts
return checked
last = ValueError(
f"{provider} strategy judge returned an invalid schema: "
+ "; ".join(checked["validation_errors"])
)
print(f"[策略审计] {provider} 模式无效,尝试下一端点")
except Exception as exc:
last = exc
attempts.append({
"provider": provider,
"model": model,
"error_type": type(exc).__name__,
"error": str(exc)[:500],
})
print(f"[策略审计] {provider} 失败:{type(exc).__name__},尝试下一端点")
failure = RuntimeError("没有可用的真实 LLM 端点完成有效的策略验收")
failure.judge_attempts = attempts
raise failure from last
+39
View File
@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
"""可选的语音合成(TTS)——把玩家公开发言合成为语音。
语音是本实验的**可选增强**,不是跑通的必需:默认文本模式即可完整跑完一局并
验证信息隔离。加 --voice 时才启用,用 OpenAI tts-1 把每条公开发言合成 mp3,
存到 audio/ 目录;在 macOS 上可用 afplay 顺带播放(--play)。
"""
import os
import subprocess
from .agent import get_client
# 给不同玩家分配不同音色,便于区分
_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer", "coral"]
class TTS:
def __init__(self, out_dir: str, play: bool = False):
self.out_dir = out_dir
self.play = play
os.makedirs(out_dir, exist_ok=True)
self._idx = 0
def synth(self, speaker: str, text: str, round_no: int):
voice = _VOICES[(int(speaker.lstrip("P")) - 1) % len(_VOICES)]
path = os.path.join(self.out_dir, f"r{round_no}_{speaker}_{self._idx}.mp3")
self._idx += 1
try:
resp = get_client().audio.speech.create(
model="tts-1", voice=voice, input=text)
resp.stream_to_file(path)
print(f" [TTS] {speaker} 发言已合成语音(音色 {voice})→ {path}")
if self.play:
# macOS 自带 afplay;其它平台请自行改播放器
subprocess.run(["afplay", path], check=False)
except Exception as e:
print(f" [TTS] 合成失败(不影响游戏进行):{e}")