Files
vrsub/nodes/subtitle_cleanup.py
T
cat-shark 7a7212f70c docs: 注释规范要求精简可读,并清理生产代码中的历史叙事
AGENTS.md 的注释规范新增三节可执行约束:

- 只写代码真实逻辑:注释只回答"做什么"与"为什么必须这么做",禁止写决策/
  修改时间、历史版本对比、实测数据与实验结论、事故与缺陷编号(run_xxxx /
  batch_xxxx / R01 等)——这些属 docs/decisions.md 与审查跟踪文件;当前生效
  的约束可以写,但不附带它何时因何变成这样。
- 精简可读:单段连续注释不超过 3 行;docstring 一句话概括职责,不重复函数名
  已表达的信息;不写逐行翻译代码的废话注释,只在非显然处(业务规则、边界、
  易错点、外部约束)加注。
- 覆盖范围:测试注释只说明验证什么行为,回归用例可保留一句溯源;并明确
  参数说明应写在**参数读取处**附近,而不是把多个参数的解释堆在离使用位置
  很远的注释块里。

按此清理生产代码(注释净减 70 行,18 个文件),典型处理:

- nodes/whisper.py:删掉堆在一起、含"用户 2026-08 决定 / 实测 savr-1054"
  等叙事的参数块,把各参数说明移到各自的读取处与 model.transcribe 调用处;
- nodes/llm_filter.py、nodes/subtitle_cleanup.py:模块 docstring 去掉英文
  背景叙事与条数统计,保留"默认只跑规则层""整条删除而非 '-' 占位"等当前
  行为;
- src/wov_app/{batch,db,scheduler}.py 与 routers:去掉 batch_xxx/run_xxx 事故
  编号与"修复前……"对比,改为一句"否则会出现什么问题";
- nodes/ass.py、frame_extract.py:去掉废弃值对比与日期,保留判据本身。

安全验证:用 AST 对比(剥离 docstring 后比较语法树)确认 18 个文件**零逻辑
变更**;`nodes/proper_nouns.py` 的规则表 reason 字段会注入 LLM 提示词,属于
数据而非注释,已恢复原值。全量测试 476 passed。
2026-09-13 16:37:49 +08:00

