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,9 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
audio/
|
||||
artifacts/
|
||||
*.mp3
|
||||
*.wav
|
||||
@@ -0,0 +1,128 @@
|
||||
# Experiment 10-6 · Voice Werewolf with a real-LLM user simulator
|
||||
|
||||
The experiment supports two first-class user seats: a consenting live human, or an independent real-LLM user simulator for unattended end-to-end testing. Both use the same seeded role shuffle and protected private memory in a 6–8 seat game with two Werewolves, one Seer, one Witch, and Villagers. The code-driven Judge—not an LLM—owns the state machine, night/day/vote phases, skill inventory, deaths, and deterministic win rule.
|
||||
|
||||
## Automated user simulator
|
||||
|
||||
`python demo.py --simulate-user` does not insert canned answers or turn the user into an ordinary omniscient AI player:
|
||||
|
||||
1. The simulator receives only the private and public memory authorized for its randomized seat.
|
||||
2. A separately configurable real LLM must call the sole legal tool for the turn: `speak_publicly` or `choose_player`.
|
||||
3. The chosen utterance is synthesized into a real waveform. The automatic provider order is OpenAI Audio, local `espeak` (or macOS `say`) plus OpenRouter native-audio ASR, then local synthesis plus Gemini ASR.
|
||||
4. The game consumes only the real ASR transcript. It never receives the LLM's pre-audio utterance directly.
|
||||
5. For skills and votes, the parsed ASR action must exactly equal the tool-selected action; a mismatch fails closed and is retained as `simulator_action_mismatch`.
|
||||
|
||||
The OpenRouter speech path records response IDs, provider-reported models, token usage (including nonzero audio tokens), audio hashes, transcripts, and latency without retaining credentials. The local synthesizer is a real audio component rather than an API; both the user reasoning call and audio transcription call are real external model APIs.
|
||||
|
||||
## Live two-way voice
|
||||
|
||||
`python demo.py` is no longer an all-AI text demonstration. It creates a real human seat and a `LiveVoiceSession`:
|
||||
|
||||
1. AI/Judge speech is sent to a real OpenAI TTS endpoint and played immediately.
|
||||
2. Human speech is captured from the microphone with energy VAD and end-of-speech silence detection.
|
||||
3. Captured WAV audio is sent to a real OpenAI ASR endpoint.
|
||||
4. Spoken player numbers drive human night skills and voting; daytime speech is broadcast to every Agent context.
|
||||
5. During public AI speech, microphone activity cancels playback, transcribes the barge-in, and records it as a public interruption turn. Headphones are recommended to prevent acoustic echo from triggering the detector.
|
||||
|
||||
Audio files and a timestamped `voice_trace.json` record TTS, ASR latency, and interruptions. `--no-interruptions` disables barge-in for noisy rooms.
|
||||
|
||||
## Information asymmetry and strategy acceptance
|
||||
|
||||
Every player owns a separate `memory`. The Judge has only three delivery capabilities: public broadcast, single-player private send, and Werewolf-team send. The same boundary applies to both kinds of user seat. The post-game audit proves Werewolf teammates never enter good-player contexts, Seer investigations enter only the Seer context, and all public events reach everyone.
|
||||
|
||||
The game also records role-labelled actions and runs a real LLM post-game acceptance judge over four explicit criteria: Werewolf concealment, Seer reveal timing/evidence, Villager evidence-based reasoning, and general role consistency. It quotes logged evidence and may return `insufficient`; it cannot substitute an Agent's unsupported claim for observed actions.
|
||||
The returned JSON is schema-checked: all four named criteria need a valid
|
||||
`pass|fail|insufficient` status, and every passing criterion needs evidence. A bare
|
||||
model claim of `overall_pass: true` cannot pass the gate.
|
||||
|
||||
`artifacts/acceptance_report.json` records:
|
||||
|
||||
- exactly one user seat, its kind, and its randomized role;
|
||||
- exact role counts and player count;
|
||||
- completed night–day–vote cycles and deterministic winner;
|
||||
- privacy audit result;
|
||||
- real strategy audit;
|
||||
- whether real LLM tools, TTS, ASR, and action agreement occurred, plus barge-in count for a human run.
|
||||
|
||||
The end-to-end result requires 6–8 players, the exact role mix, one protected user seat, privacy pass, observed ASR + TTS, a rule-based winner, and—on the simulator path—real tool calls and matching audio-round-trip actions. The stricter experiment-wide result additionally requires at least three complete cycles and all four strategy criteria in the same run.
|
||||
The Judge increments the cycle counter only after night, day discussion, and voting all
|
||||
finish. Reaching the safety round limit without a rule-based winner is reported as
|
||||
`未决`, not silently awarded to either faction, and therefore cannot pass acceptance.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# From the repository root: use the shared Chapter 10 environment
|
||||
uv sync --locked --python 3.12 --extra ch10
|
||||
|
||||
# Activate it before changing directories:
|
||||
# macOS/Linux:
|
||||
source .venv/bin/activate
|
||||
# Windows PowerShell: .\.venv\Scripts\Activate.ps1
|
||||
# Windows cmd: .venv\Scripts\activate.bat
|
||||
|
||||
# pip fallback when uv is not installed:
|
||||
# python -m pip install -e ".[ch10]"
|
||||
|
||||
cd chapter10/voice-werewolf
|
||||
|
||||
# Single-project compatibility path, still supported during migration:
|
||||
# python -m pip install -r requirements.txt
|
||||
|
||||
cp env.example .env
|
||||
python demo.py --simulate-user \
|
||||
--model google/gemini-2.5-flash \
|
||||
--simulator-model anthropic/claude-sonnet-4 \
|
||||
--simulator-speech-provider openrouter-system # unattended real-API E2E
|
||||
python demo.py --confirm-human-consent # 1 consenting human + 6 real LLM Agents
|
||||
python demo.py --confirm-human-consent --human-seat 3 # human is P3; role remains randomized
|
||||
```
|
||||
|
||||
The simulator can use `OPENROUTER_API_KEY` alone when `espeak` and `ffmpeg` are installed. It can instead use a funded `OPENAI_API_KEY`, or `GEMINI_API_KEY` with local synthesis. AI reasoning and the post-game strategy audit can also use ARK or Moonshot via their OpenAI-compatible endpoints.
|
||||
The live path refuses to open the microphone unless `--confirm-human-consent` is present.
|
||||
|
||||
Text-only and deterministic paths remain supplemental:
|
||||
|
||||
```bash
|
||||
python demo.py --ai-only # real LLM, all-AI text diagnostic
|
||||
python demo.py --offline # deterministic CI/privacy supplement
|
||||
pytest -q
|
||||
```
|
||||
|
||||
## Real validation results (2026-08-01 through 2026-08-03)
|
||||
|
||||
The retained [`validation/runs/`](validation/runs/) evidence contains four formal eight-seat games and an independent validation file for each. The independent validator supersedes the run's embedded status when it finds a boundary defect. Credential scans over reports, validations, and logs found zero hits.
|
||||
|
||||
- `exp10-6-simulated-user-openrouter-20260801`: the embedded report claimed action agreement and all four strategy criteria passed, but strict revalidation correctly rejects its abstention because ASR returned `P1 is not`, not an explicit abstention.
|
||||
- `...-v2`: the unaffected formal E2E result. It completed three full cycles with two user tool/audio/ASR actions, unique response IDs and nonzero audio-token receipts, information isolation, and a rule-based winner. The independent strategy judge failed Villager reasoning because the simulated Villager voted out the uncontested Seer.
|
||||
- `...-v3`: used `anthropic/claude-sonnet-4` for the user and retained four tool/audio/ASR actions. Strict revalidation rejects two ambiguous abstentions, and the strategy judge also caught a Werewolf fabricating a public event.
|
||||
|
||||
The completed 2026-08-03 campaign is
|
||||
[`exp10-6-simulated-user-openrouter-20260803-v11`](validation/runs/exp10-6-simulated-user-openrouter-20260803-v11/acceptance_report.json).
|
||||
In one seed-2 game it completed three night/day/vote cycles, preserved information
|
||||
isolation, reached a rule-determined good-faction win, and passed all four strategy
|
||||
criteria. The randomized P1 Villager performed six real LLM tool calls, six matching
|
||||
speech/ASR round trips, and three public votes across the full game. The report retains
|
||||
13 unique response IDs across simulator, ASR, and strategy-judge calls; 1,650 input
|
||||
audio tokens; 27 positive-byte TTS events; action history; provider-reported models;
|
||||
usage; audio hashes; and judge-attempt provenance. The
|
||||
[`independent validation`](validation/runs/exp10-6-simulated-user-openrouter-20260803-v11/independent_validation.json)
|
||||
rechecked all six tool/audio/action boundaries against report SHA-256
|
||||
`655b4eed74ad4f4d741dc89f97c86a68c547e4f82d1dea9fea71449dfef797e9`.
|
||||
|
||||
The parser still fails closed unless an abstention transcript explicitly contains
|
||||
`abstain`, `skip`, `none`, or the supported Chinese equivalents; the synthetic
|
||||
utterance is the real-audio-probed phrase “I choose to abstain.” A schema-invalid
|
||||
strategy grade is now retained as an attempt and the next real endpoint is tried,
|
||||
rather than accepting or discarding malformed evidence. Earlier negative runs remain
|
||||
useful regression evidence, but stale gates from different games are never combined.
|
||||
A real human microphone session is optional manual coverage for VAD and barge-in, not
|
||||
a blocker for automated system E2E.
|
||||
|
||||
---
|
||||
|
||||
## 中文说明
|
||||
|
||||
系统现在有两条正式用户路径:授权真人麦克风,以及 `--simulate-user` 独立真实 LLM 用户模拟器。模拟器只读本席上下文,必须调用发言/选人工具;工具表达先生成真实音频,再由真实 ASR 转写,游戏只消费转写结果,选人不一致时失败关闭。真人路径继续覆盖 VAD、播放与打断。
|
||||
|
||||
2026-08-01 的严格复核否决了两个把误转写当成弃权的早期运行,并据此加固了解析器。2026-08-03 的 v11 在同一局内完成 3 个完整循环、6 次真实工具→语音→ASR 回环、信息隔离、规则胜负和四项策略验收,严格总体状态为 `pass`。报告保留 13 个唯一响应 ID、1,650 个音频输入 token、27 个非空 TTS 事件、动作历史及裁判尝试溯源,独立验证再次核对 6/6 音频动作边界。`--ai-only` 与 `--offline` 只是补充诊断。
|
||||
@@ -0,0 +1,496 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""实验 10-6:1 名真人通过实时 ASR/TTS 与 5-7 个 AI Agent 玩狼人杀。
|
||||
|
||||
配套《深入理解 AI Agent》第 10 章「实验 10-6:语音狼人杀 Agent 系统」。
|
||||
|
||||
本 demo 演示三件事(对应书中架构设计):
|
||||
1. **多 Agent**:每个玩家 = 一个独立 LLM Agent(OpenAI,默认 gpt-5.6-luna)。
|
||||
2. **信息权限控制**:法官按角色把信息投递进各 Agent 的私有上下文——狼人才知道
|
||||
队友、预言家才知道查验结果、公开发言进所有人。游戏后打印审计表 + 自动校验,
|
||||
客观证明信息隔离正确。
|
||||
3. **法官编排**:确定性法官编排夜晚(刀/验/用药)→ 白天(死讯/发言/投票)→ 结算。
|
||||
|
||||
默认路径是双向真人语音验收;全 AI 文本/离线模式只用于补充诊断与 CI。
|
||||
|
||||
用法:
|
||||
export OPENAI_API_KEY=your-openai-api-key
|
||||
python demo.py # 文本模式跑完整一局(LLM 决策,默认)
|
||||
python demo.py --offline # 离线模式:规则决策,零成本、可复现,无需 API Key
|
||||
python demo.py --seed 7 # 换一局身份分布
|
||||
python demo.py --players 9 --wolves 3 # 自定义人数与狼人数
|
||||
python demo.py --voice # 额外把公开发言合成语音到 audio/
|
||||
python demo.py --voice --play # 合成并播放(macOS afplay)
|
||||
python demo.py --offline --log game.log # 把完整对局日志另存一份到文件
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class _Tee:
|
||||
"""把写入同时分发到多个流(用于 --log:既打印到终端又落盘到文件)。"""
|
||||
|
||||
def __init__(self, *streams):
|
||||
self.streams = streams
|
||||
|
||||
def write(self, data):
|
||||
for s in self.streams:
|
||||
s.write(data)
|
||||
|
||||
def flush(self):
|
||||
for s in self.streams:
|
||||
s.flush()
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv() # 若存在 .env 则加载(可选)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from werewolf.game import Judge, create_players # noqa: E402 - .env must load first
|
||||
from werewolf.roles import Faction, Role # noqa: E402 - .env must load first
|
||||
|
||||
|
||||
def verify_isolation(judge: Judge):
|
||||
"""自动校验信息隔离是否正确,并打印证据。返回是否全部通过。"""
|
||||
print("\n" + "=" * 78)
|
||||
print("信息隔离自动校验(证明每条敏感信息只进了它该进的上下文)")
|
||||
print("=" * 78)
|
||||
ok = True
|
||||
|
||||
wolves = judge.wolves()
|
||||
wolf_names = {w.name for w in wolves}
|
||||
non_wolves = [p for p in judge.players if p.role != Role.WEREWOLF]
|
||||
seer = next((p for p in judge.players if p.role == Role.SEER), None)
|
||||
|
||||
# 证据 1:狼人队友身份只在狼人上下文里
|
||||
team_line_marker = "狼人阵营的玩家是"
|
||||
wolves_have = all(any(team_line_marker in m for m in w.memory) for w in wolves)
|
||||
nonwolves_have = any(any(team_line_marker in m for m in p.memory) for p in non_wolves)
|
||||
check1 = wolves_have and not nonwolves_have
|
||||
ok &= check1
|
||||
print(f"\n[校验1] 『狼人队友身份』只进狼人上下文:{'通过 ✓' if check1 else '失败 ✗'}")
|
||||
print(f" - 每个狼人上下文都含队友身份?{wolves_have}")
|
||||
print(f" - 存在非狼人上下文含队友身份?{nonwolves_have}(应为 False)")
|
||||
|
||||
# 证据 2:预言家查验结果只在预言家本人上下文里
|
||||
if seer:
|
||||
seer_marker = "你查验了"
|
||||
seer_has = any(seer_marker in m for m in seer.memory)
|
||||
others_have = any(any(seer_marker in m for m in p.memory)
|
||||
for p in judge.players if p.name != seer.name)
|
||||
check2 = seer_has and not others_have
|
||||
ok &= check2
|
||||
print(f"\n[校验2] 『预言家查验结果』只进预言家({seer.name})上下文:{'通过 ✓' if check2 else '失败 ✗'}")
|
||||
print(f" - 预言家上下文含查验结果?{seer_has}")
|
||||
print(f" - 存在其他玩家上下文含查验结果?{others_have}(应为 False)")
|
||||
|
||||
# 证据 3:审计日志里每条记录的 visible_to 与类别相符
|
||||
def cat_visible(cat):
|
||||
return [set(r.visible_to) for r in judge.audit.records if r.category == cat]
|
||||
check3 = all(v == wolf_names for v in cat_visible("狼人队友身份")) and \
|
||||
all(v == wolf_names for v in cat_visible("狼人夜间共识"))
|
||||
ok &= check3
|
||||
print(f"\n[校验3] 审计日志中狼人专属信息的可见集合 == 狼人集合 {sorted(wolf_names)}:"
|
||||
f"{'通过 ✓' if check3 else '失败 ✗'}")
|
||||
|
||||
check4 = all(set(r.visible_to) == set(judge.names)
|
||||
for r in judge.audit.records if r.category.startswith("公开"))
|
||||
ok &= check4
|
||||
print(f"[校验4] 审计日志中所有『公开-*』信息可见集合 == 全体玩家:"
|
||||
f"{'通过 ✓' if check4 else '失败 ✗'}")
|
||||
|
||||
# 对照展示:一个狼人 vs 一个村民的完整私有上下文
|
||||
villager = next((p for p in judge.players if p.role == Role.VILLAGER), None)
|
||||
a_wolf = wolves[0] if wolves else None
|
||||
print("\n—— 对照:同一时刻两名玩家的私有上下文(证明各看各的)——")
|
||||
if a_wolf:
|
||||
print(f"\n【狼人 {a_wolf.name} 的私有上下文】(含队友身份、夜间共识)")
|
||||
for m in a_wolf.memory:
|
||||
print(f" · {m}")
|
||||
if villager:
|
||||
print(f"\n【村民 {villager.name} 的私有上下文】(不含任何他人身份/查验结果)")
|
||||
for m in villager.memory:
|
||||
print(f" · {m}")
|
||||
if seer:
|
||||
print(f"\n【预言家 {seer.name} 的私有上下文】(含独享的查验结果)")
|
||||
for m in seer.memory:
|
||||
print(f" · {m}")
|
||||
|
||||
print("\n" + "=" * 78)
|
||||
print(f"信息隔离总校验:{'全部通过 ✓✓✓' if ok else '存在失败 ✗'}")
|
||||
print("=" * 78)
|
||||
return ok
|
||||
|
||||
|
||||
def verify_simulator_trace(events):
|
||||
"""Validate tool/TTS/ASR transactions before an acceptance report is written.
|
||||
|
||||
The independent validator performs the same check on persisted evidence. The
|
||||
in-process copy keeps the embedded report honest when a provider returns a
|
||||
partial or reordered trace (for example, an ASR from a later turn).
|
||||
"""
|
||||
tools = [e for e in events if isinstance(e, dict) and e.get("type") == "simulator_llm_tool"]
|
||||
asr_count = sum(isinstance(e, dict) and e.get("type") == "simulator_asr" for e in events)
|
||||
if not tools or asr_count != len(tools):
|
||||
return False
|
||||
seen_ids = set()
|
||||
for index, tool in ((i, e) for i, e in enumerate(events)
|
||||
if isinstance(e, dict) and e.get("type") == "simulator_llm_tool"):
|
||||
transaction = []
|
||||
for item in events[index + 1:]:
|
||||
if isinstance(item, dict) and item.get("type") == "simulator_llm_tool":
|
||||
break
|
||||
if isinstance(item, dict):
|
||||
transaction.append(item)
|
||||
seat = tool.get("seat")
|
||||
tts = next((e for e in transaction if e.get("type") == "tts_ready"
|
||||
and e.get("speaker") == seat), None)
|
||||
asr = next((e for e in transaction if e.get("type") == "simulator_asr"), None)
|
||||
if tts is None or asr is None:
|
||||
return False
|
||||
if transaction.index(tts) > transaction.index(asr):
|
||||
return False
|
||||
audio_hash = tts.get("audio_sha256")
|
||||
audio_bytes = tts.get("audio_bytes")
|
||||
if (not isinstance(audio_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", audio_hash)
|
||||
or not isinstance(audio_bytes, int) or audio_bytes <= 0
|
||||
or audio_hash != asr.get("source_audio_sha256")):
|
||||
return False
|
||||
request_id = asr.get("request_id")
|
||||
tool_id = tool.get("response_id")
|
||||
if not request_id or not tool_id or request_id in seen_ids or tool_id in seen_ids:
|
||||
return False
|
||||
seen_ids.update((request_id, tool_id))
|
||||
return not any(isinstance(e, dict) and e.get("type") == "simulator_action_mismatch"
|
||||
for e in events)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="实验 10-6:语音狼人杀 Agent 系统 —— 法官编排 + 信息权限控制 + 多 Agent。",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"示例:\n"
|
||||
" python demo.py 7 人局:1 真人实时语音 + 6 AI(验收路径)\n"
|
||||
" python demo.py --simulate-user 真实 LLM + 语音回环的自动端到端路径\n"
|
||||
" python demo.py --offline 仅作为 CI 补充的全 AI 离线模式\n"
|
||||
" python demo.py --ai-only 真实 LLM、无真人音频的补充诊断模式\n"))
|
||||
parser.add_argument("--offline", "--mock", dest="offline", action="store_true",
|
||||
help="补充测试模式:全 AI 规则策略;不满足实验的真人语音验收")
|
||||
parser.add_argument("--ai-only", action="store_true",
|
||||
help="补充诊断模式:真实 LLM 全 AI 文本局;不满足真人语音验收")
|
||||
parser.add_argument("--simulate-user", action="store_true",
|
||||
help="独立 LLM 用户通过工具调用、真实 TTS 音频和 ASR 玩游戏")
|
||||
parser.add_argument("--seed", type=int, default=42,
|
||||
help="随机种子(决定身份分布与离线决策,可复现,默认 42)")
|
||||
parser.add_argument("--players", type=int, default=7,
|
||||
help="玩家总数(默认 7)")
|
||||
parser.add_argument("--wolves", type=int, default=None,
|
||||
help="狼人数量(验收配置固定为 2)")
|
||||
parser.add_argument("--human-seat", type=int, default=1,
|
||||
help="真人座位 Pn(角色仍由 seed 随机分配,默认 P1)")
|
||||
parser.add_argument("--simulated-user-seat", type=int, default=1,
|
||||
help="LLM 用户模拟器座位 Pn(角色仍随机分配,默认 P1)")
|
||||
parser.add_argument("--simulator-model", type=str, default=None,
|
||||
help="用户模拟器模型;默认与其他玩家相同")
|
||||
parser.add_argument("--simulator-speech-provider",
|
||||
default=os.getenv("SIMULATOR_SPEECH_PROVIDER", "auto"),
|
||||
choices=("auto", "openai", "openrouter-system", "gemini-system"),
|
||||
help="模拟用户语音回环供应商")
|
||||
parser.add_argument("--confirm-human-consent", action="store_true",
|
||||
help="确认真人参与者已授权麦克风采集和本局实验;真人路径无此标志会拒绝启动")
|
||||
parser.add_argument("--max-rounds", type=int, default=8, dest="max_rounds",
|
||||
help="昼夜循环的最大回合数上限(默认 8)")
|
||||
parser.add_argument("--model", type=str, default=None,
|
||||
help="覆盖 LLM 模型(默认 gpt-5.6-luna,仅在线模式有效)")
|
||||
parser.add_argument("--voice", action="store_true",
|
||||
help="兼容选项:--ai-only 时也为 AI 发言生成 TTS;真人模式默认启用双向语音")
|
||||
parser.add_argument("--play", action="store_true",
|
||||
help="合成语音后立即播放(macOS afplay;需配合 --voice)")
|
||||
parser.add_argument("--log", type=str, default=None, metavar="PATH",
|
||||
help="把完整对局日志(含审计表)另存一份到指定文件")
|
||||
parser.add_argument("--no-interruptions", action="store_true",
|
||||
help="关闭真人在 AI 播放期间的 barge-in(默认允许实时打断)")
|
||||
parser.add_argument("--report", default="artifacts/acceptance_report.json",
|
||||
help="保存回合、隐私、策略、语音事件验收报告")
|
||||
return parser
|
||||
|
||||
|
||||
def run_game(args):
|
||||
if args.model:
|
||||
os.environ["OPENAI_MODEL"] = args.model
|
||||
import werewolf.agent as agent_module
|
||||
agent_module._MODEL = args.model
|
||||
|
||||
simulated_user = bool(args.simulate_user)
|
||||
live_human = not args.offline and not args.ai_only and not simulated_user
|
||||
mode = (
|
||||
"LLM 用户模拟器 + 真实语音回环"
|
||||
if simulated_user
|
||||
else "真人实时语音 + AI"
|
||||
if live_human
|
||||
else "离线全 AI 补充测试"
|
||||
if args.offline
|
||||
else "在线全 AI 补充诊断"
|
||||
)
|
||||
roles_note = "" if args.wolves is None else f"(狼人数={args.wolves})"
|
||||
print("=" * 78)
|
||||
print("实验 10-6:语音狼人杀 Agent 系统")
|
||||
configured_model = (os.getenv("ARK_MODEL") or os.getenv("MOONSHOT_MODEL") or
|
||||
os.getenv("OPENAI_MODEL") or "provider default")
|
||||
print(f"模式:{mode} | 模型:{configured_model if not args.offline else '—'} | "
|
||||
f"种子:{args.seed} | 音频输入:"
|
||||
f"{'模拟用户真实 ASR' if simulated_user else '真人麦克风' if live_human else '关'}")
|
||||
print(f"配置:{args.players} 人局{roles_note} | 最大回合:{args.max_rounds}")
|
||||
print("=" * 78)
|
||||
|
||||
tts = None
|
||||
if simulated_user:
|
||||
from werewolf.simulator import SimulatedVoiceSession
|
||||
tts = SimulatedVoiceSession(
|
||||
os.path.join(os.path.dirname(__file__), "audio"),
|
||||
provider=args.simulator_speech_provider,
|
||||
)
|
||||
elif live_human:
|
||||
from werewolf.human import LiveVoiceSession
|
||||
tts = LiveVoiceSession(os.path.join(os.path.dirname(__file__), "audio"),
|
||||
allow_interruptions=not args.no_interruptions)
|
||||
elif args.voice:
|
||||
from werewolf.tts import TTS
|
||||
tts = TTS(os.path.join(os.path.dirname(__file__), "audio"), play=args.play)
|
||||
|
||||
wolves = 2 if args.wolves is None else args.wolves
|
||||
end_to_end = live_human or simulated_user
|
||||
if end_to_end and not (6 <= args.players <= 8):
|
||||
raise ValueError("验收路径必须是 6-8 人局")
|
||||
if end_to_end and wolves != 2:
|
||||
raise ValueError("验收路径角色配置要求恰好 2 只狼人")
|
||||
if not 1 <= args.human_seat <= args.players:
|
||||
raise ValueError("--human-seat 超出玩家座位范围")
|
||||
if not 1 <= args.simulated_user_seat <= args.players:
|
||||
raise ValueError("--simulated-user-seat 超出玩家座位范围")
|
||||
players = create_players(
|
||||
seed=args.seed,
|
||||
players=args.players,
|
||||
wolves=wolves,
|
||||
offline=args.offline,
|
||||
human_seat=args.human_seat if live_human else None,
|
||||
simulated_user_seat=args.simulated_user_seat if simulated_user else None,
|
||||
simulator_model=args.simulator_model,
|
||||
voice=tts if end_to_end else None,
|
||||
)
|
||||
judge = Judge(players, seed=args.seed, tts=tts, max_rounds=args.max_rounds)
|
||||
winner = judge.run()
|
||||
|
||||
# 打印信息可见性审计表 + 自动校验
|
||||
judge.audit.print_table(judge.names)
|
||||
isolation_ok = verify_isolation(judge)
|
||||
|
||||
strategy = None
|
||||
if not args.offline:
|
||||
from werewolf.strategy_audit import evaluate_strategy
|
||||
try:
|
||||
strategy = evaluate_strategy(judge)
|
||||
except Exception as exc:
|
||||
# Preserve the completed game and its evidence even when every
|
||||
# post-game judge endpoint is unavailable. A missing audit is a hard
|
||||
# acceptance failure, never a reason to discard the report or infer a
|
||||
# pass from the model's absence.
|
||||
strategy = {
|
||||
"schema_valid": False,
|
||||
"overall_pass": False,
|
||||
"validation_errors": ["strategy audit unavailable"],
|
||||
"error_type": type(exc).__name__,
|
||||
"error": str(exc)[:500],
|
||||
"judge_attempts": getattr(exc, "judge_attempts", []),
|
||||
}
|
||||
print(f"[策略审计] 未完成:{type(exc).__name__}")
|
||||
|
||||
role_counts = {role.value: sum(1 for p in players if p.role == role) for role in Role}
|
||||
human_players = [p.name for p in players if getattr(p, "is_human", False)]
|
||||
simulated_user_players = [
|
||||
p.name for p in players if getattr(p, "is_simulated_user", False)
|
||||
]
|
||||
user_players = [p.name for p in players if getattr(p, "is_user", False)]
|
||||
from werewolf.strategy_audit import strategy_acceptance_passes
|
||||
strategy_pass = strategy_acceptance_passes(strategy)
|
||||
voice_events = tts.events if end_to_end else []
|
||||
voice_has_asr = bool(any(
|
||||
e["type"] in {"human_asr", "simulator_asr"} for e in voice_events
|
||||
))
|
||||
voice_has_tts = bool(any(e["type"] == "tts_ready" for e in voice_events))
|
||||
simulator_tool_calls = sum(
|
||||
e["type"] == "simulator_llm_tool" for e in voice_events
|
||||
)
|
||||
simulator_audio_roundtrips = sum(e["type"] == "simulator_asr" for e in voice_events)
|
||||
simulator_receipt_events = [
|
||||
e for e in voice_events if e.get("type") in {"simulator_llm_tool", "simulator_asr"}
|
||||
]
|
||||
simulator_receipt_ids = [
|
||||
e.get("response_id") or e.get("request_id") for e in simulator_receipt_events
|
||||
]
|
||||
simulator_unique_receipts = bool(simulator_receipt_ids) and all(simulator_receipt_ids) \
|
||||
and len(simulator_receipt_ids) == len(set(simulator_receipt_ids))
|
||||
simulator_audio_receipts = [
|
||||
e.get("usage", {}).get("prompt_tokens_details", {}).get("audio_tokens", 0)
|
||||
for e in voice_events if e.get("type") == "simulator_asr"
|
||||
]
|
||||
simulator_asr_events = [e for e in voice_events if e.get("type") == "simulator_asr"]
|
||||
audio_token_receipt_required = any(
|
||||
"OpenRouter" in str(e.get("provider", "")) for e in simulator_asr_events
|
||||
)
|
||||
simulator_nonzero_audio_receipts = (not audio_token_receipt_required) or (
|
||||
bool(simulator_audio_receipts) and all(
|
||||
isinstance(value, (int, float)) and value > 0 for value in simulator_audio_receipts
|
||||
)
|
||||
)
|
||||
simulator_trace_integrity = verify_simulator_trace(voice_events) if simulated_user else False
|
||||
simulator_boundary_ok = bool(
|
||||
not simulated_user
|
||||
or (
|
||||
simulator_tool_calls > 0
|
||||
and simulator_tool_calls == simulator_audio_roundtrips
|
||||
and simulator_unique_receipts
|
||||
and simulator_nonzero_audio_receipts
|
||||
and simulator_trace_integrity
|
||||
and not any(e["type"] == "simulator_action_mismatch" for e in voice_events)
|
||||
)
|
||||
)
|
||||
roster_pass = (
|
||||
6 <= len(players) <= 8
|
||||
and role_counts.get("狼人") == 2
|
||||
and role_counts.get("预言家") == 1
|
||||
and role_counts.get("女巫") == 1
|
||||
)
|
||||
winner_determined = winner in {Faction.GOOD, Faction.WEREWOLF}
|
||||
e2e_ok = bool(
|
||||
end_to_end and isolation_ok and roster_pass and len(user_players) == 1
|
||||
and voice_has_asr and voice_has_tts and simulator_boundary_ok and winner_determined
|
||||
)
|
||||
ok = bool(e2e_ok and strategy_pass and judge.completed_rounds >= 3)
|
||||
report = {
|
||||
"schema_version": 2,
|
||||
"experiment": "10-6",
|
||||
"generated_at": __import__("time").strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
"execution_mode": (
|
||||
"simulated_user" if simulated_user else "live_human" if live_human
|
||||
else "offline" if args.offline else "ai_only"
|
||||
),
|
||||
"acceptance_path": end_to_end,
|
||||
"end_to_end_status": "pass" if e2e_ok else "incomplete" if end_to_end else "not_run",
|
||||
"players": len(players),
|
||||
"user_players": user_players,
|
||||
"human_players": human_players,
|
||||
"simulated_user_players": simulated_user_players,
|
||||
"human_role_randomized_to": next((p.role.value for p in players if getattr(p, "is_human", False)), None),
|
||||
"simulated_user_role_randomized_to": next((p.role.value for p in players if getattr(p, "is_simulated_user", False)), None),
|
||||
"role_counts": role_counts,
|
||||
"completed_day_night_vote_cycles": judge.completed_rounds,
|
||||
"winner": winner.value,
|
||||
"information_isolation_pass": isolation_ok,
|
||||
"strategy_audit": strategy,
|
||||
"strategy_audit_pass": strategy_pass,
|
||||
"action_history": judge.action_history,
|
||||
"voice_events": voice_events,
|
||||
"voice_has_asr": voice_has_asr,
|
||||
"voice_has_tts": voice_has_tts,
|
||||
"simulator_llm_tool_calls": simulator_tool_calls,
|
||||
"simulator_audio_roundtrips": simulator_audio_roundtrips,
|
||||
"barge_in_events": sum(1 for e in voice_events if e["type"] == "barge_in"),
|
||||
"gates": {
|
||||
"exact_6_to_8_player_role_roster": {"status": "pass" if roster_pass else "fail"},
|
||||
"one_user_seat": {"status": "pass" if end_to_end and len(user_players) == 1 else "not_run" if not end_to_end else "fail"},
|
||||
"one_authorized_human_participant": {"status": "pass" if live_human and len(human_players) == 1 else "not_applicable" if simulated_user else "not_run" if not live_human else "fail"},
|
||||
"one_llm_user_simulator": {"status": "pass" if simulated_user and len(simulated_user_players) == 1 else "not_applicable" if live_human else "not_run" if not simulated_user else "fail"},
|
||||
"real_user_input_asr": {"status": "pass" if voice_has_asr else "not_run" if not end_to_end else "fail"},
|
||||
"real_ai_and_judge_tts": {"status": "pass" if voice_has_tts else "not_run" if not end_to_end else "fail"},
|
||||
"llm_tool_to_audio_to_asr_boundary": {
|
||||
"status": "pass" if simulated_user and simulator_boundary_ok else "not_applicable" if live_human else "not_run" if not simulated_user else "fail",
|
||||
"tool_calls": simulator_tool_calls,
|
||||
"audio_roundtrips": simulator_audio_roundtrips,
|
||||
"unique_provider_receipts": simulator_unique_receipts,
|
||||
"audio_token_receipt_required": audio_token_receipt_required,
|
||||
"nonzero_audio_token_receipts": simulator_nonzero_audio_receipts,
|
||||
"transaction_integrity": simulator_trace_integrity,
|
||||
},
|
||||
"three_complete_cycles": {"status": "pass" if judge.completed_rounds >= 3 else "fail" if end_to_end else "supplemental_only", "observed": judge.completed_rounds},
|
||||
"information_isolation": {"status": "pass" if isolation_ok else "fail"},
|
||||
"real_llm_strategy_acceptance": {"status": "pass" if strategy_pass else "not_run" if args.offline else "fail"},
|
||||
"winner_determined_by_game_rule": {"status": "pass" if winner_determined else "fail"},
|
||||
},
|
||||
"overall_status": "pass" if ok else "incomplete" if end_to_end else "supplemental_only",
|
||||
}
|
||||
report_path = Path(args.report)
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
if winner == Faction.UNDECIDED:
|
||||
print("\n最终结果:本局未决,没有阵营满足胜利条件。")
|
||||
else:
|
||||
print(f"\n最终结果:{winner.value} 获胜。")
|
||||
print(f"验收报告:{report_path} | {report['overall_status'].upper()}")
|
||||
return ok if end_to_end else isolation_ok
|
||||
|
||||
|
||||
def main():
|
||||
args = build_parser().parse_args()
|
||||
|
||||
# 在线模式(LLM 决策 / 语音合成)才需要 API Key;离线模式不需要。
|
||||
# LLM 决策支持 OPENAI_API_KEY 或(回退)OPENROUTER_API_KEY;语音合成(--voice,
|
||||
# OpenAI tts-1)目前只支持 OPENAI_API_KEY,OpenRouter 无 TTS 端点。
|
||||
if sum(bool(value) for value in (args.offline, args.ai_only, args.simulate_user)) > 1:
|
||||
print("错误:--offline、--ai-only、--simulate-user 互斥")
|
||||
sys.exit(2)
|
||||
live_human = not args.offline and not args.ai_only and not args.simulate_user
|
||||
if live_human and not args.confirm_human_consent:
|
||||
print("拒绝采集音频:真人验收路径必须显式传入 --confirm-human-consent")
|
||||
sys.exit(2)
|
||||
has_llm_key = any(os.environ.get(k) for k in ("ARK_API_KEY", "MOONSHOT_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY"))
|
||||
if args.simulate_user:
|
||||
speech_provider_available = (
|
||||
bool(os.environ.get("OPENAI_API_KEY"))
|
||||
if args.simulator_speech_provider == "openai"
|
||||
else bool(os.environ.get("OPENROUTER_API_KEY"))
|
||||
if args.simulator_speech_provider == "openrouter-system"
|
||||
else bool(os.environ.get("GEMINI_API_KEY"))
|
||||
if args.simulator_speech_provider == "gemini-system"
|
||||
else bool(os.environ.get("OPENAI_API_KEY") or os.environ.get("OPENROUTER_API_KEY") or os.environ.get("GEMINI_API_KEY"))
|
||||
)
|
||||
if not speech_provider_available:
|
||||
print("错误:用户模拟器语音回环需要 OpenAI、OpenRouter 或 Gemini API Key。")
|
||||
sys.exit(1)
|
||||
if (args.voice or live_human) and not os.environ.get("OPENAI_API_KEY"):
|
||||
print("错误:语音合成(--voice,OpenAI tts-1)需要 OPENAI_API_KEY。"
|
||||
"请先 export OPENAI_API_KEY=your-openai-api-key(见 env.example),或去掉 --voice 跑纯文本模式。")
|
||||
sys.exit(1)
|
||||
if not args.offline and not has_llm_key:
|
||||
print("错误:LLM 决策需要 OPENAI_API_KEY 或 OPENROUTER_API_KEY。"
|
||||
"请先 export(见 env.example),或改用离线模式:python demo.py --offline")
|
||||
sys.exit(1)
|
||||
|
||||
log_file = None
|
||||
orig_stdout = sys.stdout
|
||||
if args.log:
|
||||
log_file = open(args.log, "w", encoding="utf-8")
|
||||
sys.stdout = _Tee(orig_stdout, log_file)
|
||||
try:
|
||||
ok = run_game(args)
|
||||
if not ok:
|
||||
sys.exit(1)
|
||||
except ValueError as e:
|
||||
print(f"错误:{e}")
|
||||
sys.exit(2)
|
||||
finally:
|
||||
if log_file:
|
||||
sys.stdout = orig_stdout
|
||||
log_file.close()
|
||||
print(f"(完整对局日志已保存到 {args.log})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,45 @@
|
||||
# === 实验 10-6 环境变量示例:复制为 .env 后填入你的 Key ===
|
||||
|
||||
# 每个玩家 Agent 都用 Chat Completions(默认当前便宜旗舰 gpt-5.6-luna)。
|
||||
# 首选 OPENAI_API_KEY(直连 OpenAI);可选的语音合成用 OpenAI TTS(tts-1)读同一个 Key。
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
OPENAI_ASR_MODEL=whisper-1
|
||||
OPENAI_TTS_MODEL=tts-1
|
||||
OPENAI_TTS_VOICE=coral
|
||||
VOICE_LANGUAGE=zh
|
||||
VOICE_SAMPLE_RATE=16000
|
||||
VOICE_SILENCE_SECONDS=0.8
|
||||
VOICE_RMS_THRESHOLD=0.025
|
||||
VOICE_MAX_UTTERANCE_SECONDS=25
|
||||
AUDIO_PLAYER=afplay
|
||||
WEREWOLF_LLM_TIMEOUT=45
|
||||
WEREWOLF_LLM_RETRIES=1
|
||||
|
||||
# Automated user simulator speech boundary. "auto" prefers OpenAI Audio, then
|
||||
# local espeak + OpenRouter native-audio ASR, then local espeak + Gemini ASR.
|
||||
# Explicit values: openai, openrouter-system, gemini-system.
|
||||
SIMULATOR_SPEECH_PROVIDER=auto
|
||||
SIMULATOR_ASR_MODEL=google/gemini-2.5-flash
|
||||
SIMULATOR_ESPEAK_VOICE=en-us
|
||||
SIMULATOR_ESPEAK_SPEED=145
|
||||
# macOS fallback when espeak is unavailable:
|
||||
SIMULATOR_SAY_VOICE=Samantha
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_ASR_MODEL=gemini-2.5-flash
|
||||
|
||||
# AI 玩家和赛后策略审计可走下面的真实文本端点;真人 ASR/TTS 仍需上面的 OpenAI audio。
|
||||
# ARK_API_KEY=
|
||||
# ARK_MODEL=doubao-seed-1-6-250615
|
||||
# MOONSHOT_API_KEY=
|
||||
# MOONSHOT_MODEL=kimi-k3
|
||||
|
||||
# 可选:覆盖 OpenAI 文本模型
|
||||
# OPENAI_MODEL=gpt-4.1-mini
|
||||
|
||||
# 通用回退:若未设置 OPENAI_API_KEY,则 LLM 决策自动改用 OPENROUTER_API_KEY 走
|
||||
# OpenRouter,并把模型名映射到其命名空间(gpt-5.6-luna -> openai/gpt-5.6-luna)。
|
||||
# 提示:gpt-5.6 系列直连 OpenAI 需组织验证,走 OpenRouter 更省事——
|
||||
# 只填 OPENROUTER_API_KEY(不填 OPENAI_API_KEY)即可强制走 OpenRouter。
|
||||
# `--voice` 的兼容 TTS 仍只支持 OpenAI;`--simulate-user` 可用本地 espeak
|
||||
# 生成真实波形,再用 OpenRouter 的原生音频输入完成真实 ASR。
|
||||
# OPENROUTER_API_KEY=your-openrouter-api-key
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Direct-audio independent evaluation of simulator utterances through OpenRouter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
|
||||
WORDS = {
|
||||
1: "one", 2: "two", 3: "three", 4: "four", 5: "five",
|
||||
6: "six", 7: "seven", 8: "eight", 9: "nine", 10: "ten",
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def expected_text(tool_event: dict[str, Any]) -> str:
|
||||
arguments = tool_event.get("arguments") or {}
|
||||
if tool_event.get("tool") == "speak_publicly":
|
||||
return str(arguments.get("utterance", "")).strip()
|
||||
target = str(arguments.get("target", "")).strip()
|
||||
if target == "none":
|
||||
return "I choose to abstain."
|
||||
number = int(target.removeprefix("P"))
|
||||
return f"I choose player {WORDS.get(number, str(number))}."
|
||||
|
||||
|
||||
def parse_json(text: str) -> dict[str, Any]:
|
||||
text = text.strip()
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.S)
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
def evaluate(report_path: Path, output: Path, model: str) -> dict[str, Any]:
|
||||
if not os.getenv("OPENROUTER_API_KEY"):
|
||||
raise RuntimeError("OPENROUTER_API_KEY is required")
|
||||
raw = report_path.read_bytes()
|
||||
report = json.loads(raw)
|
||||
events = report.get("voice_events") or []
|
||||
client = OpenAI(
|
||||
api_key=os.environ["OPENROUTER_API_KEY"],
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
timeout=120,
|
||||
max_retries=2,
|
||||
)
|
||||
rows = []
|
||||
for index, event in enumerate(events):
|
||||
if event.get("type") != "simulator_llm_tool":
|
||||
continue
|
||||
seat = event.get("seat")
|
||||
# Treat each tool -> TTS -> ASR sequence as a transaction. Searching to
|
||||
# the end of the report can pair a failed turn with a later player's audio
|
||||
# and produce a false positive independent evaluation.
|
||||
transaction = []
|
||||
for item in events[index + 1:]:
|
||||
if item.get("type") == "simulator_llm_tool":
|
||||
break
|
||||
transaction.append(item)
|
||||
tts = next(
|
||||
(item for item in transaction
|
||||
if item.get("type") == "tts_ready" and item.get("speaker") == seat),
|
||||
None,
|
||||
)
|
||||
asr = next(
|
||||
(item for item in transaction if item.get("type") == "simulator_asr"),
|
||||
None,
|
||||
)
|
||||
if not tts or not asr:
|
||||
raise ValueError(f"tool event {event.get('sequence')} lacks TTS/ASR evidence")
|
||||
if transaction.index(tts) > transaction.index(asr):
|
||||
raise ValueError(f"tool event {event.get('sequence')} has ASR before TTS")
|
||||
if asr.get("source_audio_sha256") != tts.get("audio_sha256"):
|
||||
raise ValueError(f"tool event {event.get('sequence')} ASR/TTS audio hash mismatch")
|
||||
audio_path = Path(str(tts["file"]))
|
||||
if not audio_path.is_absolute():
|
||||
project_root = next(
|
||||
parent for parent in report_path.resolve().parents
|
||||
if parent.name == "voice-werewolf"
|
||||
)
|
||||
candidates = [Path.cwd() / audio_path, project_root / audio_path]
|
||||
audio_path = next((path for path in candidates if path.is_file()), candidates[0])
|
||||
if not audio_path.is_file():
|
||||
raise FileNotFoundError(audio_path)
|
||||
if sha256(audio_path) != tts.get("audio_sha256"):
|
||||
raise ValueError(f"audio hash mismatch: {audio_path}")
|
||||
expected = expected_text(event)
|
||||
started = time.perf_counter()
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": (
|
||||
"Independently evaluate this synthetic Werewolf-game speech audio. "
|
||||
f"The intended text is: {expected!r}. Return one JSON object only with "
|
||||
"keys transcript (string), intelligibility_1_to_5 (integer), "
|
||||
"semantic_fidelity_1_to_5 (integer), naturalness_1_to_5 (integer), "
|
||||
"action_or_seat_preserved (boolean), and rationale (short string). "
|
||||
"Judge the waveform itself. A robotic voice may score low on naturalness "
|
||||
"without losing intelligibility or semantic fidelity."
|
||||
),
|
||||
},
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": base64.b64encode(audio_path.read_bytes()).decode("ascii"),
|
||||
"format": audio_path.suffix.lstrip(".").lower(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
temperature=0,
|
||||
max_tokens=600,
|
||||
)
|
||||
judgment = parse_json(response.choices[0].message.content or "")
|
||||
usage = response.usage.model_dump() if response.usage else None
|
||||
row = {
|
||||
"tool_sequence": event.get("sequence"),
|
||||
"tts_sequence": tts.get("sequence"),
|
||||
"asr_sequence": asr.get("sequence"),
|
||||
"tool": event.get("tool"),
|
||||
"target": (event.get("arguments") or {}).get("target"),
|
||||
"expected_text": expected,
|
||||
"original_asr_transcript": asr.get("transcript"),
|
||||
"audio_file": str(audio_path.relative_to(Path.cwd())),
|
||||
"audio_bytes": audio_path.stat().st_size,
|
||||
"audio_sha256": sha256(audio_path),
|
||||
"judge": judgment,
|
||||
"request_id": response.id,
|
||||
"provider_reported_model": response.model,
|
||||
"usage": usage,
|
||||
"latency_seconds": round(time.perf_counter() - started, 3),
|
||||
}
|
||||
row["pass"] = bool(
|
||||
int(judgment.get("intelligibility_1_to_5", 0)) >= 4
|
||||
and int(judgment.get("semantic_fidelity_1_to_5", 0)) >= 4
|
||||
and judgment.get("action_or_seat_preserved") is True
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
result = {
|
||||
"schema_version": 1,
|
||||
"experiment": "10-6-independent-audio-evaluation",
|
||||
"source_report": str(report_path),
|
||||
"source_report_sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"provider": "OpenRouter multimodal audio API",
|
||||
"requested_model": model,
|
||||
"evaluations": rows,
|
||||
"gates": {
|
||||
"all_simulator_audio_evaluated": len(rows)
|
||||
== sum(event.get("type") == "simulator_llm_tool" for event in events),
|
||||
"unique_real_request_ids": len({row["request_id"] for row in rows}) == len(rows),
|
||||
"all_audio_hashes_match": bool(rows),
|
||||
"all_intelligible_and_semantically_faithful": bool(rows)
|
||||
and all(row["pass"] for row in rows),
|
||||
},
|
||||
}
|
||||
result["status"] = "pass" if all(result["gates"].values()) else "fail"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(
|
||||
json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("report", type=Path)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--model", default="google/gemini-3-flash-preview")
|
||||
args = parser.parse_args()
|
||||
result = evaluate(args.report, args.output, args.model)
|
||||
print(json.dumps({"status": result["status"], "gates": result["gates"]}, indent=2))
|
||||
return 0 if result["status"] == "pass" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,6 @@
|
||||
# 实验 10-6 依赖
|
||||
openai>=1.40.0
|
||||
python-dotenv>=1.0.0
|
||||
sounddevice>=0.4.6
|
||||
numpy>=1.26.0
|
||||
pytest>=8.0.0
|
||||
@@ -0,0 +1,124 @@
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parent / "validation"
|
||||
|
||||
|
||||
def test_persisted_acceptance_status_does_not_claim_unrun_human_audio():
|
||||
status = json.loads((ROOT / "acceptance_status_2026-07-29.json").read_text())
|
||||
assert status["safety"]["phone_calls_placed"] == 0
|
||||
assert status["safety"]["human_audio_captured"] is False
|
||||
assert status["audio_endpoint_probe"]["asr_status"] == "fail"
|
||||
assert status["audio_endpoint_probe"]["asr_error_code"] == "insufficient_quota"
|
||||
assert "no microphone or human audio" in status["audio_endpoint_probe"]["asr_input"]
|
||||
assert status["acceptance_gates"]["authorized_human_participant"]["status"] == "not_run"
|
||||
assert status["acceptance_gates"]["real_human_asr"]["status"] == "not_run"
|
||||
assert status["implementation_gates"]["consent_refusal_before_live_session_construction"]["status"] == "pass_by_test"
|
||||
assert status["implementation_gates"]["barge_in_cancels_playback_and_transcribes"]["status"] == "pass_by_mocked_mechanism_test"
|
||||
assert status["implementation_gates"]["deterministic_judge_and_win_rule"]["status"] == "pass_by_test"
|
||||
assert status["overall_status"] == "incomplete"
|
||||
|
||||
|
||||
def test_offline_and_real_llm_evidence_are_explicitly_non_acceptance():
|
||||
offline = json.loads((ROOT / "offline_privacy_supplement_2026-07-29.json").read_text())
|
||||
partial = json.loads((ROOT / "real_llm_partial_2026-07-29.json").read_text())
|
||||
trace = json.loads((ROOT / "real_llm_partial_trace_2026-07-29.json").read_text())
|
||||
audit = json.loads((ROOT / "real_strategy_audit_supplement_2026-07-29.json").read_text())
|
||||
assert offline["acceptance_path"] is False
|
||||
assert offline["overall_status"] == "supplemental_only"
|
||||
assert offline["information_isolation_pass"] is True
|
||||
assert partial["acceptance_path"] is False
|
||||
assert partial["gates"]["three_complete_cycles"]["status"] == "fail"
|
||||
assert partial["overall_status"] == "incomplete"
|
||||
assert trace["complete_cycles"] == 2
|
||||
assert trace["trace_complete"] is False
|
||||
assert any(e.get("action") == "speech" for e in trace["events"])
|
||||
assert any(e.get("phase") == "vote" for e in trace["events"])
|
||||
assert audit["acceptance_path"] is False
|
||||
assert audit["human_audio_used"] is False
|
||||
assert audit["audit"]["schema_valid"] is True
|
||||
assert audit["audit"]["overall_pass"] is False
|
||||
assert audit["overall_status"] == "supplemental_only"
|
||||
|
||||
|
||||
def test_real_user_simulator_runs_prove_e2e_and_keep_negative_results_separate():
|
||||
runs = ROOT / "runs"
|
||||
strategy_run = json.loads(
|
||||
(runs / "exp10-6-simulated-user-openrouter-20260801" / "acceptance_report.json").read_text()
|
||||
)
|
||||
three_cycle_run = json.loads(
|
||||
(runs / "exp10-6-simulated-user-openrouter-20260801-v2" / "acceptance_report.json").read_text()
|
||||
)
|
||||
|
||||
strategy_validation = json.loads(
|
||||
(runs / "exp10-6-simulated-user-openrouter-20260801" / "independent_validation.json").read_text()
|
||||
)
|
||||
three_cycle_validation = json.loads(
|
||||
(runs / "exp10-6-simulated-user-openrouter-20260801-v2" / "independent_validation.json").read_text()
|
||||
)
|
||||
|
||||
assert strategy_run["strategy_audit_pass"] is True
|
||||
assert strategy_validation["strict_audio_action_boundary"] == "fail"
|
||||
assert "not an explicit abstention" in strategy_validation["errors"][0]
|
||||
|
||||
report = three_cycle_run
|
||||
assert report["execution_mode"] == "simulated_user"
|
||||
assert report["acceptance_path"] is True
|
||||
assert report["gates"]["one_llm_user_simulator"]["status"] == "pass"
|
||||
assert report["gates"]["information_isolation"]["status"] == "pass"
|
||||
assert report["gates"]["winner_determined_by_game_rule"]["status"] == "pass"
|
||||
assert report["simulator_llm_tool_calls"] == 2
|
||||
assert report["simulator_audio_roundtrips"] == 2
|
||||
ids = [
|
||||
event.get("response_id") or event.get("request_id")
|
||||
for event in report["voice_events"]
|
||||
if event["type"] in {"simulator_llm_tool", "simulator_asr"}
|
||||
]
|
||||
assert all(ids)
|
||||
assert len(ids) == len(set(ids))
|
||||
asr_events = [event for event in report["voice_events"] if event["type"] == "simulator_asr"]
|
||||
assert all(
|
||||
event["usage"]["prompt_tokens_details"]["audio_tokens"] > 0
|
||||
for event in asr_events
|
||||
)
|
||||
assert three_cycle_validation["strict_audio_action_boundary"] == "pass"
|
||||
assert three_cycle_run["completed_day_night_vote_cycles"] == 3
|
||||
assert three_cycle_run["strategy_audit_pass"] is False
|
||||
assert three_cycle_run["overall_status"] == "incomplete"
|
||||
|
||||
|
||||
def test_fixed_abstention_has_real_audio_api_receipt():
|
||||
probe = json.loads((ROOT / "fixed_abstention_probe_20260801.json").read_text())
|
||||
assert probe["synthetic_audio_only"] is True
|
||||
assert probe["spoken_text"] == "I choose to abstain."
|
||||
assert probe["asr_transcript"] == "I choose to abstain."
|
||||
assert probe["explicit_abstention"] is True
|
||||
assert probe["parsed_target"] is None
|
||||
assert probe["provider"] == "OpenRouter multimodal audio API"
|
||||
assert probe["response_id"].startswith("gen-")
|
||||
assert probe["usage"]["prompt_tokens_details"]["audio_tokens"] > 0
|
||||
assert len(probe["source_audio_sha256"]) == 64
|
||||
assert probe["status"] == "pass"
|
||||
|
||||
|
||||
def test_latest_formal_run_passes_every_gate_in_one_report():
|
||||
run = ROOT / "runs" / "exp10-6-simulated-user-openrouter-20260803-v11"
|
||||
report = json.loads((run / "acceptance_report.json").read_text())
|
||||
independent = json.loads((run / "independent_validation.json").read_text())
|
||||
|
||||
assert report["overall_status"] == "pass"
|
||||
assert report["end_to_end_status"] == "pass"
|
||||
assert report["completed_day_night_vote_cycles"] >= 3
|
||||
assert report["strategy_audit_pass"] is True
|
||||
assert report["information_isolation_pass"] is True
|
||||
assert all(
|
||||
gate["status"] in {"pass", "not_applicable"}
|
||||
for gate in report["gates"].values()
|
||||
)
|
||||
assert independent["strict_audio_action_boundary"] == "pass"
|
||||
assert independent["source_report_sha256"] == hashlib.sha256(
|
||||
(run / "acceptance_report.json").read_bytes()
|
||||
).hexdigest()
|
||||
assert independent["simulator_tool_events_checked"] == report["simulator_llm_tool_calls"]
|
||||
@@ -0,0 +1,287 @@
|
||||
import json
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import demo
|
||||
from werewolf.agent import PlayerAgent
|
||||
from werewolf.game import Judge, create_players
|
||||
from werewolf.human import HumanPlayerAgent, LiveVoiceSession
|
||||
from werewolf.roles import Faction, Role
|
||||
from werewolf.strategy_audit import evaluate_strategy, strategy_acceptance_passes, validate_strategy_result
|
||||
|
||||
|
||||
class NoAudio:
|
||||
def __init__(self):
|
||||
self.private_prompts = []
|
||||
|
||||
def say(self, speaker, text, round_no, allow_barge_in=False):
|
||||
self.private_prompts.append(text)
|
||||
|
||||
|
||||
def test_exact_acceptance_roster_has_one_human_and_required_roles():
|
||||
players = create_players(seed=42, players=7, wolves=2, human_seat=1, voice=NoAudio())
|
||||
assert sum(isinstance(p, HumanPlayerAgent) for p in players) == 1
|
||||
roles = [p.role for p in players]
|
||||
assert roles.count(Role.WEREWOLF) == 2
|
||||
assert roles.count(Role.SEER) == 1
|
||||
assert roles.count(Role.WITCH) == 1
|
||||
assert players[0].role in set(Role)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seat_count", [6, 7, 8])
|
||||
def test_every_allowed_live_roster_has_one_human_and_five_to_seven_ai(seat_count):
|
||||
players = create_players(
|
||||
seed=seat_count,
|
||||
players=seat_count,
|
||||
wolves=2,
|
||||
human_seat=1,
|
||||
voice=NoAudio(),
|
||||
)
|
||||
roles = [player.role for player in players]
|
||||
assert sum(isinstance(player, HumanPlayerAgent) for player in players) == 1
|
||||
assert sum(not isinstance(player, HumanPlayerAgent) for player in players) == seat_count - 1
|
||||
assert 5 <= seat_count - 1 <= 7
|
||||
assert roles.count(Role.WEREWOLF) == 2
|
||||
assert roles.count(Role.SEER) == 1
|
||||
assert roles.count(Role.WITCH) == 1
|
||||
assert roles.count(Role.VILLAGER) == seat_count - 4
|
||||
|
||||
|
||||
def test_spoken_player_number_parser():
|
||||
candidates = ["P2", "P3", "P4"]
|
||||
assert HumanPlayerAgent._spoken_target("我投三号玩家", candidates, True) == "P3"
|
||||
assert HumanPlayerAgent._spoken_target("player 4", candidates, True) == "P4"
|
||||
assert HumanPlayerAgent._spoken_target("I choose player three", candidates, True) == "P3"
|
||||
assert HumanPlayerAgent._spoken_target("我弃票", candidates, True) is None
|
||||
assert HumanPlayerAgent._spoken_target("I choose to abstain", candidates, True) is None
|
||||
assert not HumanPlayerAgent._explicit_none("P1 is not")
|
||||
|
||||
|
||||
def test_live_human_terminal_never_prints_god_view(capsys):
|
||||
voice = NoAudio()
|
||||
players = create_players(seed=42, players=7, wolves=2, human_seat=1, voice=voice)
|
||||
judge = Judge(players, seed=42)
|
||||
judge.assign_identities()
|
||||
output = capsys.readouterr().out
|
||||
assert "上帝视角身份表已隐藏" in output
|
||||
assert "P2:" not in output
|
||||
assert any("您的身份" in prompt for prompt in voice.private_prompts)
|
||||
judge._print_private(["P2"], "SECRET_WOLF_ACTION")
|
||||
assert "SECRET_WOLF_ACTION" not in capsys.readouterr().out
|
||||
judge._print_private(["P1"], "MY_PRIVATE_ACTION")
|
||||
assert "MY_PRIVATE_ACTION" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_live_default_without_consent_stops_before_game_or_voice_construction(monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", ["demo.py"])
|
||||
with patch("demo.run_game") as run_game, patch(
|
||||
"werewolf.human.LiveVoiceSession"
|
||||
) as live_session:
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
demo.main()
|
||||
assert exc.value.code == 2
|
||||
run_game.assert_not_called()
|
||||
live_session.assert_not_called()
|
||||
|
||||
|
||||
def test_human_role_is_actually_randomized_by_the_shared_shuffle():
|
||||
observed = {
|
||||
create_players(seed=seed, players=7, wolves=2, human_seat=1, voice=NoAudio())[0].role
|
||||
for seed in range(40)
|
||||
}
|
||||
assert observed == set(Role)
|
||||
|
||||
|
||||
class PassivePlayer(PlayerAgent):
|
||||
def choose_target(self, prompt, candidates, players, allow_none=False):
|
||||
if self.role == Role.SEER and candidates:
|
||||
return candidates[0]
|
||||
return None
|
||||
|
||||
def speak(self, players):
|
||||
return "我暂时没有可验证的判断。"
|
||||
|
||||
def vote(self, candidates, players):
|
||||
return None
|
||||
|
||||
|
||||
def test_three_cycles_count_only_after_night_day_vote_and_round_cap_is_not_fake_win(capsys):
|
||||
roles = [Role.WEREWOLF, Role.WEREWOLF, Role.SEER, Role.WITCH,
|
||||
Role.VILLAGER, Role.VILLAGER, Role.VILLAGER]
|
||||
players = [PassivePlayer(f"P{i + 1}", role, offline=True) for i, role in enumerate(roles)]
|
||||
judge = Judge(players, seed=7, max_rounds=3)
|
||||
|
||||
winner = judge.run()
|
||||
|
||||
assert judge.completed_rounds == 3
|
||||
assert len([r for r in judge.audit.records if r.category == "公开-死讯"]) == 3
|
||||
assert len([r for r in judge.audit.records if r.category == "公开-放逐"]) == 3
|
||||
assert winner == Faction.UNDECIDED
|
||||
output = capsys.readouterr().out
|
||||
assert "本局未决" in output
|
||||
assert "获胜阵营:未决" not in output
|
||||
|
||||
|
||||
def test_deterministic_winner_conditions():
|
||||
roles = [Role.WEREWOLF, Role.WEREWOLF, Role.SEER, Role.WITCH,
|
||||
Role.VILLAGER, Role.VILLAGER]
|
||||
players = [PlayerAgent(f"P{i + 1}", role, offline=True) for i, role in enumerate(roles)]
|
||||
judge = Judge(players)
|
||||
players[0].alive = players[1].alive = False
|
||||
assert judge._check_winner() == Faction.GOOD
|
||||
players[0].alive = players[1].alive = True
|
||||
players[4].alive = players[5].alive = False
|
||||
assert judge._check_winner() == Faction.WEREWOLF
|
||||
players[5].alive = True
|
||||
assert judge._check_winner() is None
|
||||
|
||||
|
||||
def test_strategy_acceptance_requires_all_named_criteria_and_evidence():
|
||||
valid = {
|
||||
"criteria": {
|
||||
name: {"status": "pass", "evidence": f"quoted evidence for {name}"}
|
||||
for name in (
|
||||
"werewolf_concealment", "seer_timing_and_evidence",
|
||||
"villager_logical_reasoning", "role_consistency",
|
||||
)
|
||||
},
|
||||
"overall_pass": True,
|
||||
}
|
||||
assert strategy_acceptance_passes(validate_strategy_result(valid))
|
||||
valid_fail = {
|
||||
"criteria": {
|
||||
name: {"status": "fail", "evidence": f"counterevidence for {name}"}
|
||||
for name in (
|
||||
"werewolf_concealment", "seer_timing_and_evidence",
|
||||
"villager_logical_reasoning", "role_consistency",
|
||||
)
|
||||
},
|
||||
"overall_pass": False,
|
||||
}
|
||||
checked_fail = validate_strategy_result(valid_fail)
|
||||
assert checked_fail["schema_valid"] is True
|
||||
assert checked_fail["overall_pass"] is False
|
||||
assert not strategy_acceptance_passes(checked_fail)
|
||||
malformed = {"criteria": {"role_consistency": {"status": "pass"}}, "overall_pass": True}
|
||||
checked = validate_strategy_result(malformed)
|
||||
assert checked["schema_valid"] is False
|
||||
assert checked["overall_pass"] is False
|
||||
assert not strategy_acceptance_passes(checked)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("malformed", [None, [], "not-json-object", 7])
|
||||
def test_strategy_validation_fails_closed_for_non_object_provider_output(malformed):
|
||||
checked = validate_strategy_result(malformed)
|
||||
assert checked["schema_valid"] is False
|
||||
assert checked["overall_pass"] is False
|
||||
assert not strategy_acceptance_passes(checked)
|
||||
|
||||
|
||||
def test_strategy_audit_retains_invalid_schema_and_tries_next_backend(monkeypatch):
|
||||
from werewolf import strategy_audit as audit_module
|
||||
|
||||
criteria = {
|
||||
name: {"status": "pass", "evidence": f"quoted evidence for {name}"}
|
||||
for name in (
|
||||
"werewolf_concealment", "seer_timing_and_evidence",
|
||||
"villager_logical_reasoning", "role_consistency",
|
||||
)
|
||||
}
|
||||
malformed = {
|
||||
"criteria": dict(list(criteria.items())[:3]),
|
||||
"role_consistency": criteria["role_consistency"],
|
||||
}
|
||||
valid = {"criteria": criteria, "overall_pass": True}
|
||||
|
||||
class Completion:
|
||||
def __init__(self, payload, response_id):
|
||||
self.payload = payload
|
||||
self.response_id = response_id
|
||||
|
||||
def create(self, **kwargs):
|
||||
return SimpleNamespace(
|
||||
id=self.response_id,
|
||||
model="reported-model",
|
||||
usage=SimpleNamespace(model_dump=lambda: {"prompt_tokens": 10, "completion_tokens": 5}),
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content=__import__("json").dumps(self.payload)))],
|
||||
)
|
||||
|
||||
def client(payload, response_id):
|
||||
return SimpleNamespace(chat=SimpleNamespace(completions=Completion(payload, response_id)))
|
||||
|
||||
monkeypatch.setattr(audit_module, "_backends", lambda: [
|
||||
(client(malformed, "bad-schema"), "judge-a", "provider-a"),
|
||||
(client(valid, "valid-schema"), "judge-b", "provider-b"),
|
||||
])
|
||||
judge = SimpleNamespace(
|
||||
players=[SimpleNamespace(name="P1", role=Role.VILLAGER)],
|
||||
action_history=[{"actor": "P1", "role": "村民", "action": "vote", "target": "P2"}],
|
||||
)
|
||||
result = evaluate_strategy(judge)
|
||||
assert result["schema_valid"] is True
|
||||
assert result["overall_pass"] is True
|
||||
assert result["provider"] == "provider-b"
|
||||
assert [attempt["response_id"] for attempt in result["judge_attempts"]] == [
|
||||
"bad-schema", "valid-schema"
|
||||
]
|
||||
assert result["judge_attempts"][0]["schema_valid"] is False
|
||||
# Invalid-attempt provenance must remain acyclic when attached to the
|
||||
# accepted result so the retained acceptance report can be serialized.
|
||||
json.dumps(result)
|
||||
|
||||
|
||||
def test_barge_in_cancels_playback_and_transcribes_without_real_audio(monkeypatch, tmp_path):
|
||||
class FakeProcess:
|
||||
def __init__(self):
|
||||
self.terminated = False
|
||||
|
||||
def poll(self):
|
||||
return 0 if self.terminated else None
|
||||
|
||||
def terminate(self):
|
||||
self.terminated = True
|
||||
|
||||
class FakeInputStream:
|
||||
def __init__(self, **kwargs):
|
||||
self.frames = iter([
|
||||
np.full((1024, 1), 0.2, dtype="float32"),
|
||||
np.full((1024, 1), 0.2, dtype="float32"),
|
||||
np.zeros((1024, 1), dtype="float32"),
|
||||
])
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def read(self, _block):
|
||||
return next(self.frames), False
|
||||
|
||||
session = LiveVoiceSession.__new__(LiveVoiceSession)
|
||||
session.allow_interruptions = True
|
||||
session.sample_rate = 1024
|
||||
session.threshold = 0.05
|
||||
session.silence_seconds = 1.0
|
||||
session.max_utterance = 1.0
|
||||
session.player = "fake-player"
|
||||
events = []
|
||||
session._tts = lambda *args: tmp_path / "synthetic.mp3"
|
||||
session._event = lambda kind, **data: events.append((kind, data))
|
||||
session._write_wav = lambda frames, path: None
|
||||
session._transcribe = lambda path, kind: "我来打断"
|
||||
proc = FakeProcess()
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "sounddevice", SimpleNamespace(InputStream=FakeInputStream)
|
||||
)
|
||||
monkeypatch.setattr("werewolf.human.subprocess.Popen", lambda *args, **kwargs: proc)
|
||||
|
||||
result = session.say("P2", "一段 AI 发言", 1, allow_barge_in=True)
|
||||
|
||||
assert result == "我来打断"
|
||||
assert proc.terminated is True
|
||||
assert events == [("barge_in", {"interrupted_speaker": "P2"})]
|
||||
@@ -0,0 +1,20 @@
|
||||
import pytest
|
||||
from werewolf.game import Judge
|
||||
from werewolf.agent import PlayerAgent
|
||||
from werewolf.roles import Role, Faction
|
||||
|
||||
def test_simultaneous_deaths_returns_undecided_faction():
|
||||
"""Contract proved: Judge._check_winner returns Faction.UNDECIDED when all wolves and good players die simultaneously.
|
||||
Bug locked out: returning Faction.GOOD when zero good players survive alongside zero wolves."""
|
||||
# Setup players: 1 Werewolf and 1 Witch (Good)
|
||||
p_wolf = PlayerAgent("P1", Role.WEREWOLF, offline=True)
|
||||
p_witch = PlayerAgent("P2", Role.WITCH, offline=True)
|
||||
judge = Judge([p_wolf, p_witch])
|
||||
|
||||
# Both players die in night phase
|
||||
p_wolf.alive = False
|
||||
p_witch.alive = False
|
||||
|
||||
# Check winner must report UNDECIDED, not GOOD when zero good players survive
|
||||
winner = judge._check_winner()
|
||||
assert winner == Faction.UNDECIDED
|
||||
@@ -0,0 +1,221 @@
|
||||
import json
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import demo
|
||||
from werewolf import agent as agent_module
|
||||
from werewolf.game import Judge, create_players
|
||||
from werewolf.roles import Role
|
||||
from werewolf.agent import PlayerAgent
|
||||
from werewolf.simulator import SimulatedUserPlayerAgent, SimulatedVoiceSession
|
||||
|
||||
|
||||
class FakeVoice:
|
||||
def __init__(self, transcripts=None):
|
||||
self.events = []
|
||||
self.transcripts = iter(transcripts or [])
|
||||
self.spoken = []
|
||||
|
||||
def say(self, speaker, text, round_no, allow_barge_in=False):
|
||||
self.spoken.append((speaker, text, round_no))
|
||||
|
||||
def record_llm_decision(self, **data):
|
||||
self.events.append({"type": "simulator_llm_tool", **data})
|
||||
|
||||
def roundtrip_user(self, speaker, text, round_no):
|
||||
self.spoken.append((speaker, text, round_no))
|
||||
return next(self.transcripts)
|
||||
|
||||
def _event(self, type_, **data):
|
||||
self.events.append({"type": type_, **data})
|
||||
|
||||
|
||||
def tool_response(name, arguments):
|
||||
call = SimpleNamespace(
|
||||
function=SimpleNamespace(name=name, arguments=json.dumps(arguments, ensure_ascii=False))
|
||||
)
|
||||
message = SimpleNamespace(tool_calls=[call])
|
||||
return SimpleNamespace(
|
||||
id="response-real-shape",
|
||||
model="provider-model",
|
||||
usage=SimpleNamespace(model_dump=lambda: {"prompt_tokens": 10, "completion_tokens": 3}),
|
||||
choices=[SimpleNamespace(message=message)],
|
||||
)
|
||||
|
||||
|
||||
def test_simulated_user_is_one_randomized_protected_user_seat():
|
||||
voice = FakeVoice()
|
||||
players = create_players(
|
||||
seed=42,
|
||||
players=7,
|
||||
wolves=2,
|
||||
simulated_user_seat=1,
|
||||
voice=voice,
|
||||
)
|
||||
assert sum(isinstance(player, SimulatedUserPlayerAgent) for player in players) == 1
|
||||
assert sum(getattr(player, "is_user", False) for player in players) == 1
|
||||
assert players[0].role in set(Role)
|
||||
|
||||
judge = Judge(players, seed=42)
|
||||
judge.assign_identities()
|
||||
assert any("您的身份" in text for _, text, _ in voice.spoken)
|
||||
|
||||
|
||||
def test_simulator_uses_private_context_tool_and_only_asr_speech(monkeypatch):
|
||||
voice = FakeVoice(transcripts=["ASR 后的公开发言"])
|
||||
player = SimulatedUserPlayerAgent("P1", Role.VILLAGER, voice, model="test/model")
|
||||
player.observe("仅 P1 可见的记忆")
|
||||
captured = {}
|
||||
response = tool_response("speak_publicly", {"utterance": "LLM 原始公开发言"})
|
||||
monkeypatch.setattr(agent_module, "get_client", lambda: object())
|
||||
|
||||
def fake_create(_client, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(agent_module, "_safe_create", fake_create)
|
||||
|
||||
speech = player.speak(["P1", "P2"])
|
||||
|
||||
assert speech == "ASR 后的公开发言"
|
||||
assert "仅 P1 可见的记忆" in captured["messages"][1]["content"]
|
||||
assert captured["tool_choice"]["function"]["name"] == "speak_publicly"
|
||||
assert captured["tools"][0]["function"]["parameters"]["additionalProperties"] is False
|
||||
assert voice.spoken[-1][1] == "LLM 原始公开发言"
|
||||
assert voice.events[0]["tool"] == "speak_publicly"
|
||||
|
||||
|
||||
def test_simulator_fails_closed_when_asr_changes_selected_action(monkeypatch):
|
||||
voice = FakeVoice(transcripts=["I choose player three"])
|
||||
player = SimulatedUserPlayerAgent("P1", Role.VILLAGER, voice)
|
||||
response = tool_response("choose_player", {"target": "P2", "reason": "公开证据"})
|
||||
monkeypatch.setattr(agent_module, "get_client", lambda: object())
|
||||
monkeypatch.setattr(agent_module, "_safe_create", lambda _client, **kwargs: response)
|
||||
|
||||
with pytest.raises(RuntimeError, match="speech boundary changed"):
|
||||
player.vote(["P2", "P3"], ["P1", "P2", "P3"])
|
||||
|
||||
mismatch = voice.events[-1]
|
||||
assert mismatch["type"] == "simulator_action_mismatch"
|
||||
assert mismatch["tool_target"] == "P2"
|
||||
assert mismatch["parsed_target"] == "P3"
|
||||
|
||||
|
||||
def test_simulator_fails_closed_when_abstention_is_not_explicit_in_asr(monkeypatch):
|
||||
voice = FakeVoice(transcripts=["P1 is not"])
|
||||
player = SimulatedUserPlayerAgent("P1", Role.VILLAGER, voice)
|
||||
response = tool_response("choose_player", {"target": "none", "reason": "not enough evidence"})
|
||||
monkeypatch.setattr(agent_module, "get_client", lambda: object())
|
||||
monkeypatch.setattr(agent_module, "_safe_create", lambda _client, **kwargs: response)
|
||||
|
||||
with pytest.raises(RuntimeError, match="speech boundary changed"):
|
||||
player.vote(["P2", "P3"], ["P1", "P2", "P3"])
|
||||
|
||||
assert voice.spoken[-1][1] == "I choose to abstain."
|
||||
assert voice.events[-1]["type"] == "simulator_action_mismatch"
|
||||
|
||||
|
||||
def test_judge_retains_llm_decision_reason_in_action_evidence(monkeypatch):
|
||||
voice = FakeVoice(transcripts=["I choose player two"])
|
||||
simulator = SimulatedUserPlayerAgent("P1", Role.VILLAGER, voice)
|
||||
response = tool_response(
|
||||
"choose_player", {"target": "P2", "reason": "P2 contradicted the public vote record"}
|
||||
)
|
||||
monkeypatch.setattr(agent_module, "get_client", lambda: object())
|
||||
monkeypatch.setattr(agent_module, "_safe_create", lambda _client, **kwargs: response)
|
||||
target = simulator.vote(["P2"], ["P1", "P2"])
|
||||
record = Judge._decision_record(
|
||||
simulator, round=1, phase="vote", actor="P1", role="村民",
|
||||
action="vote", target=target,
|
||||
)
|
||||
assert record["reason"] == "P2 contradicted the public vote record"
|
||||
assert simulator.last_decision_reason is None
|
||||
|
||||
|
||||
def test_good_faction_vote_prompt_prioritizes_uncontested_seer_evidence(monkeypatch):
|
||||
player = PlayerAgent("P7", Role.VILLAGER)
|
||||
captured = {}
|
||||
|
||||
def fake_chat(instruction, players, max_tokens, json_mode=False):
|
||||
captured["instruction"] = instruction
|
||||
return '{"target": "P5", "reason": "P4 is uncontested and checked P5 as a wolf"}'
|
||||
|
||||
monkeypatch.setattr(player, "_chat", fake_chat)
|
||||
assert player.vote(["P4", "P5"], ["P4", "P5", "P7"]) == "P5"
|
||||
assert "不得投该声明者" in captured["instruction"]
|
||||
assert "被查杀者仅仅否认并不构成" in captured["instruction"]
|
||||
|
||||
|
||||
def test_reasoning_model_empty_content_retries_with_larger_bounded_budget(monkeypatch):
|
||||
player = PlayerAgent("P7", Role.VILLAGER)
|
||||
calls = []
|
||||
responses = iter([
|
||||
SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=""))]),
|
||||
SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="visible speech"))]),
|
||||
])
|
||||
monkeypatch.setattr(agent_module, "get_client", lambda: object())
|
||||
|
||||
def fake_create(_client, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return next(responses)
|
||||
|
||||
monkeypatch.setattr(agent_module, "_safe_create", fake_create)
|
||||
assert player._chat("speak", ["P7"], max_tokens=180) == "visible speech"
|
||||
assert calls[0]["max_tokens"] == 512
|
||||
assert calls[1]["max_tokens"] == 2048
|
||||
|
||||
|
||||
def test_simulator_vote_prompt_uses_the_same_evidence_priority(monkeypatch):
|
||||
voice = FakeVoice(transcripts=["I choose player five"])
|
||||
player = SimulatedUserPlayerAgent("P1", Role.VILLAGER, voice)
|
||||
captured = {}
|
||||
response = tool_response(
|
||||
"choose_player", {"target": "P5", "reason": "P4 is the only Seer and checked P5"}
|
||||
)
|
||||
monkeypatch.setattr(agent_module, "get_client", lambda: object())
|
||||
|
||||
def fake_create(_client, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(agent_module, "_safe_create", fake_create)
|
||||
assert player.vote(["P4", "P5"], ["P1", "P4", "P5"]) == "P5"
|
||||
instruction = captured["messages"][1]["content"]
|
||||
assert "不得投该声明者" in instruction
|
||||
assert "被查杀者仅仅否认不构成" in instruction
|
||||
|
||||
|
||||
def test_system_speech_falls_back_to_macos_say(monkeypatch, tmp_path):
|
||||
from werewolf import simulator as simulator_module
|
||||
|
||||
paths = {"espeak-ng": None, "espeak": None, "say": "/usr/bin/say", "ffmpeg": "/opt/bin/ffmpeg"}
|
||||
monkeypatch.setattr(simulator_module.shutil, "which", lambda name: paths.get(name))
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "configured-for-test")
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
if command[0] == "/usr/bin/say":
|
||||
output = command[command.index("-o") + 1]
|
||||
else:
|
||||
output = command[-1]
|
||||
from pathlib import Path
|
||||
Path(output).write_bytes(b"real-audio-shaped-bytes")
|
||||
return SimpleNamespace(returncode=0)
|
||||
|
||||
monkeypatch.setattr(simulator_module.subprocess, "run", fake_run)
|
||||
session = SimulatedVoiceSession(str(tmp_path), provider="openrouter-system")
|
||||
path = session._synthesize("P1", "I choose player four.", 1)
|
||||
assert path.read_bytes() == b"real-audio-shaped-bytes"
|
||||
assert session.events[0]["model"] == "macos-say-Samantha"
|
||||
|
||||
|
||||
def test_simulated_user_cli_needs_no_human_consent(monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "configured-for-test")
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "configured-for-test")
|
||||
monkeypatch.setattr(sys, "argv", ["demo.py", "--simulate-user"])
|
||||
with patch("demo.run_game", return_value=True) as run_game:
|
||||
demo.main()
|
||||
assert run_game.call_args.args[0].simulate_user is True
|
||||
assert run_game.call_args.args[0].confirm_human_consent is False
|
||||
@@ -0,0 +1,29 @@
|
||||
import pytest
|
||||
from werewolf.game import Judge
|
||||
from werewolf.agent import PlayerAgent
|
||||
from werewolf.roles import Role, Faction
|
||||
|
||||
|
||||
def test_witch_killed_by_wolves_cannot_use_poison_in_same_night():
|
||||
"""Contract proved: Judge._witch_act prevents a Witch who was killed by wolves at night and not saved from using poison in the same night.
|
||||
Bug locked out: allowing a dead witch killed by wolves to poison another player on the night she dies."""
|
||||
players = [
|
||||
PlayerAgent("P1", Role.WEREWOLF, offline=True),
|
||||
PlayerAgent("P2", Role.WEREWOLF, offline=True),
|
||||
PlayerAgent("P3", Role.SEER, offline=True),
|
||||
PlayerAgent("P4", Role.WITCH, offline=True),
|
||||
PlayerAgent("P5", Role.VILLAGER, offline=True),
|
||||
]
|
||||
|
||||
judge = Judge(players, seed=42)
|
||||
witch = judge.by_name("P4")
|
||||
|
||||
# Override witch target selection to attempt poisoning P3 if asked
|
||||
witch._offline_choose_target = lambda candidates, allow_none: "P3"
|
||||
|
||||
# Wolves killed P4 (the witch)
|
||||
killed = "P4"
|
||||
poisoned, saved = judge._witch_act(killed)
|
||||
|
||||
assert saved is False
|
||||
assert poisoned is None, "A witch killed by wolves at night cannot use poison in the same night"
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independently recompute the simulated-user audio/action boundary from a report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from werewolf.human import HumanPlayerAgent
|
||||
|
||||
|
||||
def validate(report_path: Path) -> dict:
|
||||
raw = report_path.read_bytes()
|
||||
report = json.loads(raw)
|
||||
events = report.get("voice_events", [])
|
||||
errors = []
|
||||
checked = 0
|
||||
seen_sequences = set()
|
||||
seen_request_ids = set()
|
||||
seen_provider_ids = set()
|
||||
simulator_asr_count = sum(
|
||||
isinstance(event, dict) and event.get("type") == "simulator_asr"
|
||||
for event in events
|
||||
) if isinstance(events, list) else 0
|
||||
simulator_tool_count = 0
|
||||
|
||||
if not isinstance(events, list):
|
||||
errors.append("voice_events must be an array")
|
||||
events = []
|
||||
|
||||
# A trace is an append-only sequence. Reject duplicate/out-of-order sequence
|
||||
# numbers instead of allowing a later event to be accidentally paired with an
|
||||
# earlier action.
|
||||
previous_sequence = 0
|
||||
for event in events:
|
||||
if not isinstance(event, dict):
|
||||
errors.append("every voice event must be an object")
|
||||
continue
|
||||
sequence = event.get("sequence")
|
||||
if isinstance(sequence, int) and sequence in seen_sequences:
|
||||
errors.append(f"duplicate voice event sequence: {sequence}")
|
||||
if not isinstance(sequence, int) or sequence <= previous_sequence:
|
||||
errors.append(f"voice event sequence is not strictly increasing: {sequence!r}")
|
||||
if isinstance(sequence, int):
|
||||
seen_sequences.add(sequence)
|
||||
previous_sequence = max(previous_sequence, sequence)
|
||||
|
||||
for index, event in enumerate(events):
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
if event.get("type") != "simulator_llm_tool":
|
||||
continue
|
||||
checked += 1
|
||||
simulator_tool_count += 1
|
||||
tool_request_id = event.get("response_id") or event.get("request_id")
|
||||
if not isinstance(tool_request_id, str) or not tool_request_id.strip():
|
||||
errors.append(f"tool event {event.get('sequence')} lacks provider response_id")
|
||||
elif tool_request_id in seen_provider_ids:
|
||||
errors.append(f"duplicate tool provider response_id: {tool_request_id}")
|
||||
else:
|
||||
seen_provider_ids.add(tool_request_id)
|
||||
seat = event.get("seat")
|
||||
if not isinstance(seat, str) or not re.fullmatch(r"P\d+", seat):
|
||||
errors.append(f"tool event {event.get('sequence')} has invalid seat")
|
||||
# Only events in this action's contiguous transaction may be used. The
|
||||
# old validator searched to the end of the trace, so a missing ASR could
|
||||
# silently borrow another turn's transcript.
|
||||
transaction = []
|
||||
for item in events[index + 1:]:
|
||||
if not isinstance(item, dict):
|
||||
errors.append(f"tool event {event.get('sequence')} has non-object transaction event")
|
||||
continue
|
||||
if item.get("type") == "simulator_llm_tool":
|
||||
break
|
||||
transaction.append(item)
|
||||
tts = next((item for item in transaction
|
||||
if item.get("type") == "tts_ready" and item.get("speaker") == seat), None)
|
||||
following = next((item for item in transaction
|
||||
if item.get("type") == "simulator_asr"), None)
|
||||
if tts is None:
|
||||
errors.append(f"tool event {event.get('sequence')} has no same-seat TTS")
|
||||
if following is None:
|
||||
errors.append(f"tool event {event.get('sequence')} has no transaction-local simulator_asr")
|
||||
continue
|
||||
if tts is not None:
|
||||
if transaction.index(tts) > transaction.index(following):
|
||||
errors.append(f"tool event {event.get('sequence')} has ASR before TTS")
|
||||
audio_hash = tts.get("audio_sha256")
|
||||
source_hash = following.get("source_audio_sha256")
|
||||
if not isinstance(audio_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", audio_hash):
|
||||
errors.append(f"tool event {event.get('sequence')} has invalid TTS audio hash")
|
||||
if source_hash != audio_hash:
|
||||
errors.append(f"tool event {event.get('sequence')} ASR source hash does not match TTS")
|
||||
if not isinstance(tts.get("audio_bytes"), int) or tts["audio_bytes"] <= 0:
|
||||
errors.append(f"tool event {event.get('sequence')} has empty TTS audio")
|
||||
request_id = following.get("request_id")
|
||||
if not isinstance(request_id, str) or not request_id.strip():
|
||||
errors.append(f"ASR event for tool {event.get('sequence')} lacks request_id")
|
||||
elif request_id in seen_request_ids:
|
||||
errors.append(f"duplicate ASR request_id: {request_id}")
|
||||
else:
|
||||
seen_request_ids.add(request_id)
|
||||
if request_id in seen_provider_ids:
|
||||
errors.append(f"provider response_id reused by ASR: {request_id}")
|
||||
seen_provider_ids.add(request_id)
|
||||
arguments = event.get("arguments") or {}
|
||||
if event.get("tool") == "speak_publicly":
|
||||
if not str(following.get("transcript", "")).strip():
|
||||
errors.append(f"speech tool event {event.get('sequence')} has empty ASR")
|
||||
continue
|
||||
target = arguments.get("target")
|
||||
transcript = str(following.get("transcript", ""))
|
||||
if target == "none":
|
||||
if not HumanPlayerAgent._explicit_none(transcript):
|
||||
errors.append(
|
||||
f"tool event {event.get('sequence')} selected none but ASR was not an "
|
||||
f"explicit abstention: {transcript!r}"
|
||||
)
|
||||
elif target and target not in transcript.replace(" ", ""):
|
||||
# English word-number transcripts are valid too; use the production parser
|
||||
# with the report roster as candidates.
|
||||
try:
|
||||
player_count = int(report["players"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
player_count = 0
|
||||
errors.append("report players must be an integer")
|
||||
candidates = [f"P{number}" for number in range(1, player_count + 1)]
|
||||
parsed = HumanPlayerAgent._spoken_target(transcript, candidates, False)
|
||||
if parsed != target:
|
||||
errors.append(
|
||||
f"tool event {event.get('sequence')} selected {target} but ASR parsed {parsed}"
|
||||
)
|
||||
if simulator_tool_count != report.get("simulator_llm_tool_calls", simulator_tool_count):
|
||||
errors.append("report simulator_llm_tool_calls does not match trace")
|
||||
if simulator_asr_count != report.get("simulator_audio_roundtrips", simulator_asr_count):
|
||||
errors.append("report simulator_audio_roundtrips does not match trace")
|
||||
if any(isinstance(event, dict) and event.get("type") == "simulator_action_mismatch"
|
||||
for event in events):
|
||||
errors.append("trace contains simulator_action_mismatch")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"source_report": str(report_path),
|
||||
"source_report_sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"simulator_tool_events_checked": checked,
|
||||
"strict_audio_action_boundary": "pass" if checked and not errors else "fail",
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("report", type=Path)
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
result = validate(args.report)
|
||||
rendered = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
if args.output:
|
||||
args.output.write_text(rendered + "\n", encoding="utf-8")
|
||||
print(rendered)
|
||||
return 0 if result["strict_audio_action_boundary"] == "pass" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"experiment": "10-6",
|
||||
"audited_at": "2026-07-29T20:29:44+0800",
|
||||
"safety": {
|
||||
"phone_calls_placed": 0,
|
||||
"human_audio_captured": false,
|
||||
"reason": "no authorized consenting participant or endpoint was supplied"
|
||||
},
|
||||
"audio_endpoint_probe": {
|
||||
"human_audio_used": false,
|
||||
"tts_status": "fail",
|
||||
"tts_error_class": "RateLimitError",
|
||||
"tts_error_code": "insufficient_quota",
|
||||
"asr_status": "fail",
|
||||
"asr_input": "one-second generated 440 Hz WAV; no microphone or human audio",
|
||||
"asr_error_class": "RateLimitError",
|
||||
"asr_error_code": "insufficient_quota",
|
||||
"reason": "both TTS and the separate non-human synthetic ASR probe returned insufficient_quota"
|
||||
},
|
||||
"implementation_gates": {
|
||||
"consent_refusal_before_live_session_construction": {"status": "pass_by_test"},
|
||||
"one_human_plus_5_to_7_ai_roster": {"status": "pass_by_test"},
|
||||
"exact_roles_2_wolves_1_seer_1_witch": {"status": "pass_by_test"},
|
||||
"random_human_role": {"status": "pass_by_test"},
|
||||
"microphone_vad_and_real_asr_path": {"status": "implemented_not_run"},
|
||||
"real_tts_playback_path": {"status": "implemented_endpoint_probe_failed"},
|
||||
"barge_in_cancels_playback_and_transcribes": {
|
||||
"status": "pass_by_mocked_mechanism_test",
|
||||
"acceptance_note": "no microphone or human audio was used"
|
||||
},
|
||||
"spoken_night_actions_and_votes": {"status": "implemented_not_run"},
|
||||
"private_context_and_terminal_side_channel_controls": {"status": "pass_by_test"},
|
||||
"three_cycle_accounting": {"status": "pass_by_test"},
|
||||
"deterministic_judge_and_win_rule": {
|
||||
"status": "pass_by_test",
|
||||
"round_limit_behavior": "unresolved; never invents a winning faction"
|
||||
},
|
||||
"post_game_real_llm_strategy_audit": {
|
||||
"status": "pass_real_api_mechanism_supplemental_only",
|
||||
"schema_validation": "pass_by_test_and_real_ark_run; all four named criteria and evidence are required",
|
||||
"strategy_result": "fail on offline all-AI actions; not live acceptance"
|
||||
}
|
||||
},
|
||||
"acceptance_gates": {
|
||||
"authorized_human_participant": {"status": "not_run"},
|
||||
"real_human_asr": {"status": "not_run"},
|
||||
"real_ai_and_judge_tts": {"status": "fail", "reason": "configured OpenAI Audio credential returned insufficient_quota"},
|
||||
"at_least_three_complete_live_cycles": {"status": "not_run"},
|
||||
"real_llm_role_strategy_pass": {"status": "not_run"},
|
||||
"information_isolation": {"status": "pass_supplemental_only"},
|
||||
"correct_winner": {"status": "pass_supplemental_only"}
|
||||
},
|
||||
"overall_status": "incomplete"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"experiment": "10-6-fixed-abstention-boundary-probe",
|
||||
"synthetic_audio_only": true,
|
||||
"spoken_text": "I choose to abstain.",
|
||||
"asr_transcript": "I choose to abstain.",
|
||||
"explicit_abstention": true,
|
||||
"parsed_target": null,
|
||||
"provider": "OpenRouter multimodal audio API",
|
||||
"model": "google/gemini-2.5-flash",
|
||||
"response_id": "gen-1785559459-RU7zsfZiTyiceaWIFjxT",
|
||||
"usage": {
|
||||
"completion_tokens": 5,
|
||||
"prompt_tokens": 88,
|
||||
"total_tokens": 93,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"rejected_prediction_tokens": null,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": 50,
|
||||
"cached_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"video_tokens": 0
|
||||
},
|
||||
"cost": 7.39e-05,
|
||||
"is_byok": false,
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 7.39e-05,
|
||||
"upstream_inference_prompt_cost": 6.14e-05,
|
||||
"upstream_inference_completions_cost": 1.25e-05
|
||||
}
|
||||
},
|
||||
"source_audio_sha256": "7069f304dcd7e28b5b4a52b8a816f2170749ddfa437b59c99bda1d89fab762ee",
|
||||
"status": "pass"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"experiment": "10-6",
|
||||
"generated_at": "2026-07-29T18:42:35+0800",
|
||||
"acceptance_path": false,
|
||||
"mode": "offline_all_ai_supplement",
|
||||
"players": 7,
|
||||
"human_players": [],
|
||||
"role_counts": {"狼人": 2, "预言家": 1, "女巫": 1, "村民": 3},
|
||||
"completed_day_night_vote_cycles": 2,
|
||||
"winner": "狼人阵营",
|
||||
"information_isolation_pass": true,
|
||||
"strategy_audit": null,
|
||||
"voice_events": [],
|
||||
"gates": {
|
||||
"exact_6_to_8_player_role_roster": {"status": "pass"},
|
||||
"one_authorized_human_participant": {"status": "not_run"},
|
||||
"real_human_asr": {"status": "not_run"},
|
||||
"real_ai_and_judge_tts": {"status": "not_run"},
|
||||
"three_complete_cycles": {"status": "supplemental_only", "observed": 2},
|
||||
"information_isolation": {"status": "pass"},
|
||||
"real_llm_strategy_acceptance": {"status": "not_run"}
|
||||
},
|
||||
"overall_status": "supplemental_only"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"experiment": "10-6",
|
||||
"generated_at": "2026-07-29T18:16:41+0800",
|
||||
"acceptance_path": false,
|
||||
"mode": "real_llm_all_ai_diagnostic",
|
||||
"llm_provider": "Volcengine ARK",
|
||||
"llm_model": "doubao-seed-1-6-250615",
|
||||
"trace_file": "real_llm_partial_trace_2026-07-29.json",
|
||||
"players": 7,
|
||||
"role_counts": {"狼人": 2, "预言家": 1, "女巫": 1, "村民": 3},
|
||||
"observed": {
|
||||
"complete_day_night_vote_cycles": 2,
|
||||
"entered_round": 3,
|
||||
"real_model_actions_observed": [
|
||||
"werewolf kill proposals and consensus",
|
||||
"seer inspection",
|
||||
"witch heal decision",
|
||||
"public speeches",
|
||||
"public votes"
|
||||
],
|
||||
"round_2_exile": {"player": "P1", "revealed_role": "狼人"}
|
||||
},
|
||||
"termination": {
|
||||
"status": "operator_stopped",
|
||||
"reason": "one provider request remained pending and the pre-timeout run exceeded eleven minutes",
|
||||
"remediation_in_current_code": "WEREWOLF_LLM_TIMEOUT defaults to 45 seconds with one bounded retry"
|
||||
},
|
||||
"gates": {
|
||||
"real_llm_gameplay": {"status": "partial", "complete_cycles": 2},
|
||||
"three_complete_cycles": {"status": "fail", "observed": 2},
|
||||
"real_llm_strategy_acceptance": {"status": "not_run", "reason": "game did not reach post-game audit"},
|
||||
"one_authorized_human_participant": {"status": "not_run"},
|
||||
"real_human_asr": {"status": "not_run"},
|
||||
"real_ai_and_judge_tts": {"status": "not_run"}
|
||||
},
|
||||
"overall_status": "incomplete"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"experiment": "10-6",
|
||||
"mode": "real_llm_all_ai_diagnostic",
|
||||
"provider": "Volcengine ARK",
|
||||
"model": "doubao-seed-1-6-250615",
|
||||
"events": [
|
||||
{"round": 1, "phase": "night", "actor": "P1", "role": "狼人", "action": "kill_proposal", "target": "P4"},
|
||||
{"round": 1, "phase": "night", "actor": "P6", "role": "狼人", "action": "kill_proposal", "target": "P3"},
|
||||
{"round": 1, "phase": "night", "action": "wolf_consensus", "target": "P4"},
|
||||
{"round": 1, "phase": "night", "actor": "P4", "role": "预言家", "action": "inspect", "target": "P5", "result": "好人"},
|
||||
{"round": 1, "phase": "day", "actor": "P1", "role": "狼人", "action": "speech", "text": "P4出局有点突然,希望预言家能跳出来给信息。现在信息太少,大家先别乱猜,好好听后面发言。"},
|
||||
{"round": 1, "phase": "day", "actor": "P2", "role": "女巫", "action": "speech", "text": "同意P1,现在信息确实少。希望预言家能跳出来给信息,大家先别乱猜,听后面发言。"},
|
||||
{"round": 1, "phase": "day", "actor": "P3", "role": "村民", "action": "speech", "text": "同意P1P2,现在信息少,预言家该跳出来给信息。大家先冷静,听后面发言再判断,别乱猜。"},
|
||||
{"round": 1, "phase": "day", "actor": "P5", "role": "村民", "action": "speech", "text": "同意P1P2P3,信息确实少,预言家该跳出来给信息了,我会听后面发言再判断。"},
|
||||
{"round": 1, "phase": "day", "actor": "P6", "role": "狼人", "action": "speech", "text": "同意前面说的,现在信息确实少。预言家快跳出来给信息吧,我会听发言再判断。"},
|
||||
{"round": 1, "phase": "day", "actor": "P7", "role": "村民", "action": "speech", "text": "昨晚P4出局,大家发言都挺一致的。预言家快跳出来给信息吧,不然没法判断谁是狼人。"},
|
||||
{"round": 1, "phase": "vote", "votes": {"P1": "P7", "P2": "P7", "P3": "P7", "P5": "P7", "P6": "P7", "P7": "P1"}, "exiled": "P7", "revealed_role": "村民"},
|
||||
{"round": 2, "phase": "night", "actor": "P1", "role": "狼人", "action": "kill_proposal", "target": "P3"},
|
||||
{"round": 2, "phase": "night", "actor": "P6", "role": "狼人", "action": "kill_proposal", "target": "P2"},
|
||||
{"round": 2, "phase": "night", "action": "wolf_consensus", "target": "P3"},
|
||||
{"round": 2, "phase": "night", "actor": "P2", "role": "女巫", "action": "heal", "target": "P3"},
|
||||
{"round": 2, "phase": "day", "actor": "P1", "role": "狼人", "action": "speech", "text": "平安夜挺好,不知是守卫还是女巫的功劳。预言家该跳了,信息太少没法判断。"},
|
||||
{"round": 2, "phase": "day", "actor": "P2", "role": "女巫", "action": "speech", "text": "平安夜挺好,预言家该跳出来给信息了,不然大家没法判断谁是狼。"},
|
||||
{"round": 2, "phase": "day", "actor": "P3", "role": "村民", "action": "speech", "text": "平安夜信息还是少,预言家必须跳出来给信息了,不然没法判断谁是狼。"},
|
||||
{"round": 2, "phase": "day", "actor": "P5", "role": "村民", "action": "speech", "text": "平安夜预言家该跳了,P7是好人,第一天投票有问题,大家别跟风,好好分析。"},
|
||||
{"round": 2, "phase": "day", "actor": "P6", "role": "狼人", "action": "speech", "text": "平安夜挺好,预言家该跳出来了。P5说的投票问题有道理,大家后面多分析发言。"},
|
||||
{"round": 2, "phase": "vote", "votes": {"P1": "P5", "P2": "P1", "P3": "P1", "P5": "P1", "P6": "P2"}, "exiled": "P1", "revealed_role": "狼人"},
|
||||
{"round": 3, "phase": "night", "actor": "P6", "role": "狼人", "action": "kill_proposal", "target": "P5"},
|
||||
{"round": 3, "phase": "night", "action": "wolf_consensus", "target": "P5"},
|
||||
{"round": 3, "phase": "night", "actor": "P2", "role": "女巫", "action": "pending_provider_response", "target": "P5"}
|
||||
],
|
||||
"complete_cycles": 2,
|
||||
"trace_complete": false,
|
||||
"termination": "operator stopped pending provider request"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"experiment": "10-6",
|
||||
"generated_at": "2026-07-29T20:29:44+0800",
|
||||
"mode": "real_llm_audit_of_offline_all_ai_supplement",
|
||||
"acceptance_path": false,
|
||||
"human_audio_used": false,
|
||||
"provider": "ark",
|
||||
"model": "doubao-seed-1-6-250615",
|
||||
"completed_cycles": 2,
|
||||
"winner": "狼人阵营",
|
||||
"action_count": 31,
|
||||
"audit": {
|
||||
"criteria": {
|
||||
"werewolf_concealment": {
|
||||
"status": "fail",
|
||||
"evidence": "P6 speech: '我是好人,从发言看 P1 有点可疑,建议重点关注他。' (Accuses teammate P1)"
|
||||
},
|
||||
"seer_timing_and_evidence": {
|
||||
"status": "fail",
|
||||
"evidence": "P4 never reveals the known P7/P5 investigation results in its public speeches"
|
||||
},
|
||||
"villager_logical_reasoning": {
|
||||
"status": "fail",
|
||||
"evidence": "P3 says it merely feels P7 is suspicious without citing public speech or voting behavior"
|
||||
},
|
||||
"role_consistency": {
|
||||
"status": "fail",
|
||||
"evidence": "P4 withholds Seer results and P6 accuses its Werewolf teammate P1"
|
||||
}
|
||||
},
|
||||
"model_overall_pass_claim": false,
|
||||
"schema_valid": true,
|
||||
"validation_errors": [],
|
||||
"overall_pass": false
|
||||
},
|
||||
"overall_status": "supplemental_only",
|
||||
"acceptance_note": "The audit used a real text-model endpoint, but graded deterministic offline actions, had no human seat/audio, completed only two cycles, and failed all role-strategy criteria. It is not live acceptance."
|
||||
}
|
||||
+631
@@ -0,0 +1,631 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"experiment": "10-6",
|
||||
"generated_at": "2026-08-01T04:33:55+0000",
|
||||
"execution_mode": "simulated_user",
|
||||
"acceptance_path": true,
|
||||
"end_to_end_status": "pass",
|
||||
"players": 8,
|
||||
"user_players": [
|
||||
"P1"
|
||||
],
|
||||
"human_players": [],
|
||||
"simulated_user_players": [
|
||||
"P1"
|
||||
],
|
||||
"human_role_randomized_to": null,
|
||||
"simulated_user_role_randomized_to": "村民",
|
||||
"role_counts": {
|
||||
"狼人": 2,
|
||||
"预言家": 1,
|
||||
"女巫": 1,
|
||||
"村民": 4
|
||||
},
|
||||
"completed_day_night_vote_cycles": 3,
|
||||
"winner": "好人阵营",
|
||||
"information_isolation_pass": true,
|
||||
"strategy_audit": {
|
||||
"criteria": {
|
||||
"werewolf_concealment": {
|
||||
"status": "pass",
|
||||
"evidence": "P2: \"P1出局了,有点可惜。P4昨天验了P1是好人,结果P1还是走了。这说明P4的预言家身份可能确实是真的。我现在有点迷茫,大家有什么看法吗?\""
|
||||
},
|
||||
"seer_timing_and_evidence": {
|
||||
"status": "pass",
|
||||
"evidence": "P4: \"我是预言家,昨晚查验了P1,P1是好人。平安夜很正常,希望大家多发言,不要藏着掖着。\""
|
||||
},
|
||||
"villager_logical_reasoning": {
|
||||
"status": "fail",
|
||||
"evidence": "P1: \"action\": \"vote\", \"target\": \"P4\""
|
||||
},
|
||||
"role_consistency": {
|
||||
"status": "pass",
|
||||
"evidence": "P6: \"action\": \"heal\", \"target\": \"P1\""
|
||||
}
|
||||
},
|
||||
"overall_pass": false,
|
||||
"provider": "openrouter",
|
||||
"model": "google/gemini-2.5-flash",
|
||||
"model_overall_pass_claim": false,
|
||||
"schema_valid": true,
|
||||
"validation_errors": []
|
||||
},
|
||||
"strategy_audit_pass": false,
|
||||
"voice_events": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"monotonic": 6659830.16557793,
|
||||
"wall_time": "2026-08-01T04:33:10+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge-private",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.063,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r0_judge-private_1.wav",
|
||||
"audio_bytes": 290926,
|
||||
"audio_sha256": "4072ec5c58cccd2053867e5fa15cff4f0dbe2d66497a6818cc0a72c22c6e49d0"
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"monotonic": 6659834.233251573,
|
||||
"wall_time": "2026-08-01T04:33:15+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.075,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_judge_2.wav",
|
||||
"audio_bytes": 323170,
|
||||
"audio_sha256": "ad0abe1d2b2c094ea011233bb22a34482fc8bb74029a018fb679257c931668b0"
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"monotonic": 6659835.188596986,
|
||||
"wall_time": "2026-08-01T04:33:15+0000",
|
||||
"type": "simulator_llm_tool",
|
||||
"seat": "P1",
|
||||
"tool": "speak_publicly",
|
||||
"arguments": {
|
||||
"utterance": "It's a quiet start to the game with no one out. This means the wolves are either very careful or there's a strong protective role. I'll be listening closely to everyone's first statements."
|
||||
},
|
||||
"response_id": "gen-1785558795-x5xNY3w9a06bTDTdJvSM",
|
||||
"requested_model": "google/gemini-2.5-flash",
|
||||
"provider_reported_model": "google/gemini-2.5-flash",
|
||||
"usage": {
|
||||
"completion_tokens": 50,
|
||||
"prompt_tokens": 333,
|
||||
"total_tokens": 383,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"rejected_prediction_tokens": null,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": 0,
|
||||
"cached_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"video_tokens": 0
|
||||
},
|
||||
"cost": 0.0002249,
|
||||
"is_byok": false,
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 0.0002249,
|
||||
"upstream_inference_prompt_cost": 9.99e-05,
|
||||
"upstream_inference_completions_cost": 0.000125
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"sequence": 4,
|
||||
"monotonic": 6659835.270335359,
|
||||
"wall_time": "2026-08-01T04:33:16+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P1",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.079,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P1_4.wav",
|
||||
"audio_bytes": 592094,
|
||||
"audio_sha256": "e15a7521886b8c41f62c1cd0761cad11bd95c9397c04315ecb02409dad8ce355"
|
||||
},
|
||||
{
|
||||
"sequence": 5,
|
||||
"monotonic": 6659836.571264422,
|
||||
"wall_time": "2026-08-01T04:33:17+0000",
|
||||
"type": "simulator_asr",
|
||||
"provider": "OpenRouter multimodal audio API",
|
||||
"model": "google/gemini-2.5-flash",
|
||||
"request_id": "gen-1785558796-Z2MMwFI1oqcdDkcUT0gA",
|
||||
"usage": {
|
||||
"completion_tokens": 44,
|
||||
"prompt_tokens": 363,
|
||||
"total_tokens": 407,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"rejected_prediction_tokens": null,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": 325,
|
||||
"cached_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"video_tokens": 0
|
||||
},
|
||||
"cost": 0.0004464,
|
||||
"is_byok": false,
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 0.0004464,
|
||||
"upstream_inference_prompt_cost": 0.0003364,
|
||||
"upstream_inference_completions_cost": 0.00011
|
||||
}
|
||||
},
|
||||
"latency_seconds": 1.3,
|
||||
"source_audio_sha256": "e15a7521886b8c41f62c1cd0761cad11bd95c9397c04315ecb02409dad8ce355",
|
||||
"transcript": "It's a quiet start to the game with no one out. This means the wolves are either very careful or there's a strong protective role. I'll be listening closely to everyone's first statements."
|
||||
},
|
||||
{
|
||||
"sequence": 6,
|
||||
"monotonic": 6659837.423200945,
|
||||
"wall_time": "2026-08-01T04:33:18+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P2",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.075,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P2_6.wav",
|
||||
"audio_bytes": 728412,
|
||||
"audio_sha256": "b8e637c51cd20248934af7eb7fba7ddb59b963612cf539472bfa1cc0cd024146"
|
||||
},
|
||||
{
|
||||
"sequence": 7,
|
||||
"monotonic": 6659838.175782725,
|
||||
"wall_time": "2026-08-01T04:33:18+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P3",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.066,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P3_7.wav",
|
||||
"audio_bytes": 713980,
|
||||
"audio_sha256": "23904a77895ae9d1ea5e4c8fa1e5634ade79c6aef7ad9c70e24dc77953a60633"
|
||||
},
|
||||
{
|
||||
"sequence": 8,
|
||||
"monotonic": 6659838.90640096,
|
||||
"wall_time": "2026-08-01T04:33:19+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P4",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.073,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P4_8.wav",
|
||||
"audio_bytes": 770588,
|
||||
"audio_sha256": "403134dd9ccdb8f189d2d173d16f3b3e4c4c4bf616e01065b5aa2ee123fc6900"
|
||||
},
|
||||
{
|
||||
"sequence": 9,
|
||||
"monotonic": 6659839.678299638,
|
||||
"wall_time": "2026-08-01T04:33:20+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P5",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.074,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P5_9.wav",
|
||||
"audio_bytes": 956542,
|
||||
"audio_sha256": "cf5c5c1b26f4cdb884b1054e5ac2172c6b81ec620a7dc2e587b5ffbd72b834ef"
|
||||
},
|
||||
{
|
||||
"sequence": 10,
|
||||
"monotonic": 6659840.533715743,
|
||||
"wall_time": "2026-08-01T04:33:21+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P6",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.092,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P6_10.wav",
|
||||
"audio_bytes": 922906,
|
||||
"audio_sha256": "55f6f8db90bd1cc487e3febb908a306d008a1b5af5917b21f7361e83ed9aa5a8"
|
||||
},
|
||||
{
|
||||
"sequence": 11,
|
||||
"monotonic": 6659841.280212561,
|
||||
"wall_time": "2026-08-01T04:33:22+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P7",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.071,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P7_11.wav",
|
||||
"audio_bytes": 729436,
|
||||
"audio_sha256": "ba43febf7aa2134c120eb2db5d345046232fb8bd431c0aee5410659c437437fd"
|
||||
},
|
||||
{
|
||||
"sequence": 12,
|
||||
"monotonic": 6659842.26070054,
|
||||
"wall_time": "2026-08-01T04:33:23+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P8",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.071,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P8_12.wav",
|
||||
"audio_bytes": 864610,
|
||||
"audio_sha256": "653a1512a5ad1278c8da9bf027443ebcac58a21b37874b48ff9b61a37add3a9d"
|
||||
},
|
||||
{
|
||||
"sequence": 13,
|
||||
"monotonic": 6659843.83486303,
|
||||
"wall_time": "2026-08-01T04:33:24+0000",
|
||||
"type": "simulator_llm_tool",
|
||||
"seat": "P1",
|
||||
"tool": "choose_player",
|
||||
"arguments": {
|
||||
"reason": "P4声称自己是预言家,并且查验P1是好人。作为P1村民身份的我,知道P4的查验结果是正确的。但是,作为村民,我不能让狼人知道我掌握了这些信息。如果P4真的是预言家,他应该通过逻辑推理来找出狼人,而不是直接暴露自己的身份。他急于跳预言家,有可能是狼人悍跳。我先票出P4,如果P4是真预言家,那么他被出局后,狼人会继续作恶。如果P4是狼人,那么好人阵营就少了一个敌人。",
|
||||
"target": "P4"
|
||||
},
|
||||
"response_id": "gen-1785558803-pBfinsZ7kICKFK3TyU8m",
|
||||
"requested_model": "google/gemini-2.5-flash",
|
||||
"provider_reported_model": "google/gemini-2.5-flash",
|
||||
"usage": {
|
||||
"completion_tokens": 146,
|
||||
"prompt_tokens": 675,
|
||||
"total_tokens": 821,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"rejected_prediction_tokens": null,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": 0,
|
||||
"cached_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"video_tokens": 0
|
||||
},
|
||||
"cost": 0.0005675,
|
||||
"is_byok": false,
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 0.0005675,
|
||||
"upstream_inference_prompt_cost": 0.0002025,
|
||||
"upstream_inference_completions_cost": 0.000365
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"sequence": 14,
|
||||
"monotonic": 6659843.893408566,
|
||||
"wall_time": "2026-08-01T04:33:24+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P1",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.056,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P1_14.wav",
|
||||
"audio_bytes": 88244,
|
||||
"audio_sha256": "880288b17ec7695536592b91dbd83621d789d4a441e926f5312291a56c131188"
|
||||
},
|
||||
{
|
||||
"sequence": 15,
|
||||
"monotonic": 6659844.775011441,
|
||||
"wall_time": "2026-08-01T04:33:25+0000",
|
||||
"type": "simulator_asr",
|
||||
"provider": "OpenRouter multimodal audio API",
|
||||
"model": "google/gemini-2.5-flash",
|
||||
"request_id": "gen-1785558804-iwPhaJmlnUg74nxRTF4w",
|
||||
"usage": {
|
||||
"completion_tokens": 5,
|
||||
"prompt_tokens": 88,
|
||||
"total_tokens": 93,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"rejected_prediction_tokens": null,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": 50,
|
||||
"cached_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"video_tokens": 0
|
||||
},
|
||||
"cost": 7.39e-05,
|
||||
"is_byok": false,
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 7.39e-05,
|
||||
"upstream_inference_prompt_cost": 6.14e-05,
|
||||
"upstream_inference_completions_cost": 1.25e-05
|
||||
}
|
||||
},
|
||||
"latency_seconds": 0.881,
|
||||
"source_audio_sha256": "880288b17ec7695536592b91dbd83621d789d4a441e926f5312291a56c131188",
|
||||
"transcript": "I choose player four."
|
||||
},
|
||||
{
|
||||
"sequence": 16,
|
||||
"monotonic": 6659851.173196478,
|
||||
"wall_time": "2026-08-01T04:33:31+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.068,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_judge_16.wav",
|
||||
"audio_bytes": 1025148,
|
||||
"audio_sha256": "015f4cfa86113ce26137a1c852dbdd9f9b16073b4e5cbf09f969bd77c794e8bb"
|
||||
},
|
||||
{
|
||||
"sequence": 17,
|
||||
"monotonic": 6659853.731666616,
|
||||
"wall_time": "2026-08-01T04:33:34+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.061,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_judge_17.wav",
|
||||
"audio_bytes": 304082,
|
||||
"audio_sha256": "1cbcec8d528486b50106d7cd00bed6a36e37a858dc391336edd83c113a1177de"
|
||||
},
|
||||
{
|
||||
"sequence": 18,
|
||||
"monotonic": 6659854.58235228,
|
||||
"wall_time": "2026-08-01T04:33:35+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P2",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.086,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P2_18.wav",
|
||||
"audio_bytes": 1246682,
|
||||
"audio_sha256": "abf6093c3110e3f703cc3041c24d9d96abade651911e7f46815514c455b85870"
|
||||
},
|
||||
{
|
||||
"sequence": 19,
|
||||
"monotonic": 6659855.374771954,
|
||||
"wall_time": "2026-08-01T04:33:36+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P3",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.064,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P3_19.wav",
|
||||
"audio_bytes": 760284,
|
||||
"audio_sha256": "e440e40888f7c32e114dcc3f4dd4d093ea51939234c077026b5dfebcc2989d3a"
|
||||
},
|
||||
{
|
||||
"sequence": 20,
|
||||
"monotonic": 6659856.151835248,
|
||||
"wall_time": "2026-08-01T04:33:36+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P5",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.074,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P5_20.wav",
|
||||
"audio_bytes": 993010,
|
||||
"audio_sha256": "9970b3340ddbbaa73f270439755953dd19c869baf5f00d03fca8d99ed6e35fb3"
|
||||
},
|
||||
{
|
||||
"sequence": 21,
|
||||
"monotonic": 6659857.087746971,
|
||||
"wall_time": "2026-08-01T04:33:37+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P6",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.075,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P6_21.wav",
|
||||
"audio_bytes": 1166004,
|
||||
"audio_sha256": "c64a0cdd5e74d70eb6f844fe23890c7c13d30a233375b1d4402943e8110e3eb6"
|
||||
},
|
||||
{
|
||||
"sequence": 22,
|
||||
"monotonic": 6659858.029585563,
|
||||
"wall_time": "2026-08-01T04:33:38+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P7",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.071,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P7_22.wav",
|
||||
"audio_bytes": 885312,
|
||||
"audio_sha256": "f6d8c8e2da7e2f31b58e87bbf10fbfb86bc3d52d2c2978669d856bff394ac52f"
|
||||
},
|
||||
{
|
||||
"sequence": 23,
|
||||
"monotonic": 6659858.787386592,
|
||||
"wall_time": "2026-08-01T04:33:39+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P8",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.072,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P8_23.wav",
|
||||
"audio_bytes": 914014,
|
||||
"audio_sha256": "d4d5c1e1ab515c4f774cacd1fd0c17881c64b840eccbdf077a96b2a5e98f7b1e"
|
||||
},
|
||||
{
|
||||
"sequence": 24,
|
||||
"monotonic": 6659864.12203645,
|
||||
"wall_time": "2026-08-01T04:33:44+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.064,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_judge_24.wav",
|
||||
"audio_bytes": 747880,
|
||||
"audio_sha256": "8ff7f49e66d4e6d92e35a1123d9cd65bf5a61b7a3da5925b8eec2938c561a1f3"
|
||||
},
|
||||
{
|
||||
"sequence": 25,
|
||||
"monotonic": 6659865.992012885,
|
||||
"wall_time": "2026-08-01T04:33:46+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.049,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r3_judge_25.wav",
|
||||
"audio_bytes": 301324,
|
||||
"audio_sha256": "0794c0baf5b6850549e0cfc78980f8b3bc0e43f252e3ab4ff5ab62d83d1c1ef0"
|
||||
},
|
||||
{
|
||||
"sequence": 26,
|
||||
"monotonic": 6659866.904763845,
|
||||
"wall_time": "2026-08-01T04:33:47+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P5",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.065,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r3_P5_26.wav",
|
||||
"audio_bytes": 836698,
|
||||
"audio_sha256": "6233184105402a4e737d2523dc83403adf3b6c4864bbf4b2e347f2f4736e645a"
|
||||
},
|
||||
{
|
||||
"sequence": 27,
|
||||
"monotonic": 6659867.763743965,
|
||||
"wall_time": "2026-08-01T04:33:48+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P6",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.085,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r3_P6_27.wav",
|
||||
"audio_bytes": 760644,
|
||||
"audio_sha256": "80f4af4a5b30058d2083120ab0f0c2f97b5f6e3eeb2f9aa59006a068e7bfa4ee"
|
||||
},
|
||||
{
|
||||
"sequence": 28,
|
||||
"monotonic": 6659868.582208743,
|
||||
"wall_time": "2026-08-01T04:33:49+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P7",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.079,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r3_P7_28.wav",
|
||||
"audio_bytes": 1011036,
|
||||
"audio_sha256": "6a55092cd1725488ea0edcfb13911f02bf27187559384b7b524efece5bb8ad4f"
|
||||
},
|
||||
{
|
||||
"sequence": 29,
|
||||
"monotonic": 6659869.310455916,
|
||||
"wall_time": "2026-08-01T04:33:50+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P8",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.086,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r3_P8_29.wav",
|
||||
"audio_bytes": 731924,
|
||||
"audio_sha256": "8a2fd440211c3158cc6f25cfa28fce33e29bb7d5ea91af16169167432f196da3"
|
||||
},
|
||||
{
|
||||
"sequence": 30,
|
||||
"monotonic": 6659872.8176404,
|
||||
"wall_time": "2026-08-01T04:33:53+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.084,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r3_judge_30.wav",
|
||||
"audio_bytes": 769880,
|
||||
"audio_sha256": "1d5c830540a69028e80f039f3b2bc218066c4b79b9c1076701e7b5d8f98f229f"
|
||||
},
|
||||
{
|
||||
"sequence": 31,
|
||||
"monotonic": 6659872.891453664,
|
||||
"wall_time": "2026-08-01T04:33:53+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.073,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r3_judge_31.wav",
|
||||
"audio_bytes": 274520,
|
||||
"audio_sha256": "24a4432aee15b31513a2f4368bdc15b55433dfd35ff6b2c37559f2f6f22f9c2b"
|
||||
}
|
||||
],
|
||||
"voice_has_asr": true,
|
||||
"voice_has_tts": true,
|
||||
"simulator_llm_tool_calls": 2,
|
||||
"simulator_audio_roundtrips": 2,
|
||||
"barge_in_events": 0,
|
||||
"gates": {
|
||||
"exact_6_to_8_player_role_roster": {
|
||||
"status": "pass"
|
||||
},
|
||||
"one_user_seat": {
|
||||
"status": "pass"
|
||||
},
|
||||
"one_authorized_human_participant": {
|
||||
"status": "not_applicable"
|
||||
},
|
||||
"one_llm_user_simulator": {
|
||||
"status": "pass"
|
||||
},
|
||||
"real_user_input_asr": {
|
||||
"status": "pass"
|
||||
},
|
||||
"real_ai_and_judge_tts": {
|
||||
"status": "pass"
|
||||
},
|
||||
"llm_tool_to_audio_to_asr_boundary": {
|
||||
"status": "pass",
|
||||
"tool_calls": 2,
|
||||
"audio_roundtrips": 2
|
||||
},
|
||||
"three_complete_cycles": {
|
||||
"status": "pass",
|
||||
"observed": 3
|
||||
},
|
||||
"information_isolation": {
|
||||
"status": "pass"
|
||||
},
|
||||
"real_llm_strategy_acceptance": {
|
||||
"status": "fail"
|
||||
},
|
||||
"winner_determined_by_game_rule": {
|
||||
"status": "pass"
|
||||
}
|
||||
},
|
||||
"overall_status": "incomplete"
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source_report": "validation/runs/exp10-6-simulated-user-openrouter-20260801-v2/acceptance_report.json",
|
||||
"source_report_sha256": "356dbdf05b42b66583a18c1261f65fe4ccdb5f40f56d72eee1850bedf4ac56c6",
|
||||
"simulator_tool_events_checked": 2,
|
||||
"strict_audio_action_boundary": "pass",
|
||||
"errors": []
|
||||
}
|
||||
+714
@@ -0,0 +1,714 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"experiment": "10-6",
|
||||
"generated_at": "2026-08-01T04:37:51+0000",
|
||||
"execution_mode": "simulated_user",
|
||||
"acceptance_path": true,
|
||||
"end_to_end_status": "pass",
|
||||
"players": 8,
|
||||
"user_players": [
|
||||
"P1"
|
||||
],
|
||||
"human_players": [],
|
||||
"simulated_user_players": [
|
||||
"P1"
|
||||
],
|
||||
"human_role_randomized_to": null,
|
||||
"simulated_user_role_randomized_to": "村民",
|
||||
"role_counts": {
|
||||
"狼人": 2,
|
||||
"预言家": 1,
|
||||
"女巫": 1,
|
||||
"村民": 4
|
||||
},
|
||||
"completed_day_night_vote_cycles": 2,
|
||||
"winner": "好人阵营",
|
||||
"information_isolation_pass": true,
|
||||
"strategy_audit": {
|
||||
"criteria": {
|
||||
"werewolf_concealment": {
|
||||
"status": "fail",
|
||||
"evidence": "P2说P5被投出去又被刀,这是捏造信息。"
|
||||
},
|
||||
"seer_timing_and_evidence": {
|
||||
"status": "pass",
|
||||
"evidence": "我是预言家,昨晚查验了P1,P1是好人。"
|
||||
},
|
||||
"villager_logical_reasoning": {
|
||||
"status": "pass",
|
||||
"evidence": "P4是目前唯一跳预言家的,且报了P1金水,P5没有明确站边,也没有给出任何有建设性的信息,反而说 '这平安夜有点意思',发言过于随意,有划水嫌疑。"
|
||||
},
|
||||
"role_consistency": {
|
||||
"status": "fail",
|
||||
"evidence": "P2的发言很奇怪,P5昨天是被投票出局的,不是被刀出局。P2说P5被投出去又被刀,这是捏造信息。"
|
||||
}
|
||||
},
|
||||
"overall_pass": false,
|
||||
"provider": "openrouter",
|
||||
"model": "google/gemini-2.5-flash",
|
||||
"model_overall_pass_claim": false,
|
||||
"schema_valid": true,
|
||||
"validation_errors": []
|
||||
},
|
||||
"strategy_audit_pass": false,
|
||||
"voice_events": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"monotonic": 6660064.938664906,
|
||||
"wall_time": "2026-08-01T04:37:05+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge-private",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.066,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r0_judge-private_1.wav",
|
||||
"audio_bytes": 290926,
|
||||
"audio_sha256": "4072ec5c58cccd2053867e5fa15cff4f0dbe2d66497a6818cc0a72c22c6e49d0"
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"monotonic": 6660069.687056506,
|
||||
"wall_time": "2026-08-01T04:37:10+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.073,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_judge_2.wav",
|
||||
"audio_bytes": 323170,
|
||||
"audio_sha256": "ad0abe1d2b2c094ea011233bb22a34482fc8bb74029a018fb679257c931668b0"
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"monotonic": 6660071.70736767,
|
||||
"wall_time": "2026-08-01T04:37:12+0000",
|
||||
"type": "simulator_llm_tool",
|
||||
"seat": "P1",
|
||||
"tool": "speak_publicly",
|
||||
"arguments": {
|
||||
"utterance": "Good morning everyone. Since it's a peaceful night with no deaths, we need to be extra careful today. I'm a villager looking for evidence to identify the wolves. Let's start by hearing from anyone who has information to share, especially if there are any seers who got results last night."
|
||||
},
|
||||
"response_id": "gen-1785559030-spNZyA0QkdAqVUCAyzRH",
|
||||
"requested_model": "anthropic/claude-sonnet-4",
|
||||
"provider_reported_model": "anthropic/claude-sonnet-4",
|
||||
"usage": {
|
||||
"completion_tokens": 93,
|
||||
"prompt_tokens": 1149,
|
||||
"total_tokens": 1242,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"rejected_prediction_tokens": null,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": 0,
|
||||
"cached_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"video_tokens": 0
|
||||
},
|
||||
"cost": 0.004842,
|
||||
"is_byok": false,
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 0.004842,
|
||||
"upstream_inference_prompt_cost": 0.003447,
|
||||
"upstream_inference_completions_cost": 0.001395
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"sequence": 4,
|
||||
"monotonic": 6660071.786439727,
|
||||
"wall_time": "2026-08-01T04:37:12+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P1",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.073,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P1_4.wav",
|
||||
"audio_bytes": 943042,
|
||||
"audio_sha256": "25c321db62b76146045007dde047aa9d753a065144f3e156b62c5a4a4010fc69"
|
||||
},
|
||||
{
|
||||
"sequence": 5,
|
||||
"monotonic": 6660073.073498523,
|
||||
"wall_time": "2026-08-01T04:37:13+0000",
|
||||
"type": "simulator_asr",
|
||||
"provider": "OpenRouter multimodal audio API",
|
||||
"model": "google/gemini-2.5-flash",
|
||||
"request_id": "gen-1785559032-tErOo2DoQoQhH2M1dmHX",
|
||||
"usage": {
|
||||
"completion_tokens": 64,
|
||||
"prompt_tokens": 538,
|
||||
"total_tokens": 602,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"rejected_prediction_tokens": null,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": 500,
|
||||
"cached_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"video_tokens": 0
|
||||
},
|
||||
"cost": 0.0006714,
|
||||
"is_byok": false,
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 0.0006714,
|
||||
"upstream_inference_prompt_cost": 0.0005114,
|
||||
"upstream_inference_completions_cost": 0.00016
|
||||
}
|
||||
},
|
||||
"latency_seconds": 1.286,
|
||||
"source_audio_sha256": "25c321db62b76146045007dde047aa9d753a065144f3e156b62c5a4a4010fc69",
|
||||
"transcript": "Good morning everyone. Since it's a peaceful night with no deaths, we need to be extra careful today. I'm a villager looking for evidence to identify the wolves. Let's start by hearing from anyone who has information to share, especially if there are any seers who got results last night."
|
||||
},
|
||||
{
|
||||
"sequence": 6,
|
||||
"monotonic": 6660073.961132782,
|
||||
"wall_time": "2026-08-01T04:37:14+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P2",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.074,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P2_6.wav",
|
||||
"audio_bytes": 907964,
|
||||
"audio_sha256": "1a19837651669d30d773f7f6c5136e31e28504766f162a1acf9e5a88c76a70a5"
|
||||
},
|
||||
{
|
||||
"sequence": 7,
|
||||
"monotonic": 6660074.793108945,
|
||||
"wall_time": "2026-08-01T04:37:15+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P3",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.074,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P3_7.wav",
|
||||
"audio_bytes": 913618,
|
||||
"audio_sha256": "acb7323818965eeaa5d27fab666a16679afed28d09e3adafe58e840be2de6928"
|
||||
},
|
||||
{
|
||||
"sequence": 8,
|
||||
"monotonic": 6660075.679470788,
|
||||
"wall_time": "2026-08-01T04:37:16+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P4",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.057,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P4_8.wav",
|
||||
"audio_bytes": 929880,
|
||||
"audio_sha256": "c52b74cfca755abac59489ac2d0594f1dfc1e71c3c53b909965dd452e72a13d6"
|
||||
},
|
||||
{
|
||||
"sequence": 9,
|
||||
"monotonic": 6660076.602908004,
|
||||
"wall_time": "2026-08-01T04:37:17+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P5",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.075,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P5_9.wav",
|
||||
"audio_bytes": 882522,
|
||||
"audio_sha256": "862216fb788092ada3ec5173c3f47906fc4f3e1ba157f4c076a6d1eccac6084f"
|
||||
},
|
||||
{
|
||||
"sequence": 10,
|
||||
"monotonic": 6660077.34997115,
|
||||
"wall_time": "2026-08-01T04:37:18+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P6",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.073,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P6_10.wav",
|
||||
"audio_bytes": 763248,
|
||||
"audio_sha256": "048178ad618f1cf986fa96ecead02081b4939749d466c6df5c2973fce9040a08"
|
||||
},
|
||||
{
|
||||
"sequence": 11,
|
||||
"monotonic": 6660078.475235486,
|
||||
"wall_time": "2026-08-01T04:37:19+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P7",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.076,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P7_11.wav",
|
||||
"audio_bytes": 1061138,
|
||||
"audio_sha256": "d935eb37460712700603b17bc90e7f2f817df54cd2156aab671fb45df7584e9b"
|
||||
},
|
||||
{
|
||||
"sequence": 12,
|
||||
"monotonic": 6660079.383143585,
|
||||
"wall_time": "2026-08-01T04:37:20+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P8",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.072,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P8_12.wav",
|
||||
"audio_bytes": 931296,
|
||||
"audio_sha256": "1574dfaea95529d8e30eefad8e58d53ce8d743ada9cbae8a21dc46bc50c0382e"
|
||||
},
|
||||
{
|
||||
"sequence": 13,
|
||||
"monotonic": 6660081.716445442,
|
||||
"wall_time": "2026-08-01T04:37:22+0000",
|
||||
"type": "simulator_llm_tool",
|
||||
"seat": "P1",
|
||||
"tool": "choose_player",
|
||||
"arguments": {
|
||||
"target": "none",
|
||||
"reason": "P4跳预言家验我金水,在没有对跳的情况下,我倾向相信P4的身份。其他玩家发言都比较正常,没有明显的狼人破绽。第一天信息有限,不宜盲投,选择弃票观察。"
|
||||
},
|
||||
"response_id": "gen-1785559040-oO8E4LBzbfEGuuvr3vVK",
|
||||
"requested_model": "anthropic/claude-sonnet-4",
|
||||
"provider_reported_model": "anthropic/claude-sonnet-4",
|
||||
"usage": {
|
||||
"completion_tokens": 132,
|
||||
"prompt_tokens": 1700,
|
||||
"total_tokens": 1832,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"rejected_prediction_tokens": null,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": 0,
|
||||
"cached_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"video_tokens": 0
|
||||
},
|
||||
"cost": 0.00708,
|
||||
"is_byok": false,
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 0.00708,
|
||||
"upstream_inference_prompt_cost": 0.0051,
|
||||
"upstream_inference_completions_cost": 0.00198
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"sequence": 14,
|
||||
"monotonic": 6660081.764605112,
|
||||
"wall_time": "2026-08-01T04:37:22+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P1",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.043,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P1_14.wav",
|
||||
"audio_bytes": 42524,
|
||||
"audio_sha256": "0d9ee3c2b7690fe0d2cf41b0450755995e66d3d360b76d7e4173086c87795352"
|
||||
},
|
||||
{
|
||||
"sequence": 15,
|
||||
"monotonic": 6660082.798986178,
|
||||
"wall_time": "2026-08-01T04:37:23+0000",
|
||||
"type": "simulator_asr",
|
||||
"provider": "OpenRouter multimodal audio API",
|
||||
"model": "google/gemini-2.5-flash",
|
||||
"request_id": "gen-1785559042-1GZNLMYIWInzO59ApGnk",
|
||||
"usage": {
|
||||
"completion_tokens": 4,
|
||||
"prompt_tokens": 63,
|
||||
"total_tokens": 67,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"rejected_prediction_tokens": null,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": 25,
|
||||
"cached_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"video_tokens": 0
|
||||
},
|
||||
"cost": 4.64e-05,
|
||||
"is_byok": false,
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 4.64e-05,
|
||||
"upstream_inference_prompt_cost": 3.64e-05,
|
||||
"upstream_inference_completions_cost": 1e-05
|
||||
}
|
||||
},
|
||||
"latency_seconds": 1.034,
|
||||
"source_audio_sha256": "0d9ee3c2b7690fe0d2cf41b0450755995e66d3d360b76d7e4173086c87795352",
|
||||
"transcript": "P1 is not"
|
||||
},
|
||||
{
|
||||
"sequence": 16,
|
||||
"monotonic": 6660089.250340574,
|
||||
"wall_time": "2026-08-01T04:37:30+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.08,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_judge_16.wav",
|
||||
"audio_bytes": 889394,
|
||||
"audio_sha256": "4ff2b73cad8e108f291f1b8112d78c2a2b22e04ef69862263dc8a8ad5dabebb5"
|
||||
},
|
||||
{
|
||||
"sequence": 17,
|
||||
"monotonic": 6660091.794800593,
|
||||
"wall_time": "2026-08-01T04:37:32+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.062,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_judge_17.wav",
|
||||
"audio_bytes": 304072,
|
||||
"audio_sha256": "601d85e481cab7a3d0f97ada2b21b25ecaf1db4242837389196e332aa8b3d16c"
|
||||
},
|
||||
{
|
||||
"sequence": 18,
|
||||
"monotonic": 6660094.03442275,
|
||||
"wall_time": "2026-08-01T04:37:34+0000",
|
||||
"type": "simulator_llm_tool",
|
||||
"seat": "P1",
|
||||
"tool": "speak_publicly",
|
||||
"arguments": {
|
||||
"utterance": "This is very telling. P4 claimed to be the seer and verified me as good, then got killed last night - classic wolf move to eliminate the real seer. Since P4 correctly identified me as good, this confirms he was likely the true seer. We should focus on those who voted for P4 yesterday, as wolves often try to eliminate seers early."
|
||||
},
|
||||
"response_id": "gen-1785559052-ot6EVpz9tzQVSv3Vc3nK",
|
||||
"requested_model": "anthropic/claude-sonnet-4",
|
||||
"provider_reported_model": "anthropic/claude-sonnet-4",
|
||||
"usage": {
|
||||
"completion_tokens": 111,
|
||||
"prompt_tokens": 1726,
|
||||
"total_tokens": 1837,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"rejected_prediction_tokens": null,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": 0,
|
||||
"cached_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"video_tokens": 0
|
||||
},
|
||||
"cost": 0.006843,
|
||||
"is_byok": false,
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 0.006843,
|
||||
"upstream_inference_prompt_cost": 0.005178,
|
||||
"upstream_inference_completions_cost": 0.001665
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"sequence": 19,
|
||||
"monotonic": 6660094.128008126,
|
||||
"wall_time": "2026-08-01T04:37:34+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P1",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.093,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P1_19.wav",
|
||||
"audio_bytes": 1154406,
|
||||
"audio_sha256": "e6d9c0274102424b9c9a14dce70e357319498a4ba97b40613e17e5024be3a5ff"
|
||||
},
|
||||
{
|
||||
"sequence": 20,
|
||||
"monotonic": 6660095.610149198,
|
||||
"wall_time": "2026-08-01T04:37:36+0000",
|
||||
"type": "simulator_asr",
|
||||
"provider": "OpenRouter multimodal audio API",
|
||||
"model": "google/gemini-2.5-flash",
|
||||
"request_id": "gen-1785559054-DXFJigc5J4fcYV5NfWkJ",
|
||||
"usage": {
|
||||
"completion_tokens": 73,
|
||||
"prompt_tokens": 663,
|
||||
"total_tokens": 736,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"rejected_prediction_tokens": null,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": 625,
|
||||
"cached_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"video_tokens": 0
|
||||
},
|
||||
"cost": 0.0008189,
|
||||
"is_byok": false,
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 0.0008189,
|
||||
"upstream_inference_prompt_cost": 0.0006364,
|
||||
"upstream_inference_completions_cost": 0.0001825
|
||||
}
|
||||
},
|
||||
"latency_seconds": 1.481,
|
||||
"source_audio_sha256": "e6d9c0274102424b9c9a14dce70e357319498a4ba97b40613e17e5024be3a5ff",
|
||||
"transcript": "This is very telling. P4 claimed to be the seer and verified me as good, then got killed last night. Classic wolf move to eliminate the real seer. Since P4 correctly identified me as good, this confirms he was likely the true seer. We should focus on those who voted for P4 yesterday, as wolves often try to eliminate seers early."
|
||||
},
|
||||
{
|
||||
"sequence": 21,
|
||||
"monotonic": 6660096.573507839,
|
||||
"wall_time": "2026-08-01T04:37:37+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P2",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.086,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P2_21.wav",
|
||||
"audio_bytes": 1196966,
|
||||
"audio_sha256": "f96f0c81afb44f2eb68637938711ebc310f28a88106d3c0639ea865451342339"
|
||||
},
|
||||
{
|
||||
"sequence": 22,
|
||||
"monotonic": 6660097.313278559,
|
||||
"wall_time": "2026-08-01T04:37:38+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P3",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.073,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P3_22.wav",
|
||||
"audio_bytes": 982662,
|
||||
"audio_sha256": "841e56e726756c824f80bc2e7b2534a5e79da23b841f9c371d7d36836ec49ea5"
|
||||
},
|
||||
{
|
||||
"sequence": 23,
|
||||
"monotonic": 6660098.07019322,
|
||||
"wall_time": "2026-08-01T04:37:38+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P6",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.086,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P6_23.wav",
|
||||
"audio_bytes": 978756,
|
||||
"audio_sha256": "4fb7d65fae0b19898cbb55e891cba276229805f4fb2e7bab0b59c0237a3334be"
|
||||
},
|
||||
{
|
||||
"sequence": 24,
|
||||
"monotonic": 6660098.725929254,
|
||||
"wall_time": "2026-08-01T04:37:39+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P7",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.063,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P7_24.wav",
|
||||
"audio_bytes": 728740,
|
||||
"audio_sha256": "c897346a14fb6f685afe015ca8d7ba10a72b160de10f3ca36e9f1990b5f8ca2e"
|
||||
},
|
||||
{
|
||||
"sequence": 25,
|
||||
"monotonic": 6660099.518123498,
|
||||
"wall_time": "2026-08-01T04:37:40+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P8",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.067,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P8_25.wav",
|
||||
"audio_bytes": 1066310,
|
||||
"audio_sha256": "8a5e95f8107dce98f4ce16674ca3ffc2ca91c9e2342ff0be765211ad180a0e8a"
|
||||
},
|
||||
{
|
||||
"sequence": 26,
|
||||
"monotonic": 6660103.414020963,
|
||||
"wall_time": "2026-08-01T04:37:44+0000",
|
||||
"type": "simulator_llm_tool",
|
||||
"seat": "P1",
|
||||
"tool": "choose_player",
|
||||
"arguments": {
|
||||
"target": "none",
|
||||
"reason": "基于目前信息,P4作为真预言家被刀,P5狼人身份已确认。其他玩家发言都相对合理,都认同P4是真预言家、P5是狼人的判断。没有明显的狼人特征或矛盾发言,需要更多信息才能做出准确判断,暂时弃票观察。"
|
||||
},
|
||||
"response_id": "gen-1785559060-iKXuqTxW4nhB2usi2TXP",
|
||||
"requested_model": "anthropic/claude-sonnet-4",
|
||||
"provider_reported_model": "anthropic/claude-sonnet-4",
|
||||
"usage": {
|
||||
"completion_tokens": 155,
|
||||
"prompt_tokens": 2175,
|
||||
"total_tokens": 2330,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"rejected_prediction_tokens": null,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": 0,
|
||||
"cached_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"video_tokens": 0
|
||||
},
|
||||
"cost": 0.00885,
|
||||
"is_byok": false,
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 0.00885,
|
||||
"upstream_inference_prompt_cost": 0.006525,
|
||||
"upstream_inference_completions_cost": 0.002325
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"sequence": 27,
|
||||
"monotonic": 6660103.488481883,
|
||||
"wall_time": "2026-08-01T04:37:44+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P1",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.067,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P1_27.wav",
|
||||
"audio_bytes": 42524,
|
||||
"audio_sha256": "0d9ee3c2b7690fe0d2cf41b0450755995e66d3d360b76d7e4173086c87795352"
|
||||
},
|
||||
{
|
||||
"sequence": 28,
|
||||
"monotonic": 6660104.541520248,
|
||||
"wall_time": "2026-08-01T04:37:45+0000",
|
||||
"type": "simulator_asr",
|
||||
"provider": "OpenRouter multimodal audio API",
|
||||
"model": "google/gemini-2.5-flash",
|
||||
"request_id": "gen-1785559064-K9ELBofaZckyxrbbYLPr",
|
||||
"usage": {
|
||||
"completion_tokens": 4,
|
||||
"prompt_tokens": 63,
|
||||
"total_tokens": 67,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"rejected_prediction_tokens": null,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": 25,
|
||||
"cached_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"video_tokens": 0
|
||||
},
|
||||
"cost": 4.64e-05,
|
||||
"is_byok": false,
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 4.64e-05,
|
||||
"upstream_inference_prompt_cost": 3.64e-05,
|
||||
"upstream_inference_completions_cost": 1e-05
|
||||
}
|
||||
},
|
||||
"latency_seconds": 1.053,
|
||||
"source_audio_sha256": "0d9ee3c2b7690fe0d2cf41b0450755995e66d3d360b76d7e4173086c87795352",
|
||||
"transcript": "P1 is not"
|
||||
},
|
||||
{
|
||||
"sequence": 29,
|
||||
"monotonic": 6660108.910838712,
|
||||
"wall_time": "2026-08-01T04:37:49+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.084,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_judge_29.wav",
|
||||
"audio_bytes": 751448,
|
||||
"audio_sha256": "ce1a826268133a4331c63c1d9ed13aa7663a5ec76d9fbd319411e8dfa09aec18"
|
||||
},
|
||||
{
|
||||
"sequence": 30,
|
||||
"monotonic": 6660108.973700089,
|
||||
"wall_time": "2026-08-01T04:37:49+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-en-us",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.057,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_judge_30.wav",
|
||||
"audio_bytes": 274520,
|
||||
"audio_sha256": "24a4432aee15b31513a2f4368bdc15b55433dfd35ff6b2c37559f2f6f22f9c2b"
|
||||
}
|
||||
],
|
||||
"voice_has_asr": true,
|
||||
"voice_has_tts": true,
|
||||
"simulator_llm_tool_calls": 4,
|
||||
"simulator_audio_roundtrips": 4,
|
||||
"barge_in_events": 0,
|
||||
"gates": {
|
||||
"exact_6_to_8_player_role_roster": {
|
||||
"status": "pass"
|
||||
},
|
||||
"one_user_seat": {
|
||||
"status": "pass"
|
||||
},
|
||||
"one_authorized_human_participant": {
|
||||
"status": "not_applicable"
|
||||
},
|
||||
"one_llm_user_simulator": {
|
||||
"status": "pass"
|
||||
},
|
||||
"real_user_input_asr": {
|
||||
"status": "pass"
|
||||
},
|
||||
"real_ai_and_judge_tts": {
|
||||
"status": "pass"
|
||||
},
|
||||
"llm_tool_to_audio_to_asr_boundary": {
|
||||
"status": "pass",
|
||||
"tool_calls": 4,
|
||||
"audio_roundtrips": 4
|
||||
},
|
||||
"three_complete_cycles": {
|
||||
"status": "fail",
|
||||
"observed": 2
|
||||
},
|
||||
"information_isolation": {
|
||||
"status": "pass"
|
||||
},
|
||||
"real_llm_strategy_acceptance": {
|
||||
"status": "fail"
|
||||
},
|
||||
"winner_determined_by_game_rule": {
|
||||
"status": "pass"
|
||||
}
|
||||
},
|
||||
"overall_status": "incomplete"
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source_report": "validation/runs/exp10-6-simulated-user-openrouter-20260801-v3/acceptance_report.json",
|
||||
"source_report_sha256": "5344af21b205f1cd51c013a0d8df49e7f56385745c6186c6c3918fb802a07ffb",
|
||||
"simulator_tool_events_checked": 4,
|
||||
"strict_audio_action_boundary": "fail",
|
||||
"errors": [
|
||||
"tool event 13 selected none but ASR was not an explicit abstention: 'P1 is not'",
|
||||
"tool event 26 selected none but ASR was not an explicit abstention: 'P1 is not'"
|
||||
]
|
||||
}
|
||||
+443
@@ -0,0 +1,443 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"experiment": "10-6",
|
||||
"generated_at": "2026-08-01T04:32:10+0000",
|
||||
"execution_mode": "simulated_user",
|
||||
"acceptance_path": true,
|
||||
"end_to_end_status": "pass",
|
||||
"players": 8,
|
||||
"user_players": [
|
||||
"P1"
|
||||
],
|
||||
"human_players": [],
|
||||
"simulated_user_players": [
|
||||
"P1"
|
||||
],
|
||||
"human_role_randomized_to": null,
|
||||
"simulated_user_role_randomized_to": "女巫",
|
||||
"role_counts": {
|
||||
"狼人": 2,
|
||||
"预言家": 1,
|
||||
"女巫": 1,
|
||||
"村民": 4
|
||||
},
|
||||
"completed_day_night_vote_cycles": 2,
|
||||
"winner": "好人阵营",
|
||||
"information_isolation_pass": true,
|
||||
"strategy_audit": {
|
||||
"criteria": {
|
||||
"werewolf_concealment": {
|
||||
"status": "pass",
|
||||
"evidence": "P7: \"P5预言家验了P1,P1是好人,结果P1还出局了,那P5是真的预言家吗?我觉得P5的预言家身份有点可疑。\" P8: \"P5说他是预言家,验了P1是好人,结果P1就出局了,这确实挺奇怪的。P5的预言家身份我也有点怀疑,希望P5能给出更多解释。\""
|
||||
},
|
||||
"seer_timing_and_evidence": {
|
||||
"status": "pass",
|
||||
"evidence": "P5: \"我是预言家,昨晚查验了P1,P1是好人。P1出局,很明显是狼人悍跳预言家刀的。请悍跳狼自爆,否则我将继续验人。\""
|
||||
},
|
||||
"villager_logical_reasoning": {
|
||||
"status": "pass",
|
||||
"evidence": "P2: \"P1出局有点意外,我作为村民,会仔细听大家的发言。希望狼人能早点暴露出来,大家可以多聊聊P1出局的原因。\" P3: \"P1出局确实有点出乎意料。P2说他是村民,我会先观察。大家可以多聊聊P1出局的原因,看看能不能找到狼人的线索。\""
|
||||
},
|
||||
"role_consistency": {
|
||||
"status": "pass",
|
||||
"evidence": "P5 (Seer) inspected P1 and P2, and in public speech reported P1 as good. P7 and P8 (Werewolves) proposed killing P1 and P5, and in public speech cast doubt on the Seer. P2, P3, P4, P6 (Villagers) discussed the dead player and the Seer's claim, then voted for P7 after P5's revelation."
|
||||
}
|
||||
},
|
||||
"overall_pass": true,
|
||||
"provider": "openrouter",
|
||||
"model": "google/gemini-2.5-flash",
|
||||
"model_overall_pass_claim": true,
|
||||
"schema_valid": true,
|
||||
"validation_errors": []
|
||||
},
|
||||
"strategy_audit_pass": true,
|
||||
"voice_events": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"monotonic": 6659738.807942813,
|
||||
"wall_time": "2026-08-01T04:31:39+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge-private",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.061,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r0_judge-private_1.wav",
|
||||
"audio_bytes": 290926,
|
||||
"audio_sha256": "4072ec5c58cccd2053867e5fa15cff4f0dbe2d66497a6818cc0a72c22c6e49d0"
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"monotonic": 6659741.819194071,
|
||||
"wall_time": "2026-08-01T04:31:42+0000",
|
||||
"type": "simulator_llm_tool",
|
||||
"seat": "P1",
|
||||
"tool": "choose_player",
|
||||
"arguments": {
|
||||
"reason": "第一晚被刀的是我自己,女巫不能自救,所以解药无法使用。毒药第一晚先不使用,观察一下其他玩家的发言。",
|
||||
"target": "none"
|
||||
},
|
||||
"response_id": "gen-1785558701-KGd5rQJmxY9VuJKLkL4I",
|
||||
"requested_model": "google/gemini-2.5-flash",
|
||||
"provider_reported_model": "google/gemini-2.5-flash",
|
||||
"usage": {
|
||||
"completion_tokens": 40,
|
||||
"prompt_tokens": 395,
|
||||
"total_tokens": 435,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"rejected_prediction_tokens": null,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": 0,
|
||||
"cached_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"video_tokens": 0
|
||||
},
|
||||
"cost": 0.0002185,
|
||||
"is_byok": false,
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 0.0002185,
|
||||
"upstream_inference_prompt_cost": 0.0001185,
|
||||
"upstream_inference_completions_cost": 0.0001
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"monotonic": 6659741.881685822,
|
||||
"wall_time": "2026-08-01T04:31:42+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P1",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.062,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P1_3.wav",
|
||||
"audio_bytes": 42524,
|
||||
"audio_sha256": "0d9ee3c2b7690fe0d2cf41b0450755995e66d3d360b76d7e4173086c87795352"
|
||||
},
|
||||
{
|
||||
"sequence": 4,
|
||||
"monotonic": 6659742.959315685,
|
||||
"wall_time": "2026-08-01T04:31:43+0000",
|
||||
"type": "simulator_asr",
|
||||
"provider": "OpenRouter multimodal audio API",
|
||||
"model": "google/gemini-2.5-flash",
|
||||
"request_id": "gen-1785558702-Gp3xq6Xf5Sx6pFLwLNi3",
|
||||
"usage": {
|
||||
"completion_tokens": 4,
|
||||
"prompt_tokens": 63,
|
||||
"total_tokens": 67,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"rejected_prediction_tokens": null,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": 25,
|
||||
"cached_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"video_tokens": 0
|
||||
},
|
||||
"cost": 4.64e-05,
|
||||
"is_byok": false,
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 4.64e-05,
|
||||
"upstream_inference_prompt_cost": 3.64e-05,
|
||||
"upstream_inference_completions_cost": 1e-05
|
||||
}
|
||||
},
|
||||
"latency_seconds": 1.077,
|
||||
"source_audio_sha256": "0d9ee3c2b7690fe0d2cf41b0450755995e66d3d360b76d7e4173086c87795352",
|
||||
"transcript": "P1 is not"
|
||||
},
|
||||
{
|
||||
"sequence": 5,
|
||||
"monotonic": 6659743.022028705,
|
||||
"wall_time": "2026-08-01T04:31:43+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.054,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_judge_5.wav",
|
||||
"audio_bytes": 304082,
|
||||
"audio_sha256": "1cbcec8d528486b50106d7cd00bed6a36e37a858dc391336edd83c113a1177de"
|
||||
},
|
||||
{
|
||||
"sequence": 6,
|
||||
"monotonic": 6659743.857484956,
|
||||
"wall_time": "2026-08-01T04:31:44+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P2",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.09,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P2_6.wav",
|
||||
"audio_bytes": 914730,
|
||||
"audio_sha256": "08cda30fad499eeb20caa8876579741e514d286f7db0ee999555673e5a8ce41d"
|
||||
},
|
||||
{
|
||||
"sequence": 7,
|
||||
"monotonic": 6659744.760075835,
|
||||
"wall_time": "2026-08-01T04:31:45+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P3",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.065,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P3_7.wav",
|
||||
"audio_bytes": 961736,
|
||||
"audio_sha256": "fbacc9c4139086a6267f1f5e7668a83d81feed40ce5d8afcd915d32eaf7f2379"
|
||||
},
|
||||
{
|
||||
"sequence": 8,
|
||||
"monotonic": 6659745.623963103,
|
||||
"wall_time": "2026-08-01T04:31:46+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P4",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.08,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P4_8.wav",
|
||||
"audio_bytes": 1176774,
|
||||
"audio_sha256": "a606133137d7241bcb8d232a93b8563784a338187df76d2ef124c42938734c70"
|
||||
},
|
||||
{
|
||||
"sequence": 9,
|
||||
"monotonic": 6659746.469986224,
|
||||
"wall_time": "2026-08-01T04:31:47+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P5",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.093,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P5_9.wav",
|
||||
"audio_bytes": 991594,
|
||||
"audio_sha256": "1258edf290a49d1ce8e9577389bc04214ce129c7817ea0f4fc5c3ee9d7800250"
|
||||
},
|
||||
{
|
||||
"sequence": 10,
|
||||
"monotonic": 6659747.318732785,
|
||||
"wall_time": "2026-08-01T04:31:48+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P6",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.072,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P6_10.wav",
|
||||
"audio_bytes": 1154468,
|
||||
"audio_sha256": "5227d3e1be790831250156417420c5395e5ba56b2fdf10914ea1842e1e72e718"
|
||||
},
|
||||
{
|
||||
"sequence": 11,
|
||||
"monotonic": 6659748.179338652,
|
||||
"wall_time": "2026-08-01T04:31:48+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P7",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.057,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P7_11.wav",
|
||||
"audio_bytes": 956014,
|
||||
"audio_sha256": "34dfdd2b78ceb17facf82c64fa7d2a8bd5c43c7f53fb0757e32d5640ad308b46"
|
||||
},
|
||||
{
|
||||
"sequence": 12,
|
||||
"monotonic": 6659749.175230099,
|
||||
"wall_time": "2026-08-01T04:31:49+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P8",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.078,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_P8_12.wav",
|
||||
"audio_bytes": 1112008,
|
||||
"audio_sha256": "b23c208fe1a7af1187c8a3db3e32ac87cc995e75286c6a82fe4fb9cb6da8f24e"
|
||||
},
|
||||
{
|
||||
"sequence": 13,
|
||||
"monotonic": 6659755.472674429,
|
||||
"wall_time": "2026-08-01T04:31:56+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.075,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r1_judge_13.wav",
|
||||
"audio_bytes": 774880,
|
||||
"audio_sha256": "91d6eddf38874576842938ba4d040160a8c3320d97ea19f06612a60854fa1273"
|
||||
},
|
||||
{
|
||||
"sequence": 14,
|
||||
"monotonic": 6659757.175891685,
|
||||
"wall_time": "2026-08-01T04:31:57+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.07,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_judge_14.wav",
|
||||
"audio_bytes": 307294,
|
||||
"audio_sha256": "fd3fe62cf32b3536394bad76d25c66cf22b9efca4f48469cb82179b11f8811e8"
|
||||
},
|
||||
{
|
||||
"sequence": 15,
|
||||
"monotonic": 6659758.170954376,
|
||||
"wall_time": "2026-08-01T04:31:58+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P2",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.082,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P2_15.wav",
|
||||
"audio_bytes": 1354938,
|
||||
"audio_sha256": "6043e972710e130baaf98131172067d9fe7cb69217e10e5a55a3825f13c97e13"
|
||||
},
|
||||
{
|
||||
"sequence": 16,
|
||||
"monotonic": 6659759.243308116,
|
||||
"wall_time": "2026-08-01T04:32:00+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P3",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.095,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P3_16.wav",
|
||||
"audio_bytes": 1134002,
|
||||
"audio_sha256": "115ddceb29320bbe2e2871a60bcc1e4f6e17ab1ba810378cd13d1067c5a38d84"
|
||||
},
|
||||
{
|
||||
"sequence": 17,
|
||||
"monotonic": 6659760.192742183,
|
||||
"wall_time": "2026-08-01T04:32:00+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P4",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.072,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P4_17.wav",
|
||||
"audio_bytes": 1207868,
|
||||
"audio_sha256": "4aca0b0da9f3d87bc222a5297d1172b8892bd3f62510a57180ac74a03d962941"
|
||||
},
|
||||
{
|
||||
"sequence": 18,
|
||||
"monotonic": 6659760.991943794,
|
||||
"wall_time": "2026-08-01T04:32:01+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P6",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.08,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P6_18.wav",
|
||||
"audio_bytes": 1034974,
|
||||
"audio_sha256": "5b3cecb54526a187a246b62c18c2701fab5dd5aaf2e715a0a83c4b93b28760d7"
|
||||
},
|
||||
{
|
||||
"sequence": 19,
|
||||
"monotonic": 6659761.881699391,
|
||||
"wall_time": "2026-08-01T04:32:02+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "P8",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.07,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_P8_19.wav",
|
||||
"audio_bytes": 1114376,
|
||||
"audio_sha256": "168dd458259cf3d405c401a50a83ec05598423735e653083ad9729846fd6621e"
|
||||
},
|
||||
{
|
||||
"sequence": 20,
|
||||
"monotonic": 6659766.873318817,
|
||||
"wall_time": "2026-08-01T04:32:07+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.074,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_judge_20.wav",
|
||||
"audio_bytes": 749332,
|
||||
"audio_sha256": "f010a7c2f766bcd50ca438d0cd31caec26f56856fd6696961cbe27db96160bb4"
|
||||
},
|
||||
{
|
||||
"sequence": 21,
|
||||
"monotonic": 6659766.930157353,
|
||||
"wall_time": "2026-08-01T04:32:07+0000",
|
||||
"type": "tts_ready",
|
||||
"speaker": "judge",
|
||||
"provider": "local espeak",
|
||||
"model": "espeak-zh",
|
||||
"request_id": null,
|
||||
"latency_seconds": 0.051,
|
||||
"file": "/home/ubuntu/ai-agent-book/chapter10/voice-werewolf/audio/r2_judge_21.wav",
|
||||
"audio_bytes": 274520,
|
||||
"audio_sha256": "24a4432aee15b31513a2f4368bdc15b55433dfd35ff6b2c37559f2f6f22f9c2b"
|
||||
}
|
||||
],
|
||||
"voice_has_asr": true,
|
||||
"voice_has_tts": true,
|
||||
"simulator_llm_tool_calls": 1,
|
||||
"simulator_audio_roundtrips": 1,
|
||||
"barge_in_events": 0,
|
||||
"gates": {
|
||||
"exact_6_to_8_player_role_roster": {
|
||||
"status": "pass"
|
||||
},
|
||||
"one_user_seat": {
|
||||
"status": "pass"
|
||||
},
|
||||
"one_authorized_human_participant": {
|
||||
"status": "not_applicable"
|
||||
},
|
||||
"one_llm_user_simulator": {
|
||||
"status": "pass"
|
||||
},
|
||||
"real_user_input_asr": {
|
||||
"status": "pass"
|
||||
},
|
||||
"real_ai_and_judge_tts": {
|
||||
"status": "pass"
|
||||
},
|
||||
"llm_tool_to_audio_to_asr_boundary": {
|
||||
"status": "pass",
|
||||
"tool_calls": 1,
|
||||
"audio_roundtrips": 1
|
||||
},
|
||||
"three_complete_cycles": {
|
||||
"status": "fail",
|
||||
"observed": 2
|
||||
},
|
||||
"information_isolation": {
|
||||
"status": "pass"
|
||||
},
|
||||
"real_llm_strategy_acceptance": {
|
||||
"status": "pass"
|
||||
},
|
||||
"winner_determined_by_game_rule": {
|
||||
"status": "pass"
|
||||
}
|
||||
},
|
||||
"overall_status": "incomplete"
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source_report": "validation/runs/exp10-6-simulated-user-openrouter-20260801/acceptance_report.json",
|
||||
"source_report_sha256": "656d7bcd9d7b830e33db6278574cc9bc6f194e97ae5902012e1898a72049ca89",
|
||||
"simulator_tool_events_checked": 1,
|
||||
"strict_audio_action_boundary": "fail",
|
||||
"errors": [
|
||||
"tool event 2 selected none but ASR was not an explicit abstention: 'P1 is not'"
|
||||
]
|
||||
}
|
||||
+1341
File diff suppressed because it is too large
Load Diff
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source_report": "validation/runs/exp10-6-simulated-user-openrouter-20260803-v11/acceptance_report.json",
|
||||
"source_report_sha256": "655b4eed74ad4f4d741dc89f97c86a68c547e4f82d1dea9fea71449dfef797e9",
|
||||
"simulator_tool_events_checked": 6,
|
||||
"strict_audio_action_boundary": "pass",
|
||||
"errors": []
|
||||
}
|
||||
@@ -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