131 lines
5.5 KiB
Python
131 lines
5.5 KiB
Python
"""Скачать ровно проверенные файлы трёх локальных моделей.
|
||
|
||
Файлы лежат в backend/models/ (не в Git). Скрипт одинаково работает на
|
||
Windows, macOS и Linux; при повторном запуске продолжает неполный файл.
|
||
После переноса каталога models/ интернет для занятия не нужен.
|
||
"""
|
||
|
||
import hashlib
|
||
import argparse
|
||
import shutil
|
||
import ssl
|
||
import sys
|
||
import urllib.request
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
import certifi
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
MODELS = ROOT / "models"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Artifact:
|
||
repo: str
|
||
name: str
|
||
target: str
|
||
size: int
|
||
sha256: str
|
||
|
||
|
||
ARTIFACTS = (
|
||
Artifact("Qwen/Qwen3-1.7B-GGUF", "Qwen3-1.7B-Q8_0.gguf",
|
||
"qwen3-1.7b/Qwen3-1.7B-Q8_0.gguf", 1834426016,
|
||
"061b54daade076b5d3362dac252678d17da8c68f07560be70818cace6590cb1a"),
|
||
Artifact("Vikhrmodels/Vikhr-Llama-3.2-1B-instruct-GGUF",
|
||
"Vikhr-Llama-3.2-1B-Q4_K_M.gguf",
|
||
"vikhr-1b/Vikhr-Llama-3.2-1B-Q4_K_M.gguf", 807694432,
|
||
"a85c0ef4b6476ff5fcf2c961226234ab7008d17087c2152416e61a9b7e74b32d"),
|
||
Artifact("ggerganov/whisper.cpp", "ggml-small-q5_1.bin",
|
||
"whisper-small/ggml-small-q5_1.bin", 190085487,
|
||
"ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb"),
|
||
)
|
||
|
||
|
||
def digest(path: Path) -> str:
|
||
sha = hashlib.sha256()
|
||
with path.open("rb") as stream:
|
||
while chunk := stream.read(1 << 20):
|
||
sha.update(chunk)
|
||
return sha.hexdigest()
|
||
|
||
|
||
def verify(item: Artifact) -> None:
|
||
"""Проверить перенесённый вес без сети и без изменения файла."""
|
||
target = MODELS / item.target
|
||
if not target.is_file():
|
||
raise RuntimeError(f"нет локального файла: {target}")
|
||
if target.stat().st_size != item.size:
|
||
raise RuntimeError(
|
||
f"неверный размер {item.target}: {target.stat().st_size}, ожидался {item.size} байт"
|
||
)
|
||
actual = digest(target)
|
||
if actual != item.sha256:
|
||
raise RuntimeError(f"SHA-256 не совпал для {item.target}: {actual}")
|
||
print(f"проверено SHA-256: {item.target}", flush=True)
|
||
|
||
|
||
def download(item: Artifact) -> None:
|
||
target = MODELS / item.target
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
if target.exists():
|
||
try:
|
||
verify(item)
|
||
except RuntimeError:
|
||
pass
|
||
else:
|
||
print(f"готово: {item.target}", flush=True)
|
||
return
|
||
partial = target.with_name(target.name + ".part")
|
||
offset = partial.stat().st_size if partial.exists() else 0
|
||
if offset > item.size:
|
||
raise RuntimeError(f"некорректный частичный файл: {partial}")
|
||
free = shutil.disk_usage(MODELS if MODELS.exists() else ROOT).free
|
||
if free < item.size - offset + 256 * 1024 * 1024:
|
||
raise RuntimeError(f"мало свободного места для {item.target}: осталось {free // (1 << 20)} МиБ")
|
||
url = f"https://huggingface.co/{item.repo}/resolve/main/{item.name}"
|
||
request = urllib.request.Request(url, headers={"Range": f"bytes={offset}-"} if offset else {})
|
||
print(f"скачиваю {item.target} ({item.size // (1 << 20)} МиБ)", flush=True)
|
||
with urllib.request.urlopen(
|
||
request, timeout=120, context=ssl.create_default_context(cafile=certifi.where())
|
||
) as response:
|
||
# Сервер может проигнорировать Range: тогда начинаем заново, не
|
||
# приписываем полный файл к частичному.
|
||
resumed = offset > 0 and response.status == 206
|
||
with partial.open("ab" if resumed else "wb") as out:
|
||
count = offset if resumed else 0
|
||
while chunk := response.read(1 << 20):
|
||
out.write(chunk)
|
||
count += len(chunk)
|
||
if count % (64 << 20) < len(chunk) or count == item.size:
|
||
print(f" {count // (1 << 20)}/{item.size // (1 << 20)} МиБ", flush=True)
|
||
if partial.stat().st_size != item.size or digest(partial) != item.sha256:
|
||
raise RuntimeError(f"контроль размера/SHA-256 не пройден: {partial}")
|
||
partial.replace(target)
|
||
print(f"проверено SHA-256: {item.target}", flush=True)
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument(
|
||
"--verify-only", action="store_true",
|
||
help="проверить уже загруженные/перенесённые веса без сети и без изменения файлов",
|
||
)
|
||
args = parser.parse_args()
|
||
try:
|
||
for item in ARTIFACTS:
|
||
verify(item) if args.verify_only else download(item)
|
||
except (OSError, RuntimeError) as exc:
|
||
action = "проверка прервана" if args.verify_only else "загрузка прервана"
|
||
print(f"{action}: {exc}", file=sys.stderr)
|
||
return 1
|
||
if args.verify_only:
|
||
print("Все три локальные модели прошли офлайн-проверку; файлы не изменялись.")
|
||
else:
|
||
print("Все три локальные модели проверены. Для голоса нужны также whisper-server, VAD и TTS.")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|