210 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""字幕清洗:删除寒暄幻觉与纯呻吟碎片。
ASRwhisper 无 VAD 解码)与 LLM 翻译在无语音段会输出固定套话(如“晚安/
感谢观看”/“おやすみなさい”),且在翻译产物里反复出现。处理方式为**连带
时间戳整条删除** cue 并重新编号:不能改成 '-' 占位,否则会一路渲染成可见
减号,并在翻译/过滤阶段需要额外特判。
两类词表:
- HALLUCINATION_TOKENS:中文(LLM 翻译产物中的寒暄);
- JAPANESE_HALLUCINATION_TOKENS:日文(whisper 无 VAD 解码直接输出的套话)。
删除只针对展示时长 ≥ 阈值的长条目:短时相同词可能是剧情真实台词(如真实
互道晚安),必须保留。另外删除纯呻吟/喘息碎片(判据见 _is_pure_moan)。
纯函数,不修改输入。
"""
from __future__ import annotations
import re
# Greeting/closing hallucination tokens that LLM repeats on empty/end segments.
HALLUCINATION_TOKENS = (
"谢谢观看", "感谢观看", "感谢收看", "谢谢收看", "感谢您的观看", "感谢您的收看",
"晚安", "下次再见", "再会", "敬请期待", "感谢您的光临", "欢迎光临",
"再见", "多谢观看", "观看愉快",
)
# Display-duration threshold (seconds): only remove entries longer than this.
DEFAULT_THRESHOLD_SECONDS = 15.0
# 日文套话词表:whisper 无 VAD 解码会把无语音/音乐段当语音,从而重复输出
# 这些寒暄;短时相同词可能是剧情真实台词,靠时长阈值区分(同中文表机制)。
JAPANESE_HALLUCINATION_TOKENS = (
"おやすみなさい", # 晚安
"ご視聴ありがとうございました", # 感谢观看
"ありがとうございました", # 感谢
"ご視聴ありがとうございます", # 感谢观看(现在时)
"また見てね", # 下次再见
"お楽しみに", # 敬请期待
"チャンネル登録よろしくお願いします", # 求订阅
"さようなら", # 再见
"音楽", # 音乐(自述)
"Goodbye",
"Thank you for watching",
)
# 纯呻吟/喘息字符集合:文本(去空白/标点)全部由本集合字符组成、且有效假名数
# ≤ 阈值时才判为噪声删除。集合**刻意排除** そ/こ/ね/や/ば/だ/く/へ 等假名——
# 真实短对话(そこ/やばい/ねえ/やだ/えへへ)都含这些字符,因此天然不命中,从
# 判据根源上避免误删真实短句。
MOAN_CHARS = frozenset(
# 平假名元音与ん/ふ/は(呻吟与喘息气流音的主干)
"あいうえおんふはっ"
# 小写假名(ぁぃぅぇぉ)与片假名对应(アィゥェォ、ン)
"ぁぃぅぇぉアィゥェォン"
# 长音符/省略号/半浊音(ー〜…、…)与空白、标点(呻吟常带这些装饰)
"ー〜…\u2026。、!??!、"
" \t"
)
# 短呻吟过滤的默认有效假名上限:真实呻吟/喘息碎片(あ/ん/ん?/はぁ…/あ!あ!
# /んふふ)有效假名 ≤3;>3(如ああああ)或含非呻吟字符的一律保留。
DEFAULT_MOAN_MAX_CHARS = 3
def _moan_chars(text: str) -> int:
"""返回 text 中'有效假名字符'数量(呻吟判据的一部分)。
只统计假名(片/平)与发音健全字符,空白/标点/长音符/省略号不计入,
这样'あ…'/'ん?'/'あ〜' 的有效字符都是 1 个。
"""
return sum(ch in "あいうえおんふはっぁぃぅぇぉアィゥェォン" for ch in text)
def _is_pure_moan(text: str, max_chars: int) -> bool:
"""判断一条字幕文本是否为'纯呻吟/喘息碎片'(整条删除判据)。
两个条件同时满足才返回 True:
1. 去除空白/标点后剩余字符**全部** ∈ MOAN_CHARS(即整个文本只能由呻吟
字符、标点、空白组成,不允许出现そ/こ/ね/や/ば 等真实词假名);
2. 有效假名字符数 ≤ max_chars(超过阈值即使是纯呻吟长串也不删)。
"""
chars = [c for c in text if not c.isspace()]
# 全部字符必须都在呻吟字符集合中(含标点/长音符)。
if not chars or any(c not in MOAN_CHARS for c in chars):
return False
return _moan_chars(text) <= max_chars
# 解析 SRT:每个 cue 由 序号行 + 时间轴行 + 文本行(可能多行) + 空行 组成。
# 采用逐行解析(不依赖可能粘连的跨 cue 正则),兼容文本多行。
_TS_RE = re.compile(r"^(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\s*$")
def _ts_to_seconds(ts: str) -> float:
"""Convert an SRT timestamp HH:MM:SS,mmm to seconds (float)."""
hours, minutes, rest = ts.split(":")
seconds, millis = rest.split(",")
return int(hours) * 3600 + int(minutes) * 60 + int(seconds) + int(millis) / 1000
def remove_hallucination_entries(
srt_text: str,
tokens: tuple[str, ...],
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
) -> str:
"""删除 SRT 中展示时长 ≥ 阈值且文本命中 tokens 的**整条 cue**(连带时间戳)。
被删除的 cue 不再输出(序号、时间轴、文本全部消失),剩余 cue 按原顺序
重新从 1 编号,保证产物是合法连续的 SRT。输入不被修改(纯函数)。
用途:幻觉在产生处直接剔除——whisper decode_full(日语词表)与 LLM 翻译后
(中文词表)均调用本函数,避免 '-' 占位一路流到 ASS 渲染成可见减号。
内部委托 _remove_cues_by_predicate,与短呻吟过滤共用同一套 SRT 解析/重建。
"""
def _keep(start_sec: float, end_sec: float, text: str) -> bool:
"""保留判据:不命中寒暄幻觉才保留。"""
duration = end_sec - start_sec
return not (duration >= threshold_seconds and any(t in text for t in tokens))
return _remove_cues_by_predicate(srt_text, _keep)
def remove_short_moan_entries(
srt_text: str,
max_chars: int = DEFAULT_MOAN_MAX_CHARS,
) -> str:
"""删除 SRT 中'纯呻吟/喘息碎片'的**整条 cue**whisper decode_full 去噪)。
无 VAD 解码会把呻吟段也整段救回,字幕因此混入纯语气词碎片(あ…/ん?)。
判据见 _is_pure_moan:文本全部由 MOAN_CHARS 组成且有效假名数 ≤ max_chars
才删除;max_chars=0 时关闭过滤。纯函数,不修改输入。
"""
if max_chars <= 0:
return srt_text
def _keep(start_sec: float, end_sec: float, text: str) -> bool:
"""保留判据:非纯呻吟碎片才保留。"""
return not _is_pure_moan(text, max_chars)
return _remove_cues_by_predicate(srt_text, _keep)
def _remove_cues_by_predicate(
srt_text: str,
keep: callable,
) -> str:
"""通用 SRT 逐条过滤:keep(起始秒, 结束秒, 文本) 为 False 的 cue 整条删除。
删除 cue 时序号/时间轴/文本全部消失,剩余 cue 重新从 1 连续编号(合法
SRT)。用逐行解析(不依赖跨 cue 正则,兼容多行文本)。纯函数不修改输入。
"""
lines = srt_text.splitlines()
kept: list[str] = []
number = 1
index = 0
while index < len(lines):
line = lines[index]
if line.strip() and line.strip().isdigit() and index + 1 < len(lines):
ts_match = _TS_RE.match(lines[index + 1].strip())
if ts_match:
# 收集本 cue 文本:时间轴后直到空行前的所有非空行(可多行)。
text_lines: list[str] = []
cursor = index + 2
while cursor < len(lines) and lines[cursor].strip():
text_lines.append(lines[cursor].strip())
cursor += 1
text = "\n".join(text_lines)
start_sec = _ts_to_seconds(ts_match.group(1))
end_sec = _ts_to_seconds(ts_match.group(2))
if keep(start_sec, end_sec, text):
# 保留:输出 新序号+时间轴+文本+空行(重建标准 SRT)。
kept.append(
f"{number}\n{lines[index + 1].strip()}\n{text}\n"
)
number += 1
# 删除:整条跳过(序号/时间轴/文本都不输出)。
index = cursor
continue
# 非 cue 行(文件头/尾部噪声)跳过,避免序号/空行残留。
index += 1
return "\n".join(kept).rstrip() + "\n"
def clean_japanese_hallucinations(
srt_text: str,
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
) -> str:
"""从 SRT 中整条删除日文收尾/寒暄长时幻觉(ASR 无 VAD 解码兜底)。
whisper 节点 decode_full=true(无 VAD 整段解码)在无语音段会输出长时
套话占位;本函数把展示时长 ≥ 阈值且文本含 JAPANESE_HALLUCINATION_TOKENS
的**整条 cue 连带时间戳删除**、剩余重编号。短时相同词(剧情真实道晚安)
保留。纯函数,不修改输入。
"""
return remove_hallucination_entries(srt_text, JAPANESE_HALLUCINATION_TOKENS, threshold_seconds)
def clean_srt_text(
srt_text: str,
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
) -> str:
"""从 SRT 中整条删除中文长时寒暄幻觉(LLM 翻译产物清洗)。
对展示时长 ≥ 阈值且文本含 HALLUCINATION_TOKENS 的 cue 连带时间戳整条
删除、剩余重编号;短时相同词(剧情真实互道晚安)保留。与
clean_japanese_hallucinations 同机制,词表不同。纯函数,不修改输入。
"""
return remove_hallucination_entries(srt_text, HALLUCINATION_TOKENS, threshold_seconds)