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。
This commit is contained in:
2026-09-13 16:37:49 +08:00
parent 8f6083f8cf
commit 7a7212f70c
18 changed files with 192 additions and 262 deletions
+18 -33
View File
@@ -4,7 +4,7 @@
全部视频,逐个调用现有的工作流流水线(复用 WorkflowScheduler 的 DAG 执行与
断点续跑逻辑)。
处理约定2026-09 起)
处理约定:
- **创建任务时一次性定位**:`create_job` 扫描文件夹并把每个视频登记为
batch_videos 明细;视频所在目录(视频旁)若已存在**文件名包含视频名**的
@@ -67,9 +67,8 @@ SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt"}
# 暂停信号文件名:与节点约定一致,位于 run 根目录(<work_dir>/runs/<run_id>/)。
PAUSE_FLAG = "paused.flag"
# 旧版批量完成标记文件名(位于视频同名文件夹根目录)。新逻辑不再写入该标记
# 产物直接放视频旁、靠旁挂字幕文件识别完成);仍保留读取能力,用于兼容
# 旧版任务在详情/下载接口中展示产物。
# 兼容读取的历史完成标记文件名(旧任务用它记录产物路径)。当前逻辑不再
# 写入,产物直接放视频旁;保留读取能力以便旧任务的详情/下载仍可用。
MARKER_NAME = "batch.done.json"
# 批量处理私有工作空间根目录:位于应用存储目录下(data/storage/batch)。
@@ -154,7 +153,7 @@ def _sidecar_product_name(video: Path, source: Path) -> str:
def load_marker(work_dir: Path) -> dict | None:
"""读取同名文件夹里的旧版完成标记;不存在或损坏时返回 None。"""
"""读取历史完成标记;不存在或损坏时返回 None。"""
path = work_dir / MARKER_NAME
if not path.is_file():
return None
@@ -351,23 +350,20 @@ class BatchWorker:
definition.validate()
items = self.db.list_batch_videos(job_id)
# total 创建任务时已固定为"无字幕需处理的视频数",这里不覆盖;
# 老任务(历史口径 total=全部视频数)由 sync_batch_job_progress 在读取
# 时自我修正为不含 SKIPPED 的口径。
# total 创建时已固定为"无字幕需处理的视频数",此处不覆盖;历史任务
# 的旧口径由 sync_batch_job_progress 在读取时修正为不含 SKIPPED。
self.db.update_batch_job(
job_id, status="RUNNING", progress=0,
current_video=None, error=None, updated_at=_now_iso(),
)
# total 用于进度条分母;无字幕项为 0 表示整批跳过(创建即 COMPLETED
# 正常不会进入本循环)。
# total 进度条分母;为 0 表示整批跳过(创建即 COMPLETED)。
total = int(job["total"] or 0)
for item in items:
# 暂停检查:批量任务被暂停后停止处理后续视频,等待用户继续。
current = self.db.get_batch_job(job_id)
if current is None or current["status"] == "PAUSED":
# 停下前把已完成/失败的视频实时入账,让暂停中的前端也能看到
# 真实进度(回归 batch_fee668175444)。
# 停下前把已完成/失败入账,让暂停中的前端看到真实进度。
self.db.sync_batch_job_progress(job_id)
logger.info("批量任务 %s 已暂停,停止在视频 %s", job_id, item["video_path"])
return
@@ -407,17 +403,9 @@ class BatchWorker:
self.db.update_batch_job(job_id, status="PAUSED", updated_at=_now_iso())
return
# 全部视频处理完成:先用明细实时对齐汇总(done 只计实际完成的,
# 不含 SKIPPED),再置 COMPLETED。
#
# 置 COMPLETED 前必须校验**没有未处理完的视频残留**:若本轮循环因
# 视频处理中断/异常(_process_video 返回但视频仍 PENDING,等同进程
# 在处理中被杀)而没有真正处理完所有 PENDING,就**不能**标完成——
# 否则会出现"明细还有 N 个待处理、任务却已完成"的僵尸状态
# batch_969fabe74b83 等 3 个任务真实发生:引擎串行处理到 9.9GB
# 大视频时中断,10 个视频留 PENDING 却被无条件置 COMPLETED)。
# 此时保持 RUNNING,让引擎下一轮(重启后重新拾起 RUNNING 任务)
# 继续处理剩余 PENDING,全部结束才真正置 COMPLETED。
# 先按明细实时对齐汇总(done 不计 SKIPPED),再判断能否收尾。
# 仍有未结束视频时不能标 COMPLETED,否则会出现“还有待处理视频却已完成”
# 的僵尸状态;此时保持 RUNNING,由引擎下一轮续跑。
self.db.sync_batch_job_progress(job_id)
job = self.db.get_batch_job(job_id)
if job is None:
@@ -427,8 +415,7 @@ class BatchWorker:
if v["status"] not in ("SKIPPED", "COMPLETED", "FAILED")
]
if leftovers:
# 有未处理完的视频PENDING/PAUSED/QUEUED 等):保持 RUNNING
# 由引擎下一轮续跑;记录日志便于排查中断位置。
# 有未处理完的视频:保持 RUNNING,由引擎下一轮续跑。
logger.warning(
"批量任务 %s 仍有 %d 个视频未处理完(%s…),保持 RUNNING 待续跑,不置 COMPLETED",
job_id, len(leftovers), Path(leftovers[0]["video_path"]).name,
@@ -464,11 +451,11 @@ class BatchWorker:
work_dir.mkdir(parents=True, exist_ok=True)
run_id = item.get("run_id")
if run_id is not None and self.db.get_run(run_id) is None:
# run 记录已不存在(此前收尾异常删除了 run 但状态未同步):重新新建。
# run 记录已不存在(收尾异常删除了 run 但状态未同步):重新新建。
run_id = None
if run_id is None:
# 首次处理:创建 source=batch 的运行,input_uri 直接指向本地视频
# (不上传副本),调度器按工作流 DAG 动态组装节点执行。
# 首次处理:创建 source=batch 的运行,input_uri 指向本地视频
# (不上传副本),调度器按 DAG 执行。
run_id = f"run_{uuid.uuid4().hex[:12]}"
now = _now_iso()
self.db.create_run({
@@ -486,7 +473,7 @@ class BatchWorker:
self.db.update_batch_video(item["id"], run_id=run_id, updated_at=_now_iso())
run = self.db.get_run(run_id)
# 已完成(例如上次收尾前中断):直接放置产物并清理后返回
# 已完成(收尾前中断):直接补做收尾
if run["status"] == "COMPLETED":
self._finalize_video(item, run_id, video, work_dir, definition)
return
@@ -494,10 +481,8 @@ class BatchWorker:
if run["status"] == "PAUSED":
self.db.resume_run(run_id, _now_iso())
elif run["status"] == "FAILED":
# 失败重跑:**保留**已完成节点的产物记录,只恢复 QUEUED——
# execute_run 从产物表重建已完成节点并跳过,只重跑失败节点
# 不再 reset_run 清空产物:extract/ocr 等长耗时节点的成果会被
# 白白丢弃重做(run_e2b74e89e232 实测 22222 帧 OCR)。
# 失败重跑:保留产物记录只置 QUEUED,由 execute_run 跳过已完成
# 节点、仅重跑失败节点,避免浪费抽帧/OCR 等长耗时成果
self.db.update_run(run_id, status="QUEUED", error=None, updated_at=_now_iso())
elif run["status"] == "RUNNING":
# 上次进程被杀残留:恢复 QUEUED(保留产物)由 execute_run 续跑。
+12 -21
View File
@@ -353,11 +353,10 @@ class Database:
def next_queued_run(self) -> dict[str, Any] | None:
"""按创建时间返回最早一条排队(QUEUED)任务。
只取 QUEUEDPAUSED 任务必须由用户显式 resume(转回 QUEUED)后调度器
才重新执行。修复回归——此前把 PAUSED 也当可执行任务拾起,execute_run
会先置 RUNNING 再检查暂停,导致"点击暂停反而开始任务"
同时排除 source=batch 的批量运行:批量任务由批量引擎使用视频旁的
同名文件夹作为 storage 执行,主调度器拾起会用错存储目录。
只取 QUEUEDPAUSED 必须由用户显式 resume 后才执行,否则暂停会被
execute_run 立刻覆盖成 RUNNING。
排除 source=batch:批量运行由批量引擎在私有工作空间执行,主调度器
拾起会用错存储目录。
"""
with self._connect() as conn:
row = conn.execute(
@@ -388,13 +387,10 @@ class Database:
def recover_interrupted_batch_jobs(self, updated_at: str) -> int:
"""重启恢复:把遗留 RUNNING 的批量任务恢复为 QUEUED,返回恢复数量。
批量引擎处理视频(尤其大文件)时进程被杀/重启,批量任务停在
RUNNING:其关联 run 由 recover_interrupted_runs 恢复为 QUEUED,但
批量任务本身若保持 RUNNINGnext_queued_batch_job 只拾取 QUEUED
永远不会重新驱动它 → 未处理完的 PENDING 视频永久残留
batch_969fabe74b83 事故链路之一)。恢复为 QUEUED 后引擎重新拾起,
从断点(剩余 PENDING 视频 + 已恢复的 run)继续处理。用户主动暂停的
PAUSED 批量任务保持不变,等待显式 resume。
批量任务停在 RUNNINGnext_queued_batch_job 只拾取 QUEUED
永远不会重新驱动它,未处理完的 PENDING 视频会永久残留;恢复为
QUEUED 后引擎从断点(剩余视频 + 已恢复的 run)继续。用户主动暂停的
PAUSED 保持不变。
"""
with self._connect() as conn:
cur = conn.execute(
@@ -579,15 +575,10 @@ class Database:
def sync_batch_job_progress(self, job_id: str) -> None:
"""按视频明细实时对齐任务的 total/done/failed 汇总并落库。
统计口径(2026-09 用户确认):total = 本批**无字幕、需要处理**的视频数
(= 明细里非 SKIPPED 的数量,创建时已固定,运行中 SKIPPED 不会变化);
done = 实际**处理完成**的视频数(仅 COMPLETEDSKIPPED 不计);
failed = 处理失败的视频数。已有字幕直接跳过的视频不参与 total/done
引擎在暂停、视频间检查、收尾等边界调用,router 在读取前也调用,保证
前端看到的进度始终与明细一致——即使任务被暂停或进程被终止(回归
batch_fee668175444:整批 431 个含 383 个已有字幕,应显示 15/48 而非
0/431)。任务不存在时静默返回。
口径:total = 需处理的视频数(非 SKIPPED,创建时固定);done 只计
COMPLETEDSKIPPED 不计);failed 为失败数。引擎在暂停、视频间与
收尾边界调用,router 读取前也调用,保证前端进度与明细一致(即使任务
被暂停或进程被终止)。任务不存在时静默返回
"""
with self._connect() as conn:
counts = conn.execute(
+10 -19
View File
@@ -37,10 +37,9 @@ def _product_finals(video: dict) -> dict[str, str]:
"""列出该视频可下载的最终产物(键为下载 alias,值为文件名)。
来源合并两处:
- 旧版完成标记 `batch.done.json`(位于 work_dir/同名文件夹),键为语义
别名(如 cn_srt/ass),用于兼容旧版批量任务;
- 视频所在目录(视频旁)中**文件名含视频名**的字幕文件,键即文件名。
新版处理完成后产物放到视频旁,靠旁挂字幕文件即可列出与下载。
- 历史完成标记 `batch.done.json` 里的语义别名(兼容旧任务);
- 视频所在目录中**文件名含视频名**的字幕文件(当前产物即放视频旁),
键与值都是文件名。
"""
finals: dict[str, str] = {}
marker = batch_engine.load_marker(Path(video["work_dir"]))
@@ -52,7 +51,7 @@ def _product_finals(video: dict) -> dict[str, str]:
def _enrich_videos(db: Database, videos: list[dict]) -> list[dict]:
"""为每个视频补充最终产物清单(视频旁字幕文件 + 旧版完成标记)。
"""为每个视频补充最终产物清单(视频旁字幕 + 历史完成标记)。
finals 形如 {alias: 文件名},前端据此渲染下载链接;未完成的视频没有产物。
"""
@@ -81,10 +80,8 @@ def create_batch_job(
def list_batch_jobs(db: Database = Depends(_get_db)) -> list[dict]:
"""返回最近的批量任务列表(不含视频明细,明细按需单独查询)。
返回前对每个任务实时对齐 total/done/failed任务被暂停或引擎不在运行时
汇总字段也能与明细一致,前端列表的进度数字不会停留在 0
(回归 batch_fee668175444431 个含 383 个已有字幕,应显示实际处理进度
而非 0/431)。
返回前对每个任务实时对齐 total/done/failed,保证任务被暂停或引擎运行时
进度数字仍与明细一致(否则会一直显示 0)。
"""
jobs = db.list_batch_jobs()
for job in jobs:
@@ -163,10 +160,8 @@ def download_batch_video(
) -> FileResponse:
"""下载视频的最终产物:解析 alias 对应的文件后返回。
alias 解析顺序:
1. 旧版完成标记里的语义别名(如 cn_srt/ass)→ 文件位于 work_dir
2. 视频旁(视频所在目录)文件名含视频名的字幕文件名 → 直接返回该文件。
只有存在且文件真实落盘的产物才可下载。
alias 解析顺序:历史完成标记里的语义别名(文件在 work_dir)→ 视频旁
文件名含视频名的字幕文件。只有文件真实落盘才可下载。
"""
video = db.get_batch_video(video_id)
if video is None or video["job_id"] != job_id:
@@ -191,12 +186,8 @@ def download_batch_video(
return FileResponse(target, filename=target.name)
# ---------------------------------------------------------------------------
# 本地目录浏览(目录树选择器)
#
# 浏览器出于安全限制拿不到所选文件夹的绝对路径,因此由**本地后端**提供目录
# 浏览能力:roots 返回可浏览的根(Windows 盘符 / POSIX 根 + 家目录),dirs
# 返回指定目录的直接子目录,前端据此渲染懒加载目录树,点击选择后回填路径。
# ---------------------------------------------------------------------------
# 本地目录浏览(目录树选择器):浏览器拿不到所选文件夹的绝对路径,改由本地
# 后端提供——roots 返回可浏览根,dirs 返回直接子目录,前端懒加载成目录树。
@router.get("/api/batch/roots")
+4 -4
View File
@@ -36,8 +36,8 @@ def _validate_definition(raw: dict) -> WorkflowDefinition:
"""解析并校验 DAG 定义,非法时转换为 422 HTTP 异常。
除 `WorkflowDefinition.validate()` 的结构校验(名称/版本/节点 ID 唯一/
边引用存在)之外,还要求 DAG **可拓扑排序**:环形依赖虽然结构上合法
但执行时无法确定节点顺序,必须拒绝保存与发布R04
边引用存在)之外,还要求 DAG **可拓扑排序**:环形依赖结构上合法但无法
确定执行顺序,必须拒绝保存与发布。
"""
try:
definition = WorkflowDefinition.from_dict(raw)
@@ -127,8 +127,8 @@ def publish_workflow(workflow_id: str, db: Database = Depends(_get_db)) -> dict:
raise HTTPException(status_code=404, detail="workflow not found")
if workflow["latest_version"] == 0:
raise HTTPException(status_code=422, detail="workflow has no version")
# 发布前重新校验待发布版本:历史遗留的无效定义(例如修复前保存的环形 DAG)
# 不能进入用户应用中心,否则创建出来的任务会在执行期失败(R04)
# 发布前重新校验:无效定义(如环形 DAG)不能进入应用中心,否则任务会在
# 执行期失败
latest = db.get_latest_workflow_version(workflow_id)
if latest is None:
raise HTTPException(status_code=422, detail="workflow has no version")
+6 -12
View File
@@ -104,9 +104,8 @@ class WorkflowScheduler:
else:
time.sleep(self.interval_seconds)
except Exception: # noqa: BLE001
# 单次轮询异常不杀死调度线程:曾因 next_queued_run/execute_run
# 的未捕获异常导致线程退出,任务永远停留在 QUEUED 不被拾起
# run_011d01f19999 实际发生)。记录后跳过本轮,下一轮继续。
# 单次轮询异常不杀死调度线程:否则任务会永远停在 QUEUED
# 无人拾起。记录后跳过本轮,下一轮继续。
logger.exception("调度器轮询异常,跳过本轮")
time.sleep(self.interval_seconds)
def _resolve_ref(
@@ -131,10 +130,8 @@ class WorkflowScheduler:
# 任务不存在或不在可执行状态(排队/暂停)时直接返回,避免重复执行。
if run is None or run["status"] not in ("QUEUED", "PAUSED"):
return
# 已暂停的任务不自动续跑:直接返回保持 PAUSED,等用户显式 resume
# resume 把状态转回 QUEUED 后才会真正执行)。修复回归——此前以
# PAUSED 进入后立即置 RUNNING,节点循环的暂停检查永远不成立,
# 任务被复活继续执行("点击暂停反而开始任务")。
# 已暂停的任务不自动续跑:直接返回保持 PAUSED,等用户显式 resume
# resume 转回 QUEUED 后才执行);否则暂停会被立刻覆盖成 RUNNING。
if run["status"] == "PAUSED":
return
@@ -150,11 +147,8 @@ class WorkflowScheduler:
return
# 解析并校验 DAG,随后计算拓扑执行顺序。
#
# 任何预检异常(缺字段/边引用不存在/环形依赖)都必须在这里把任务标
# FAILED修复前这段在 try 之外,异常直接冒到 _loop 被吞掉,任务停在
# QUEUEDnext_queued_run 每轮拾起同一条队首记录,后续任务全部堵塞
# (R04:环形 DAG 卡死队列)。历史无效版本无法删除,只能就地判失败。
# FAILED否则队首记录会一直停在 QUEUED 堵塞后续任务。
try:
definition = WorkflowDefinition.from_dict(version["definition"])
definition.validate()
@@ -311,7 +305,7 @@ class WorkflowScheduler:
"""
source = Path(resolved)
if not source.is_file():
# 兼容旧版本:原文件已改名,但最终别名仍记录有效路径时直接复用。
# 兼容历史记录:原文件已改名,但最终别名仍指向有效路径时复用。
existing = self.db.get_artifact(run["id"], alias)
if existing is not None and Path(existing["uri"]).is_file():
return existing["uri"]