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
497 lines
25 KiB
Python
497 lines
25 KiB
Python
#!/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()
|