Files
vrsub/nodes/vlm.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

192 lines
8.5 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.
"""VLM OCR 节点。
调用本地 Ollama 服务的多模态模型(默认 glm-ocr:latest)对图片做文字识别
(OCR),用于从视频帧中提取字幕文字,构建"真实音频 + 正确字幕"的测试数据。
与 llm-translate 节点相互独立:本节点只做视觉 OCR,不做翻译,避免把两类
职责混在一起。调用协议见 Ollama 官方文档:POST /api/chat。
请求约定:
- 流式传输(stream=True)逐行接收,命中终止序列立即停止拉取,避免模型重复
循环时无限输出;整体受超时截止约束(timeout_seconds / VLM_TIMEOUT_SECONDS
默认 5 秒),超时即终止而不继续等待后续块。
- 采样 temperature 默认 0.3,并携带终止序列 `["\n", "\n答", "答"]`(输出
首个换行即停,同时阻止“答:”式重复循环),模型遇到任一标记即停止。
"""
from __future__ import annotations
import base64
import json
import os
import re
import time
import urllib.error
import urllib.request
from pathlib import Path
from wov_sdk.models import InvokeRequest, InvokeResponse
# 终止序列列表:模型输出一旦出现任一标记(如换行 "\n"、"答")立即停止生成
# Ollama 的 stop 参数,同时作为流式读取时的本地截断标记)。
# 语义:输出第一个换行即停(单行字幕),"答" 系列阻止"答:"式重复循环。
STOP_SEQUENCE = ["\n","\n答","答"]
def _default_host() -> str:
"""默认 Ollama 服务地址,可通过环境变量覆盖。"""
return os.getenv("OLLAMA_HOST", "http://192.168.123.70:11434")
def _clean_ocr_text(content: str) -> str:
"""清洗模型输出:去掉 markdown 围栏与空行,只保留识别到的文字。
glm-ocr 的自定义解析器会在识别文本后追加大量 ``` 围栏,必须剔除,
否则会污染字幕文本。
"""
lines: list[str] = []
for line in content.splitlines():
stripped = line.strip()
# 整行只有反引号(可带语言标记,如 ```markdown)的围栏行丢弃。
if re.fullmatch(r"`+[A-Za-z0-9]*", stripped):
continue
if stripped:
lines.append(stripped)
return "\n".join(lines)
# 标签提取正则:匹配 <gettext>...</gettext>DOTALL 让 . 也能匹配换行)。
_GETTEXT_RE = re.compile(r"<gettext>(.*?)</gettext>", re.DOTALL)
def _extract_gettext(raw: str) -> str:
"""从模型输出中提取 <gettext></gettext> 标签内的内容。
提示词要求模型用该标签包裹识别结果;模型未按格式输出(找不到标签)
时回退返回原始文本,保持旧行为。多个标签只取第一个(防重复循环)。
"""
match = _GETTEXT_RE.search(raw)
if match is None:
return raw
return match.group(1)
def _truncate_at_stop(raw: str) -> str:
"""在最先命中的终止序列处截断文本。
STOP_SEQUENCE 为终止序列列表(如 ["\n", "\n答", "答"]):取最早出现的位置
截断,返回截断后的文本;未命中任何序列时原样返回。
"""
positions = [pos for seq in STOP_SEQUENCE if (pos := raw.find(seq)) != -1]
if not positions:
return raw
return raw[: min(positions)]
def _consume_stream(response, deadline: float) -> str:
"""逐行读取流式响应,直到命中终止序列 / 流结束 / 超过截止时间。
response 为 urllib 打开的响应对象,readline() 返回 bytes(每行一个
Ollama 流式 JSON 块)。返回拼接后的原始文本(未清洗、未截断终止序列)。
"""
parts: list[str] = []
while True:
# 每次调用整体 5 秒上限:超过立即终止,不再等待下一个流式块。
if time.monotonic() >= deadline:
raise TimeoutError("timed out")
line = response.readline()
if not line:
# 流式输出正常结束(模型未发出终止序列,直接收完)。
break
chunk = json.loads(line.decode("utf-8"))
message = chunk.get("message")
if message is None:
# done 行是流结束标记,可能不带 message;其余缺失视为格式错误。
if chunk.get("done"):
break
raise KeyError("missing message in stream chunk")
parts.append(str(message.get("content", "")))
# 命中任一终止序列即停止接收后续内容(模型应已结束生成)。
if any(seq in "".join(parts) for seq in STOP_SEQUENCE):
break
if chunk.get("done"):
# 模型侧完成(未命中终止序列也自然结束)。
break
return "".join(parts)
def invoke(request: InvokeRequest) -> InvokeResponse:
"""识别 image_uri 指向的图片中的文字,产物为 ocr.txt。
参数:model(默认 glm-ocr:latest)、ollama_host、prompt、
timeout_seconds(默认 5,每次调用整体上限);均可通过环境变量
VLM_MODEL / OLLAMA_HOST / VLM_TIMEOUT_SECONDS 覆盖。
"""
image_uri = request.inputs.get("image_uri")
if not image_uri:
return InvokeResponse(status="failed", error="image_uri is required")
image_path = Path(image_uri)
# 文件不存在时提前失败,避免无谓的网络请求。
if not image_path.is_file():
return InvokeResponse(status="failed", error="image file not found")
host = str(request.params.get("ollama_host") or _default_host())
model = str(request.params.get("model") or os.getenv("VLM_MODEL", "glm-ocr:latest"))
prompt = str(
request.params.get("prompt")
or os.getenv("VLM_PROMPT", "提取图像中的文字,不要描述图片中的内容")
)
timeout = float(
request.params.get("timeout_seconds")
or os.getenv("VLM_TIMEOUT_SECONDS", "5")
)
# 图片按 base64 随请求体发送(Ollama 多模态标准格式)。
image_b64 = base64.b64encode(image_path.read_bytes()).decode("ascii")
body = {
"model": model,
# 流式传输:逐行接收生成内容,命中终止序列或超时即停止。
"stream": True,
# keep_alive 让模型在服务端常驻,避免逐帧调用反复加载模型。
"keep_alive": str(request.params.get("keep_alive", "5m")),
# 采样选项:temperature 默认 0.3(可参数覆盖);glm-ocr 在大图上有已知
# 重复循环 bugrepeat_penalty 惩罚重复 token、num_predict 限制输出上限。
"options": {
"temperature": float(request.params.get("temperature", 0.3)),
"repeat_penalty": float(request.params.get("repeat_penalty", 1)),
"num_predict": int(request.params.get("num_predict", 256)),
},
# /api/chat 的输入结构:识别指令放系统提示词,用户消息只携带图片
# content 为空、images 传 base64,与 glm-ocr 期望结构一致)。
"messages": [
{"role": "system", "content": prompt},
{"role": "user", "content": "", "images": [image_b64]},
],
# 模型遇到任一终止序列即停止生成,遏制重复循环;同时作为流式读取截断点。
"stop": STOP_SEQUENCE,
}
request_url = f"{host.rstrip('/')}/api/chat"
http_request = urllib.request.Request(
request_url,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
# timeout 同时作用于连接与每次 socket 读取;deadline 保证整体 5 秒上限。
deadline = time.monotonic() + timeout
with urllib.request.urlopen(http_request, timeout=timeout) as response:
raw = _consume_stream(response, deadline)
# 终止序列可能随最后一个流式块一起返回,在最先命中的序列处截断再清洗。
raw = _truncate_at_stop(raw)
# 提取提示词约定的 <gettext> 标签内容;模型未按格式输出时回退原始文本。
text = _clean_ocr_text(_extract_gettext(raw))
output_dir = Path(request.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "ocr.txt"
output_path.write_text(text + "\n", encoding="utf-8")
return InvokeResponse(
status="completed",
outputs={"text": text, "text_uri": str(output_path)},
)
except (urllib.error.URLError, KeyError, ValueError, OSError) as exc:
# 网络失败、响应格式异常、超时等统一转换为 failed 响应。
return InvokeResponse(status="failed", error=str(exc))