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
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:
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""实验 10-6:语音狼人杀 Agent 系统(多 Agent + 信息权限控制 + 法官编排)。"""
|
||||
@@ -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) 否则直连 OpenAI;3) 最后才回退 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 消息并调用 LLM;user 消息里只拼接本 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)
|
||||
@@ -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)
|
||||
@@ -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_KEY(ASR/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
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user