新增 test_纠错节点用独立环境变量不受全局模型影响,锁定期望行为: params.model > SUBTITLE_CORRECTION_MODEL > LLM_MODEL > 兜底默认。 背景:上一个提交想"让纠错节点保留旧模型",但只改了 os.getenv 的兜底常量 ——只要 .env 里存在 LLM_MODEL(生产环境必然存在),兜底值永远取不到, 该改动实际无效,真实 LLM 集成测试 test_generic_correction_generalizes_to_unseen_mishearing 失败(新模型 误听泛化实测 0/4,旧模型 4/4)。 本用例当前为红(测试代码尚缺 urllib 导入,实现也待补),按用户要求 与实现一起保留待 review 后修正。
204 lines
8.7 KiB
Python
204 lines
8.7 KiB
Python
"""LLM 默认模型解析契约测试(切换默认模型只改数据/环境,不改代码)。
|
||
|
||
背景
|
||
----
|
||
2026-09 评测结论(data/experiments/translate_models/REPORT.md)决定把翻译/过滤/
|
||
纠错三类节点的**默认模型**从 `Qwen/Qwen3.6-35B-A3B` 换成 `Qwen/Qwen3.5-35B-A3B`
|
||
(质量持平、速度 0.232 s/行,是基线档最快)。
|
||
|
||
仓库约定"切换模型不改代码":三个节点(llm-translate / llm-filter /
|
||
subtitle-correction)的模型解析顺序都是
|
||
`params["model"]` → 环境变量 `LLM_MODEL` → 兜底默认值。
|
||
因此换默认模型 = 改 `.env` 的 `LLM_MODEL`(+ 文档),代码只在兜底默认值上同步。
|
||
|
||
本测试锁定两件事,防止"改了 .env 但节点仍走旧模型"或"params 覆盖失效":
|
||
1. 环境变量 `LLM_MODEL` 能真正决定请求体里的 model(在 HTTP 边界观测真实请求);
|
||
2. `params["model"]` 优先级高于环境变量(工作流参数覆盖仍有效)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from nodes import llm
|
||
from wov_sdk.models import InvokeRequest
|
||
|
||
|
||
def _capture_model(monkeypatch, run_id: str = "r") -> list[dict]:
|
||
"""在 HTTP 边界记录真实请求体,并返回正常结构响应(模型名取请求体的)。"""
|
||
sent: list[dict] = []
|
||
|
||
class Response:
|
||
"""最小可用的 OpenAI 兼容响应;内容由请求体推导,便于断言对齐。"""
|
||
|
||
def __init__(self, body: dict):
|
||
items = json.loads(body["messages"][1]["content"])
|
||
self._payload = json.dumps(
|
||
{
|
||
"choices": [
|
||
{
|
||
"message": {
|
||
"content": json.dumps(
|
||
[{"id": i["id"], "text": "译文"} for i in items],
|
||
ensure_ascii=False,
|
||
)
|
||
}
|
||
}
|
||
],
|
||
"usage": {"total_tokens": 1},
|
||
}
|
||
).encode()
|
||
|
||
def __enter__(self):
|
||
return self
|
||
|
||
def __exit__(self, *args):
|
||
return False
|
||
|
||
def read(self):
|
||
return self._payload
|
||
|
||
def open_request(request, **kwargs):
|
||
body = json.loads(request.data)
|
||
sent.append(body)
|
||
return Response(body)
|
||
|
||
monkeypatch.setattr("urllib.request.urlopen", open_request)
|
||
return sent
|
||
|
||
|
||
def test_env_model_决定请求体里的模型(monkeypatch, tmp_path: Path) -> None:
|
||
"""环境变量 LLM_MODEL 生效:旧模型默认值不会被写死进请求。
|
||
|
||
这是"改 .env 就换默认模型"的硬契约——若节点把默认模型写死在代码里,
|
||
本用例会因请求体仍是旧模型而失败。
|
||
"""
|
||
monkeypatch.setenv("LLM_MODEL", "新默认/模型")
|
||
source = tmp_path / "input.srt"
|
||
source.write_text("1\n00:00:01,000 --> 00:00:02,000\nこんにちは\n", encoding="utf-8")
|
||
sent = _capture_model(monkeypatch)
|
||
response = llm.invoke(
|
||
InvokeRequest(
|
||
run_id="r", node_instance_id="",
|
||
inputs={"srt_uri": str(source)}, output_dir=str(tmp_path / "out"),
|
||
)
|
||
)
|
||
assert response.status == "completed", response.error
|
||
assert sent[0]["model"] == "新默认/模型"
|
||
|
||
|
||
def test_params_model_优先于环境变量(monkeypatch, tmp_path: Path) -> None:
|
||
"""工作流 DAG 的 params.model 覆盖环境变量(切换模型是数据,不是环境依赖)。"""
|
||
monkeypatch.setenv("LLM_MODEL", "环境/模型")
|
||
source = tmp_path / "input.srt"
|
||
source.write_text("1\n00:00:01,000 --> 00:00:02,000\nこんにちは\n", encoding="utf-8")
|
||
sent = _capture_model(monkeypatch)
|
||
response = llm.invoke(
|
||
InvokeRequest(
|
||
run_id="r", node_instance_id="",
|
||
inputs={"srt_uri": str(source)}, params={"model": "工作流/模型"},
|
||
output_dir=str(tmp_path / "out"),
|
||
)
|
||
)
|
||
assert response.status == "completed", response.error
|
||
assert sent[0]["model"] == "工作流/模型"
|
||
|
||
|
||
def test_纠错节点用独立环境变量不受全局模型影响() -> None:
|
||
"""纠错节点必须能用**独立**环境变量固定模型,不受全局 LLM_MODEL 控制。
|
||
|
||
回归(本次提交前实测):该节点原先写的是 `os.getenv("LLM_MODEL", "旧模型")`
|
||
——`LLM_MODEL` 存在时(生产环境必有)兜底值永远不会被取到,
|
||
导致"有意保留旧模型"实际失效,真实 LLM 集成测试
|
||
test_generic_correction_generalizes_to_unseen_mishearing 失败。
|
||
|
||
正确行为:`params.model` > `SUBTITLE_CORRECTION_MODEL` > `LLM_MODEL` > 兜底。
|
||
这样既保留全局一致(不设置该变量时),又能在该节点需要时单独固定模型。
|
||
"""
|
||
import os
|
||
from unittest import mock
|
||
|
||
from nodes import subtitle_correction as sc
|
||
|
||
entries = [{"start": 100.0, "end": 103.0, "text": "もっとマンゴーを舐めてください"}]
|
||
sent: list[str] = []
|
||
|
||
class Response:
|
||
def __enter__(self):
|
||
return self
|
||
|
||
def __exit__(self, *args):
|
||
return False
|
||
|
||
def read(self):
|
||
return json.dumps({"choices": [{"message": {"content": "请多舔舔我的小穴"}}]}).encode()
|
||
|
||
def fake_open(request, *args, **kwargs):
|
||
sent.append(json.loads(request.data)["model"])
|
||
return Response()
|
||
|
||
env = {"LLM_MODEL": "全局/模型", "SUBTITLE_CORRECTION_MODEL": "纠错/专用模型"}
|
||
with mock.patch.object(urllib.request, "urlopen", fake_open), mock.patch.dict(os.environ, env):
|
||
sc.correct_entry(entries[0], entries, 0, {})
|
||
assert sent[-1] == "纠错/专用模型", "独立环境变量应优先于全局 LLM_MODEL"
|
||
sc.correct_entry(entries[0], entries, 0, {"model": "参数/模型"})
|
||
assert sent[-1] == "参数/模型", "params.model 优先级最高"
|
||
# 未设置独立变量时退回全局 LLM_MODEL(保持单一全局配置能力)。
|
||
with mock.patch.object(urllib.request, "urlopen", fake_open), mock.patch.dict(
|
||
os.environ, {"LLM_MODEL": "全局/模型"}
|
||
):
|
||
os.environ.pop("SUBTITLE_CORRECTION_MODEL", None)
|
||
sc.correct_entry(entries[0], entries, 0, {})
|
||
assert sent[-1] == "全局/模型"
|
||
|
||
|
||
def test_兜底默认模型按节点职责分离() -> None:
|
||
"""翻译节点跟随 .env 的 LLM_MODEL;纠错节点保留旧默认(实测更优)。
|
||
|
||
2026-09 实测(同一误听泛化场景,各跑 4 次):
|
||
- `Qwen/Qwen3.6-35B-A3B`:4/4 正确推断(“小穴”);
|
||
- `Qwen/Qwen3.5-35B-A3B`:0/4(输出“阴道/曼果/曼戈”,字面直译)。
|
||
|
||
而翻译节点上新模型与旧模型逐条一致(1160 vs 1160,时间戳完全对齐)。
|
||
因此**不能三处同源**:翻译跟全局(.env),纠错独立保留旧模型;
|
||
过滤节点默认不调 LLM(use_llm=0),其兜底仅作启用时的默认值。
|
||
|
||
本用例锁定“分叉是有意为之”:翻译兜底必须与 .env 一致;纠错兜底必须
|
||
保留旧模型;三处都必须读 LLM_MODEL 环境变量(保留单点覆盖能力)。
|
||
"""
|
||
import inspect
|
||
|
||
from nodes import llm_filter, subtitle_correction
|
||
|
||
env_model = None
|
||
env_path = Path(__file__).resolve().parent.parent / ".env"
|
||
if env_path.is_file():
|
||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||
if line.strip().startswith("LLM_MODEL=") and not line.strip().startswith("#"):
|
||
env_model = line.split("=", 1)[1].strip()
|
||
# 模型解析所在函数:翻译在 translate_lines、过滤在 _judge_category、
|
||
# 纠错在 correct_entry。
|
||
sources = {
|
||
"llm-translate": inspect.getsource(llm.translate_lines),
|
||
"llm-filter": inspect.getsource(llm_filter._judge_category),
|
||
"subtitle-correction": inspect.getsource(subtitle_correction.correct_entry),
|
||
}
|
||
fallbacks = {}
|
||
for name, src in sources.items():
|
||
marker = 'os.getenv("LLM_MODEL", "'
|
||
assert marker in src, f"{name} 未按约定读取 LLM_MODEL 环境变量"
|
||
fallbacks[name] = src.split(marker, 1)[1].split('"', 1)[0]
|
||
# 翻译节点跟随全局配置。
|
||
if env_model:
|
||
assert fallbacks["llm-translate"] == env_model, (
|
||
f"翻译兜底 {fallbacks['llm-translate']} 与 .env 的 LLM_MODEL={env_model} 不一致"
|
||
)
|
||
assert fallbacks["llm-filter"] == env_model, "过滤节点兜底应与全局保持一致"
|
||
# 纠错节点**有意**保留旧模型(实测新模型 0/4 vs 旧 4/4)。
|
||
assert fallbacks["subtitle-correction"] == "Qwen/Qwen3.6-35B-A3B", (
|
||
"纠错节点应保留旧默认(新模型在该节点泛化实测 0/4,明显劣化)"
|
||
)
|