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,825 @@
|
||||
"""TTS 质量评估流水线的核心步骤。
|
||||
|
||||
一条评估链路:
|
||||
合成(OpenAI TTS) -> 时长探测(ffprobe) -> 回译(Whisper) -> 计算 CER/字准确率
|
||||
-> LLM Rubric 打分(gpt-5.6-luna) [可选: Gemini 音频评审 gemini-3.5-flash]
|
||||
|
||||
说明:TTS 合成与 Whisper 回译必须走 OpenAI 直连;文本 Rubric 与直接听音频的
|
||||
多模态 Rubric 支持 Google Gemini、OpenRouter 与 Mistral Voxtral。每条路径都把
|
||||
两段真实音频交给音频模型,不会退化成转写文本评审。
|
||||
|
||||
所有对外函数都做了健壮性处理:单条失败抛出带上下文的异常,由 demo.py 捕获后
|
||||
在汇总表里记为失败,而不会中断整表。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
import config
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 客户端(带自动重试,缓解偶发的网络抖动)。
|
||||
# ---------------------------------------------------------------------------
|
||||
_client: Optional[OpenAI] = None
|
||||
|
||||
|
||||
def get_client() -> OpenAI:
|
||||
"""OpenAI 直连 client:用于 TTS 合成与 Whisper 回译(这两项不能走 OpenRouter)。"""
|
||||
global _client
|
||||
if _client is None:
|
||||
key = os.environ.get("OPENAI_API_KEY", "").strip()
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
"缺少 OPENAI_API_KEY(TTS 合成 / Whisper 回译需 OpenAI 直连)。"
|
||||
"请 `export OPENAI_API_KEY=your-openai-api-key` 或写入 .env。"
|
||||
)
|
||||
_client = OpenAI(api_key=key, max_retries=5, timeout=60.0)
|
||||
return _client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM Rubric 评审客户端:支持 OpenRouter 回退。
|
||||
# gpt-5.x 直连 OpenAI 需组织实名认证,只要有 OPENROUTER_API_KEY 就优先走 OpenRouter。
|
||||
# 注意:仅 chat 评审可回退;TTS / Whisper 仍需 OpenAI 直连(见 get_client)。
|
||||
# ---------------------------------------------------------------------------
|
||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
_judge_client: Optional[OpenAI] = None
|
||||
_judge_client_kind: str = ""
|
||||
|
||||
|
||||
def _to_openrouter_model(model: str) -> str:
|
||||
"""把模型名映射成 OpenRouter id:含 '/' 视为原生 id;gpt-* -> openai/*;
|
||||
claude-* -> anthropic/claude-opus-4.8;其余回退到 openai/gpt-5.6-luna。"""
|
||||
if "/" in model:
|
||||
return model
|
||||
if model.startswith("gpt-"):
|
||||
return "openai/" + model
|
||||
if model.startswith("claude-"):
|
||||
return "anthropic/claude-opus-4.8"
|
||||
return "openai/gpt-5.6-luna"
|
||||
|
||||
|
||||
def get_judge_client_and_model(model: str):
|
||||
"""构造 LLM 评审用的 client 并返回 (client, 实际模型名)。
|
||||
|
||||
回退:gpt-5.x 且有 OPENROUTER_API_KEY -> 优先 OpenRouter;否则有 OPENAI_API_KEY ->
|
||||
直连;否则有 OPENROUTER_API_KEY -> OpenRouter(模型名映射);皆无 -> 清晰报错。
|
||||
"""
|
||||
global _judge_client, _judge_client_kind
|
||||
primary = os.environ.get("OPENAI_API_KEY", "").strip()
|
||||
orkey = os.environ.get("OPENROUTER_API_KEY", "").strip()
|
||||
prefer_or = bool(orkey) and model.startswith("gpt-5")
|
||||
|
||||
if not prefer_or and primary:
|
||||
if _judge_client_kind != "openai":
|
||||
_judge_client = OpenAI(api_key=primary, max_retries=5, timeout=60.0)
|
||||
_judge_client_kind = "openai"
|
||||
return _judge_client, model
|
||||
if orkey:
|
||||
if _judge_client_kind != "openrouter":
|
||||
_judge_client = OpenAI(base_url=OPENROUTER_BASE_URL, api_key=orkey,
|
||||
max_retries=5, timeout=60.0)
|
||||
_judge_client_kind = "openrouter"
|
||||
return _judge_client, _to_openrouter_model(model)
|
||||
if primary:
|
||||
if _judge_client_kind != "openai":
|
||||
_judge_client = OpenAI(api_key=primary, max_retries=5, timeout=60.0)
|
||||
_judge_client_kind = "openai"
|
||||
return _judge_client, model
|
||||
raise RuntimeError(
|
||||
"缺少 OPENAI_API_KEY / OPENROUTER_API_KEY,无法运行 LLM Rubric 评审。"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1) TTS 合成(多 provider 分发)
|
||||
# ---------------------------------------------------------------------------
|
||||
def synthesize(cfg: config.TTSConfig, text: str, out_path: str) -> None:
|
||||
"""按 cfg.provider 分发到对应服务商合成语音,写入 out_path(mp3)。失败抛异常。
|
||||
|
||||
OpenAI 走官方 SDK;其余服务商按各家公开 REST 接口用内置 urllib 调用,
|
||||
不引入额外依赖。缺少对应 key 时抛出带上下文的异常,由上层记为该行失败。
|
||||
"""
|
||||
fn = _SYNTH_DISPATCH.get(cfg.provider)
|
||||
if fn is None:
|
||||
raise RuntimeError(
|
||||
f"未知 provider: {cfg.provider!r}(可选:{', '.join(_SYNTH_DISPATCH)})"
|
||||
)
|
||||
audio = fn(cfg, text)
|
||||
if not audio:
|
||||
raise RuntimeError(f"{cfg.provider} TTS 返回空音频")
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(audio)
|
||||
|
||||
|
||||
def _require_env(name: str) -> str:
|
||||
# 走 config.env_get 以支持环境变量别名(如 Fish 的 FISH_API_KEY / FISHAUDIO_API_KEY)。
|
||||
val = config.env_get(name)
|
||||
if not val:
|
||||
raise RuntimeError(f"缺少环境变量 {name},无法用该 provider 合成。")
|
||||
return val
|
||||
|
||||
|
||||
def _http_post(url: str, body: dict, headers: dict, timeout: float = 90.0) -> bytes:
|
||||
"""POST JSON,返回原始响应字节。非 2xx 抛出带响应体片段的异常。"""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
req = urllib.request.Request(
|
||||
url, data=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json", **headers}, method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode("utf-8", "replace")[:300]
|
||||
raise RuntimeError(f"HTTP {e.code}: {detail}") from None
|
||||
|
||||
|
||||
def _synth_openai(cfg: config.TTSConfig, text: str) -> bytes:
|
||||
kwargs = dict(model=cfg.model, voice=cfg.voice, input=text)
|
||||
if cfg.supports_speed() and abs(cfg.speed - 1.0) > 1e-6:
|
||||
kwargs["speed"] = cfg.speed
|
||||
return get_client().audio.speech.create(**kwargs).content
|
||||
|
||||
|
||||
def _synth_elevenlabs(cfg: config.TTSConfig, text: str) -> bytes:
|
||||
key = _require_env("ELEVENLABS_API_KEY")
|
||||
voice = cfg.voice or "21m00Tcm4TlvDq8ikWAM"
|
||||
url = (f"https://api.elevenlabs.io/v1/text-to-speech/{voice}"
|
||||
f"?output_format=mp3_44100_128")
|
||||
body = {"text": text, "model_id": cfg.model or "eleven_multilingual_v2"}
|
||||
# ElevenLabs 返回原始 mp3 字节。
|
||||
return _http_post(url, body, {"xi-api-key": key, "Accept": "audio/mpeg"})
|
||||
|
||||
|
||||
def _synth_fishaudio(cfg: config.TTSConfig, text: str) -> bytes:
|
||||
key = _require_env("FISH_API_KEY")
|
||||
if not cfg.voice:
|
||||
raise RuntimeError(
|
||||
"Fish Audio voice consistency requires a real reference_id; "
|
||||
"set FISH_REFERENCE_ID."
|
||||
)
|
||||
# Use Fish's maintained SDK instead of assuming the REST response is raw
|
||||
# MP3. The current S1 endpoint streams MessagePack chunks and the SDK is
|
||||
# the provider-supported decoder for that wire format.
|
||||
from fish_audio_sdk import Session, TTSRequest
|
||||
|
||||
request = TTSRequest(text=text, reference_id=cfg.voice, format="mp3")
|
||||
return b"".join(Session(key).tts(request, backend=cfg.model or "s1"))
|
||||
|
||||
|
||||
# Minimax /v1/t2a_v2 uses Bearer auth and no longer takes a GroupId query
|
||||
# parameter. The global and mainland-China deployments live on separate hosts;
|
||||
# pick one via MINIMAX_REGION (defaults to the global api.minimax.io host).
|
||||
_MINIMAX_T2A_ENDPOINTS = {
|
||||
"global": "https://api.minimax.io/v1/t2a_v2",
|
||||
"cn": "https://api.minimaxi.com/v1/t2a_v2",
|
||||
}
|
||||
# Success criteria for the non-streaming t2a_v2 call: base_resp.status_code == 0
|
||||
# (request accepted) and data.status == 2 (synthesis finished).
|
||||
_MINIMAX_SUCCESS_CODE = 0
|
||||
_MINIMAX_STATUS_DONE = 2
|
||||
|
||||
|
||||
def _minimax_endpoint() -> str:
|
||||
"""Return the t2a_v2 endpoint for MINIMAX_REGION: cn -> api.minimaxi.com,
|
||||
otherwise the global api.minimax.io host."""
|
||||
region = os.environ.get("MINIMAX_REGION", "").strip().lower()
|
||||
if region in ("cn", "cn_zh", "china", "minimaxi"):
|
||||
return _MINIMAX_T2A_ENDPOINTS["cn"]
|
||||
return _MINIMAX_T2A_ENDPOINTS["global"]
|
||||
|
||||
|
||||
def _synth_minimax(cfg: config.TTSConfig, text: str) -> bytes:
|
||||
key = _require_env("MINIMAX_API_KEY")
|
||||
body = {
|
||||
"model": cfg.model or "speech-2.8-hd",
|
||||
"text": text,
|
||||
"stream": False,
|
||||
"voice_setting": {"voice_id": cfg.voice, "speed": cfg.speed},
|
||||
"audio_setting": {"format": "mp3", "sample_rate": 32000},
|
||||
}
|
||||
raw = _http_post(_minimax_endpoint(), body, {"Authorization": f"Bearer {key}"})
|
||||
data = json.loads(raw)
|
||||
# Validate the request-level return code first, then the synthesis status.
|
||||
base_resp = data.get("base_resp") or {}
|
||||
if base_resp.get("status_code") != _MINIMAX_SUCCESS_CODE:
|
||||
raise RuntimeError(f"Minimax t2a_v2 failed: base_resp={base_resp or data}")
|
||||
payload = data.get("data") or {}
|
||||
status = payload.get("status")
|
||||
hexstr = payload.get("audio")
|
||||
if status != _MINIMAX_STATUS_DONE or not hexstr:
|
||||
raise RuntimeError(
|
||||
f"Minimax returned no finished audio: status={status} base_resp={base_resp}"
|
||||
)
|
||||
# data.audio is a hex-encoded mp3 payload.
|
||||
return bytes.fromhex(hexstr)
|
||||
|
||||
|
||||
def _synth_doubao(cfg: config.TTSConfig, text: str) -> bytes:
|
||||
import uuid
|
||||
appid = _require_env("DOUBAO_APP_ID")
|
||||
token = _require_env("DOUBAO_ACCESS_TOKEN")
|
||||
body = {
|
||||
"app": {"appid": appid, "token": token,
|
||||
"cluster": cfg.model or "volcano_tts"},
|
||||
"user": {"uid": "tts-quality-eval"},
|
||||
"audio": {"voice_type": cfg.voice, "encoding": "mp3",
|
||||
"speed_ratio": cfg.speed},
|
||||
"request": {"reqid": str(uuid.uuid4()), "text": text, "operation": "query"},
|
||||
}
|
||||
# 火山引擎鉴权头是特殊的 'Bearer;{token}' 形式;音频为 base64 编码的 data 字段。
|
||||
raw = _http_post("https://openspeech.bytedance.com/api/v1/tts", body,
|
||||
{"Authorization": f"Bearer;{token}"})
|
||||
data = json.loads(raw)
|
||||
b64 = data.get("data")
|
||||
if not b64:
|
||||
raise RuntimeError(f"豆包无音频返回:code={data.get('code')} "
|
||||
f"message={data.get('message')}")
|
||||
return base64.b64decode(b64)
|
||||
|
||||
|
||||
_SYNTH_DISPATCH = {
|
||||
"openai": _synth_openai,
|
||||
"elevenlabs": _synth_elevenlabs,
|
||||
"fishaudio": _synth_fishaudio,
|
||||
"minimax": _synth_minimax,
|
||||
"doubao": _synth_doubao,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2) 时长探测(ffprobe)
|
||||
# ---------------------------------------------------------------------------
|
||||
def probe_duration(path: str) -> float:
|
||||
"""返回音频时长(秒)。ffprobe 缺失或出错时抛异常。"""
|
||||
if shutil.which("ffprobe") is None:
|
||||
raise RuntimeError("未找到 ffprobe,请安装 ffmpeg(macOS: brew install ffmpeg)。")
|
||||
proc = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1", path],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"ffprobe 失败: {proc.stderr.strip()}")
|
||||
out = proc.stdout.strip()
|
||||
try:
|
||||
return float(out)
|
||||
except ValueError:
|
||||
raise RuntimeError(f"ffprobe 输出无法解析为时长: {out!r}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3) 回译(Whisper 转写)
|
||||
# ---------------------------------------------------------------------------
|
||||
# 用简体中文提示语引导 Whisper 输出简体,避免它偶尔返回繁体导致 CER 被字形差异
|
||||
# 虚高(那是转写脚本选择问题,不是 TTS 发音错误)。
|
||||
_ZH_PROMPT = "以下是普通话简体中文的句子。"
|
||||
|
||||
|
||||
def transcribe(path: str) -> str:
|
||||
with open(path, "rb") as f:
|
||||
tr = get_client().audio.transcriptions.create(
|
||||
model=config.WHISPER_MODEL, file=f, language="zh", prompt=_ZH_PROMPT,
|
||||
)
|
||||
return tr.text or ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4) 文本归一化 + 字错误率(中文用字级 CER,等价于书中所说 WER 的可懂度维度)
|
||||
# ---------------------------------------------------------------------------
|
||||
def normalize(text: str) -> str:
|
||||
"""去掉标点/空白,只保留 CJK / 字母 / 数字,并小写,便于逐字比较。"""
|
||||
text = text.lower()
|
||||
return "".join(ch for ch in text if ch.isalnum())
|
||||
|
||||
|
||||
def _edit_distance(a: str, b: str) -> int:
|
||||
"""Levenshtein 距离(字符级)。"""
|
||||
if a == b:
|
||||
return 0
|
||||
if not a:
|
||||
return len(b)
|
||||
if not b:
|
||||
return len(a)
|
||||
prev = list(range(len(b) + 1))
|
||||
for i, ca in enumerate(a, 1):
|
||||
cur = [i]
|
||||
for j, cb in enumerate(b, 1):
|
||||
cur.append(min(
|
||||
prev[j] + 1, # 删除
|
||||
cur[j - 1] + 1, # 插入
|
||||
prev[j - 1] + (ca != cb), # 替换
|
||||
))
|
||||
prev = cur
|
||||
return prev[-1]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ErrorRate:
|
||||
cer: float # 字错误率 = 编辑距离 / 参考字数
|
||||
accuracy: float # 字准确率 = 1 - cer(下限 0)
|
||||
edits: int
|
||||
ref_len: int
|
||||
|
||||
|
||||
def char_error_rate(reference: str, hypothesis: str) -> ErrorRate:
|
||||
ref = normalize(reference)
|
||||
hyp = normalize(hypothesis)
|
||||
if not ref:
|
||||
if not hyp:
|
||||
return ErrorRate(0.0, 1.0, 0, 0)
|
||||
dist = len(hyp)
|
||||
return ErrorRate(cer=float(dist), accuracy=0.0, edits=dist, ref_len=0)
|
||||
dist = _edit_distance(ref, hyp)
|
||||
cer = dist / len(ref)
|
||||
return ErrorRate(cer=cer, accuracy=max(0.0, 1.0 - cer), edits=dist, ref_len=len(ref))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5) LLM Rubric 评审(默认,OpenAI 闭环)
|
||||
# ---------------------------------------------------------------------------
|
||||
RUBRIC_DIMENSIONS = ["准确性", "自然度", "情感表达", "音色一致性"]
|
||||
|
||||
# 维度说明(供 --dump-rubric 离线打印,也是评审 prompt 的依据)。括号内标注与书中
|
||||
# 四维度(准确性 / 自然度 / 情感表达 / 音色一致性)的对应关系。
|
||||
RUBRIC_DESCRIPTIONS = {
|
||||
"准确性": "逐字核对原文,检查漏读、错读、添读、数字、专名与多音字。",
|
||||
"自然度": "直接听语音的流畅度、机器感、停顿、重音和韵律是否符合人类习惯。",
|
||||
"情感表达": "语调、语速和强调是否符合中性、兴奋、悲伤或疑问等目标情感。",
|
||||
"音色一致性": "把合成语音与同时提供的参考语音比较,判断说话人音色是否一致。",
|
||||
}
|
||||
# The text-only judge remains a diagnostic fallback. It cannot complete the
|
||||
# manuscript experiment because it cannot hear emotion or compare a speaker.
|
||||
|
||||
_JUDGE_SYSTEM = """你是严格的 TTS(文本转语音)质量评审专家。
|
||||
你将拿到:原始参考文本、该文本的期望情感、由 Whisper 对合成语音回译得到的转写文本,
|
||||
以及从音频客观测得的时长、语速(字/秒)和字错误率(CER)。
|
||||
请据此对合成语音质量按 Rubric 逐维度打分(1-5 的整数,5 最好)。你无法听到音频,
|
||||
所以情感表达和音色一致性必须返回 0 并明确标记无法判定;本路径仅是诊断回退,不能验收实验:
|
||||
|
||||
- 准确性:转写与原文是否高度一致(漏字/错字/多字越多分越低;CER 越高分越低)。
|
||||
- 自然度:语速是否接近自然朗读(中文自然朗读约 4-6 字/秒;过快>7 或过慢<3 都不自然)。
|
||||
- 情感表达:返回 0,理由说明文本特征不足以判断真实语调。
|
||||
- 音色一致性:返回 0,理由说明没有听到参考语音和合成语音。
|
||||
|
||||
注意:你看不到音频本身,只能基于以上可测特征做保守、可解释的判断。
|
||||
只输出 JSON,格式:
|
||||
{"准确性": {"score": int, "reason": str},
|
||||
"自然度": {"score": int, "reason": str},
|
||||
"情感表达": {"score": int, "reason": str},
|
||||
"音色一致性": {"score": int, "reason": str}}
|
||||
reason 用一句简短中文说明。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RubricResult:
|
||||
scores: dict # 维度 -> int
|
||||
reasons: dict # 维度 -> str
|
||||
raw: str = ""
|
||||
judge_model: str = ""
|
||||
evidence_mode: str = ""
|
||||
provider_attempts: list = field(default_factory=list)
|
||||
|
||||
|
||||
class JudgeRouteError(RuntimeError):
|
||||
"""A sanitized multimodal-judge failure carrying every attempted route."""
|
||||
|
||||
def __init__(self, message: str, provider_attempts: list):
|
||||
super().__init__(message)
|
||||
self.provider_attempts = provider_attempts
|
||||
|
||||
|
||||
def judge_rubric(reference: str, emotion: str, hypothesis: str,
|
||||
duration: float, cer: float, model: Optional[str] = None) -> RubricResult:
|
||||
"""用评审模型(默认 gpt-5.6-luna)按 Rubric 打分。返回结构化分数 + 点评。
|
||||
|
||||
评审 chat 调用支持 OpenRouter 回退(见 get_judge_client_and_model)。"""
|
||||
chars = len(normalize(reference))
|
||||
speed = chars / duration if duration > 0 else 0.0
|
||||
user = (
|
||||
f"原始参考文本:{reference}\n"
|
||||
f"期望情感:{emotion}\n"
|
||||
f"Whisper 回译文本:{hypothesis}\n"
|
||||
f"音频时长:{duration:.2f} 秒\n"
|
||||
f"语速:{speed:.2f} 字/秒(参考文本 {chars} 个有效字符)\n"
|
||||
f"字错误率 CER:{cer:.3f}\n"
|
||||
)
|
||||
judge_client, judge_model = get_judge_client_and_model(model or config.JUDGE_MODEL)
|
||||
resp = judge_client.chat.completions.create(
|
||||
model=judge_model,
|
||||
messages=[{"role": "system", "content": _JUDGE_SYSTEM},
|
||||
{"role": "user", "content": user}],
|
||||
temperature=0.0,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
raw = resp.choices[0].message.content or "{}"
|
||||
data = json.loads(raw)
|
||||
scores, reasons = {}, {}
|
||||
for dim in RUBRIC_DIMENSIONS:
|
||||
item = data.get(dim, {})
|
||||
if isinstance(item, dict):
|
||||
scores[dim] = int(item.get("score") or 0) # score 缺失或为 null 时按 0 分
|
||||
reasons[dim] = str(item.get("reason", "")).strip()
|
||||
else: # 兼容模型直接返回数字(null 按 0 分)
|
||||
scores[dim] = int(item or 0)
|
||||
reasons[dim] = ""
|
||||
return RubricResult(
|
||||
scores=scores,
|
||||
reasons=reasons,
|
||||
raw=raw,
|
||||
judge_model=judge_model,
|
||||
evidence_mode="transcript-metrics-only-incomplete",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6) 可选:Gemini 多模态音频评审(书中方案)。用 REST,避免额外 SDK 依赖。
|
||||
# ---------------------------------------------------------------------------
|
||||
def _resolve_gemini_model(api_key: str) -> str:
|
||||
"""探测当前可用的 Gemini 模型,避免默认名过期。"""
|
||||
import urllib.request
|
||||
url = f"https://generativelanguage.googleapis.com/v1beta/models?key={api_key}"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=20) as r:
|
||||
data = json.loads(r.read())
|
||||
models_list = data.get("models") or [] if isinstance(data, dict) else []
|
||||
names = [(m.get("name") or "").split("/")[-1] for m in models_list
|
||||
if isinstance(m, dict) and "generateContent" in (m.get("supportedGenerationMethods") or [])]
|
||||
# 优先默认的 gemini-3.5-flash(已验证支持音频输入),再退到 pro / 旧 flash 系列。
|
||||
for want in (config.GEMINI_MODEL_DEFAULT, "gemini-3.5-flash",
|
||||
"gemini-2.5-pro", "gemini-2.5-flash", "gemini-flash-latest"):
|
||||
if want in names:
|
||||
return want
|
||||
# 退而求其次:任意非 tts/image 的可用模型
|
||||
for n in names:
|
||||
if "tts" not in n and "image" not in n and "embedding" not in n:
|
||||
return n
|
||||
except Exception:
|
||||
pass
|
||||
return config.GEMINI_MODEL_DEFAULT
|
||||
|
||||
|
||||
def _parse_direct_audio_rubric(text: str, *, judge_model: str, provider_attempts: list) -> RubricResult:
|
||||
"""Validate a direct-audio judge response against the exact four dimensions."""
|
||||
parsed = json.loads(text)
|
||||
scores, reasons = {}, {}
|
||||
for dim in RUBRIC_DIMENSIONS:
|
||||
item = parsed.get(dim, {})
|
||||
scores[dim] = int(item.get("score") or 0) if isinstance(item, dict) else int(item or 0)
|
||||
reasons[dim] = str(item.get("reason", "")).strip() if isinstance(item, dict) else ""
|
||||
return RubricResult(
|
||||
scores=scores,
|
||||
reasons=reasons,
|
||||
raw=text,
|
||||
judge_model=judge_model,
|
||||
evidence_mode="direct-audio-with-reference",
|
||||
provider_attempts=provider_attempts,
|
||||
)
|
||||
|
||||
|
||||
def _message_text(data: dict) -> str:
|
||||
"""Extract text from OpenAI-compatible string or chunk-list content."""
|
||||
choices = data.get("choices") or []
|
||||
message_content = ((choices[0].get("message") or {}).get("content")) if choices else None
|
||||
if isinstance(message_content, list):
|
||||
return "".join(
|
||||
str(item.get("text", "")) for item in message_content if isinstance(item, dict)
|
||||
).strip()
|
||||
return str(message_content or "").strip()
|
||||
|
||||
|
||||
def _judge_mistral_audio(
|
||||
prompt: str,
|
||||
audio_b64: str,
|
||||
reference_audio_b64: str,
|
||||
*,
|
||||
provider_attempts: list,
|
||||
) -> RubricResult:
|
||||
"""Send both MP3s to Mistral Voxtral using its native data-URL chunks."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
key = os.environ.get("MISTRAL_API_KEY", "").strip()
|
||||
if not key:
|
||||
raise RuntimeError("缺少 MISTRAL_API_KEY,无法回退 Voxtral 音频评审。")
|
||||
model = os.environ.get("TTS_MISTRAL_AUDIO_JUDGE_MODEL", "voxtral-small-latest").strip()
|
||||
body = {
|
||||
"model": model,
|
||||
"temperature": 0.0,
|
||||
"response_format": {"type": "json_object"},
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "text", "text": "待评估合成语音(candidate):"},
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": "data:audio/mpeg;base64," + audio_b64,
|
||||
},
|
||||
{"type": "text", "text": "参考说话人语音(reference):"},
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": "data:audio/mpeg;base64," + reference_audio_b64,
|
||||
},
|
||||
]}],
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
"https://api.mistral.ai/v1/chat/completions",
|
||||
data=json.dumps(body).encode(),
|
||||
headers={
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
for attempt in range(3):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=90) as response:
|
||||
data = json.loads(response.read())
|
||||
break
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")[:2000]
|
||||
error = f"Mistral Voxtral HTTP {exc.code}: {detail}"
|
||||
if exc.code >= 500 and attempt < 2:
|
||||
time.sleep(2 ** attempt)
|
||||
continue
|
||||
provider_attempts.append({
|
||||
"provider": "Mistral Voxtral API",
|
||||
"model": model,
|
||||
"status": "unavailable",
|
||||
"error": error,
|
||||
"attempts": attempt + 1,
|
||||
})
|
||||
raise JudgeRouteError(error, provider_attempts) from None
|
||||
text = _message_text(data)
|
||||
if not text:
|
||||
error = f"Mistral Voxtral 未返回评审文本:{data}"
|
||||
provider_attempts.append({
|
||||
"provider": "Mistral Voxtral API",
|
||||
"model": model,
|
||||
"status": "unavailable",
|
||||
"error": error,
|
||||
})
|
||||
raise JudgeRouteError(error, provider_attempts)
|
||||
provider_attempts.append({
|
||||
"provider": "Mistral Voxtral API",
|
||||
"model": model,
|
||||
"status": "ok",
|
||||
"attempts": attempt + 1,
|
||||
})
|
||||
return _parse_direct_audio_rubric(
|
||||
text,
|
||||
judge_model=f"mistral/{model}",
|
||||
provider_attempts=provider_attempts,
|
||||
)
|
||||
|
||||
|
||||
def _judge_openrouter_audio(
|
||||
prompt: str,
|
||||
audio_b64: str,
|
||||
reference_audio_b64: str,
|
||||
*,
|
||||
provider_attempts: list,
|
||||
) -> RubricResult:
|
||||
"""Send both audio clips to an audio-capable Gemini route on OpenRouter."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
key = os.environ.get("OPENROUTER_API_KEY", "").strip()
|
||||
if not key:
|
||||
raise RuntimeError("缺少 OPENROUTER_API_KEY,无法回退多模态音频评审。")
|
||||
model = os.environ.get("TTS_AUDIO_JUDGE_MODEL", "google/gemini-3.5-flash").strip()
|
||||
content = [
|
||||
{"type": "text", "text": prompt},
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {"data": audio_b64, "format": "mp3"},
|
||||
},
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {"data": reference_audio_b64, "format": "mp3"},
|
||||
},
|
||||
]
|
||||
body = {
|
||||
"model": model,
|
||||
"temperature": 0.0,
|
||||
"response_format": {"type": "json_object"},
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
f"{OPENROUTER_BASE_URL}/chat/completions",
|
||||
data=json.dumps(body).encode(),
|
||||
headers={
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=90) as response:
|
||||
data = json.loads(response.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")[:2000]
|
||||
error = f"OpenRouter audio HTTP {exc.code}: {detail}"
|
||||
provider_attempts.append({
|
||||
"provider": "OpenRouter audio route",
|
||||
"model": model,
|
||||
"status": "unavailable",
|
||||
"error": error,
|
||||
})
|
||||
if os.environ.get("MISTRAL_API_KEY", "").strip():
|
||||
return _judge_mistral_audio(
|
||||
prompt,
|
||||
audio_b64,
|
||||
reference_audio_b64,
|
||||
provider_attempts=provider_attempts,
|
||||
)
|
||||
raise JudgeRouteError(error, provider_attempts) from None
|
||||
text = _message_text(data)
|
||||
if not text:
|
||||
error = f"OpenRouter audio 未返回评审文本:{data}"
|
||||
provider_attempts.append({
|
||||
"provider": "OpenRouter audio route",
|
||||
"model": model,
|
||||
"status": "unavailable",
|
||||
"error": error,
|
||||
})
|
||||
if os.environ.get("MISTRAL_API_KEY", "").strip():
|
||||
return _judge_mistral_audio(
|
||||
prompt,
|
||||
audio_b64,
|
||||
reference_audio_b64,
|
||||
provider_attempts=provider_attempts,
|
||||
)
|
||||
raise JudgeRouteError(error, provider_attempts)
|
||||
provider_attempts.append({
|
||||
"provider": "OpenRouter audio route",
|
||||
"model": model,
|
||||
"status": "ok",
|
||||
})
|
||||
return _parse_direct_audio_rubric(
|
||||
text,
|
||||
judge_model=f"openrouter/{model}",
|
||||
provider_attempts=provider_attempts,
|
||||
)
|
||||
|
||||
|
||||
def judge_gemini_audio(
|
||||
reference: str,
|
||||
emotion: str,
|
||||
audio_path: str,
|
||||
reference_audio_path: str,
|
||||
) -> RubricResult:
|
||||
"""让 Gemini 同时听合成音频与参考音频,执行正文四维 Rubric。
|
||||
|
||||
默认关闭;--gemini 开启。依次尝试已配置的 Google Gemini、OpenRouter 与
|
||||
Mistral Voxtral,失败抛异常由上层记为失败。
|
||||
"""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
key = os.environ.get("GEMINI_API_KEY", "").strip()
|
||||
openrouter_key = os.environ.get("OPENROUTER_API_KEY", "").strip()
|
||||
mistral_key = os.environ.get("MISTRAL_API_KEY", "").strip()
|
||||
if not key and not openrouter_key and not mistral_key:
|
||||
raise RuntimeError(
|
||||
"缺少 GEMINI_API_KEY / OPENROUTER_API_KEY / MISTRAL_API_KEY,"
|
||||
"无法使用直接音频评审。"
|
||||
)
|
||||
with open(audio_path, "rb") as f:
|
||||
audio_b64 = base64.b64encode(f.read()).decode()
|
||||
if not reference_audio_path or not os.path.isfile(reference_audio_path):
|
||||
raise RuntimeError(
|
||||
f"音色一致性评估需要真实参考语音,文件不存在: {reference_audio_path!r}"
|
||||
)
|
||||
with open(reference_audio_path, "rb") as f:
|
||||
reference_audio_b64 = base64.b64encode(f.read()).decode()
|
||||
prompt = (
|
||||
"你是严格的 TTS 质量评审专家。你会收到两段音频:第一段是待评估的合成语音,"
|
||||
"第二段是参考说话人语音。请直接聆听并按正文四维 Rubric 独立打 1-5 整数分:"
|
||||
"(1)准确性:逐字核对原文,检查漏读、错读、添读、数字、专名和多音字;"
|
||||
"(2)自然度:检查机器感、不自然停顿、流畅度、重音和韵律;"
|
||||
"(3)情感表达:检查语调、语速和强调是否符合期望情感;"
|
||||
"(4)音色一致性:只比较说话人音色,不要把内容或录音质量差异误当作不同说话人。"
|
||||
"每个理由必须引用一个可听见的具体观察。只输出 JSON:"
|
||||
'{"准确性":{"score":int,"reason":str},"自然度":{"score":int,"reason":str},'
|
||||
'"情感表达":{"score":int,"reason":str},"音色一致性":{"score":int,"reason":str}}\n'
|
||||
f"合成语音原文:{reference}\n期望情感:{emotion}\n"
|
||||
"音频顺序:1=待评估合成语音;2=参考说话人语音。"
|
||||
)
|
||||
provider_attempts = []
|
||||
if not key:
|
||||
if openrouter_key:
|
||||
return _judge_openrouter_audio(
|
||||
prompt,
|
||||
audio_b64,
|
||||
reference_audio_b64,
|
||||
provider_attempts=provider_attempts,
|
||||
)
|
||||
return _judge_mistral_audio(
|
||||
prompt,
|
||||
audio_b64,
|
||||
reference_audio_b64,
|
||||
provider_attempts=provider_attempts,
|
||||
)
|
||||
model = _resolve_gemini_model(key)
|
||||
body = {
|
||||
"contents": [{"parts": [
|
||||
{"text": prompt},
|
||||
{"inline_data": {"mime_type": "audio/mp3", "data": audio_b64}},
|
||||
{"inline_data": {"mime_type": "audio/mp3", "data": reference_audio_b64}},
|
||||
]}],
|
||||
"generationConfig": {"temperature": 0.0, "responseMimeType": "application/json"},
|
||||
}
|
||||
url = (f"https://generativelanguage.googleapis.com/v1beta/models/"
|
||||
f"{model}:generateContent?key={key}")
|
||||
req = urllib.request.Request(
|
||||
url, data=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json"}, method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
data = json.loads(r.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
# Preserve the provider's diagnostic while never serializing the key
|
||||
# (it only appears in the request URL, not this response excerpt).
|
||||
detail = exc.read().decode("utf-8", "replace")[:2000]
|
||||
direct_error = f"Gemini HTTP {exc.code}: {detail}"
|
||||
provider_attempts.append({
|
||||
"provider": "Google Gemini API",
|
||||
"model": model,
|
||||
"status": "unavailable",
|
||||
"error": direct_error,
|
||||
})
|
||||
if openrouter_key:
|
||||
return _judge_openrouter_audio(
|
||||
prompt,
|
||||
audio_b64,
|
||||
reference_audio_b64,
|
||||
provider_attempts=provider_attempts,
|
||||
)
|
||||
if mistral_key:
|
||||
return _judge_mistral_audio(
|
||||
prompt,
|
||||
audio_b64,
|
||||
reference_audio_b64,
|
||||
provider_attempts=provider_attempts,
|
||||
)
|
||||
raise JudgeRouteError(direct_error, provider_attempts) from None
|
||||
# Gemini 在安全拦截时不返回 candidates(或 candidate 无 content/parts),
|
||||
# 防御式取值并给出带 promptFeedback 的清晰错误,交由上层记为该条失败。
|
||||
candidates = data.get("candidates") or []
|
||||
parts = []
|
||||
if candidates:
|
||||
parts = (candidates[0].get("content") or {}).get("parts") or []
|
||||
if not parts or not parts[0].get("text"):
|
||||
error = f"Gemini 未返回评审文本:{data.get('promptFeedback') or data}"
|
||||
provider_attempts.append({
|
||||
"provider": "Google Gemini API",
|
||||
"model": model,
|
||||
"status": "unavailable",
|
||||
"error": error,
|
||||
})
|
||||
if openrouter_key:
|
||||
return _judge_openrouter_audio(
|
||||
prompt,
|
||||
audio_b64,
|
||||
reference_audio_b64,
|
||||
provider_attempts=provider_attempts,
|
||||
)
|
||||
if mistral_key:
|
||||
return _judge_mistral_audio(
|
||||
prompt,
|
||||
audio_b64,
|
||||
reference_audio_b64,
|
||||
provider_attempts=provider_attempts,
|
||||
)
|
||||
raise JudgeRouteError(error, provider_attempts)
|
||||
text = parts[0]["text"]
|
||||
provider_attempts.append({
|
||||
"provider": "Google Gemini API",
|
||||
"model": model,
|
||||
"status": "ok",
|
||||
})
|
||||
return _parse_direct_audio_rubric(
|
||||
text,
|
||||
judge_model=model,
|
||||
provider_attempts=provider_attempts,
|
||||
)
|
||||
|
||||
|
||||
def sha256_file(path: str) -> str:
|
||||
"""Return a content identity for an evidence audio file."""
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
Reference in New Issue
Block a user