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。
210 lines
9.6 KiB
Python
210 lines
9.6 KiB
Python
"""字幕清洗:删除寒暄幻觉与纯呻吟碎片。
|
||
|
||
ASR(whisper 无 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)
|