Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
|
|
@ -8,6 +8,7 @@
|
|||
0 3 * * * cd … && make backup в crontab демо-машины
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -18,7 +19,7 @@ from app.admin.backup import BackupError, create, listing # noqa: E402
|
|||
|
||||
#: Сколько копий держать. Место на демо-машине не бесконечно, а копия за
|
||||
#: позапрошлую неделю не нужна никому.
|
||||
KEEP = 14
|
||||
KEEP = max(1, int(os.environ.get("BACKUP_KEEP", "14")))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
|
|
|||
80
backend/scripts/backup_loop.py
Normal file
80
backend/scripts/backup_loop.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Автоматическая резервная копия PostgreSQL не реже раза в сутки.
|
||||
|
||||
Процесс живёт отдельным Compose-сервисом. Он делает копию сразу, если свежей
|
||||
нет, затем ориентируется на фактическое время последнего успешного файла.
|
||||
После ошибки повторяет попытку чаще, но не удаляет существующие копии.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.admin.backup import BackupError, DIR, create, listing # noqa: E402
|
||||
from app.config import get_settings # noqa: E402
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s backup-scheduler %(message)s",
|
||||
)
|
||||
log = logging.getLogger("backup-scheduler")
|
||||
|
||||
|
||||
def seconds_until_due(now: datetime, interval: int) -> float:
|
||||
copies = listing()
|
||||
if not copies:
|
||||
return 0.0
|
||||
age = (now - copies[0]["at"]).total_seconds()
|
||||
return max(0.0, interval - age)
|
||||
|
||||
|
||||
def prune(keep: int) -> int:
|
||||
old = listing()[keep:]
|
||||
deleted = 0
|
||||
for item in old:
|
||||
try:
|
||||
(DIR / item["name"]).unlink(missing_ok=True)
|
||||
deleted += 1
|
||||
except OSError as exc:
|
||||
# A locked/readonly old file must not take down the daily scheduler.
|
||||
log.error("не удалось удалить старую копию %s: %s", item["name"], exc)
|
||||
return deleted
|
||||
|
||||
|
||||
def run_forever() -> None:
|
||||
settings = get_settings()
|
||||
interval = max(60, settings.backup_interval_seconds)
|
||||
retry = max(10, min(settings.backup_retry_seconds, interval))
|
||||
keep = max(1, settings.backup_keep)
|
||||
log.info("запущен: интервал=%s с, хранить=%s", interval, keep)
|
||||
|
||||
while True:
|
||||
try:
|
||||
wait = seconds_until_due(datetime.now(timezone.utc), interval)
|
||||
except BackupError as exc:
|
||||
log.error("не удалось проверить резервные копии: %s; повтор через %s с", exc, retry)
|
||||
time.sleep(retry)
|
||||
continue
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
continue
|
||||
try:
|
||||
made = create()
|
||||
deleted = prune(keep)
|
||||
log.info("готово: %s (%s байт), удалено старых=%s",
|
||||
made["name"], made["size_bytes"], deleted)
|
||||
except BackupError as exc:
|
||||
log.error("цикл резервного копирования не завершён: %s; повтор через %s с", exc, retry)
|
||||
time.sleep(retry)
|
||||
continue
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_forever()
|
||||
131
backend/scripts/download_local_models.py
Normal file
131
backend/scripts/download_local_models.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""Скачать ровно проверенные файлы трёх локальных моделей.
|
||||
|
||||
Файлы лежат в 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())
|
||||
|
|
@ -1,76 +1,43 @@
|
|||
"""make llm-check: один запрос к LLM, чтобы убедиться, что ключ и адрес рабочие.
|
||||
"""Проверка активного диалогового профиля через тот же клиент, что и занятие.
|
||||
|
||||
Ключ берётся из backend/.env и никуда не печатается. Провайдер отвечает с машины
|
||||
разработки напрямую; таймаут почти всегда означает не отказ провайдера, а окружение —
|
||||
VPN-туннель или песочница, через которые российские адреса не проходят. Поэтому
|
||||
проверять отсюда, а не из обёрток, и с той машины, на которой пойдёт занятие.
|
||||
В локальном режиме ключ не нужен; OFFLINE=true запрещает запрос наружу.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.config import get_settings # noqa: E402
|
||||
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
async def check() -> int:
|
||||
settings = get_settings()
|
||||
if not settings.llm_api_key:
|
||||
print("в backend/.env нет LLM_API_KEY")
|
||||
return 1
|
||||
|
||||
print(f"адрес: {settings.llm_base_url}")
|
||||
print(f"модель: {settings.llm_model_caller}")
|
||||
|
||||
body = {
|
||||
"model": settings.llm_model_caller,
|
||||
"messages": [
|
||||
{"role": "system", "content": "Ты звонящий в службу 112, у тебя горит балкон. "
|
||||
"Ответь одной короткой фразой, в панике."},
|
||||
{"role": "user", "content": "Служба 112, что у вас случилось?"},
|
||||
],
|
||||
"max_tokens": 60,
|
||||
"temperature": 0.8,
|
||||
}
|
||||
|
||||
control = settings.dialogue_model_mode == "russian_control"
|
||||
model = settings.llm_model_control if control else settings.llm_model_caller
|
||||
address = settings.llm_control_base_url if control else settings.llm_base_url
|
||||
print(f"адрес: {address}\nмодель: {model}")
|
||||
client = LlmClient(base_url=address, timeout=30)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{settings.llm_base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {settings.llm_api_key}"},
|
||||
json=body,
|
||||
timeout=60,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
print(f"\nсеть: {type(exc).__name__} — до провайдера не достучались.")
|
||||
print("Это чаще про окружение, чем про провайдера: проверь, что российские")
|
||||
print("адреса идут мимо VPN, и повтори с самой машины стенда.")
|
||||
return 2
|
||||
|
||||
elapsed = time.monotonic() - started
|
||||
print(f"\nHTTP {response.status_code}, {elapsed:.2f} с")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print("ответ модели:", data["choices"][0]["message"]["content"].strip())
|
||||
usage = data.get("usage", {})
|
||||
print("токены:", usage.get("prompt_tokens"), "→", usage.get("completion_tokens"))
|
||||
print("\nКЛЮЧ РАБОТАЕТ")
|
||||
return 0
|
||||
|
||||
print("ответ сервера:", response.text[:400])
|
||||
if response.status_code in (401, 403):
|
||||
print("\nСеть в порядке, но ключ не принят: возможно, его нужно менять "
|
||||
"на временный токен — сверься с консолью Cloud.ru.")
|
||||
elif response.status_code == 404:
|
||||
print("\nКлюч принят, но такой модели нет: проверь LLM_MODEL_CALLER.")
|
||||
return 3
|
||||
reply = await client.complete(LlmRequest(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "Ответь одним словом по-русски: работает /no_think"}],
|
||||
temperature=0,
|
||||
max_tokens=32,
|
||||
), use_cache=False)
|
||||
except LlmUnavailable as exc:
|
||||
print(f"модель недоступна: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
await client.aclose()
|
||||
print(f"ответ за {time.monotonic() - started:.2f} с: {reply}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
raise SystemExit(asyncio.run(check()))
|
||||
|
|
|
|||
118
backend/scripts/local_llms.py
Normal file
118
backend/scripts/local_llms.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Запуск двух локальных GGUF-серверов через llama.cpp на Windows/macOS/Linux.
|
||||
|
||||
Никаких загрузок при старте: GGUF предварительно кладутся в models/ через
|
||||
download_local_models.py. Путь к llama-server задаётся LLAMA_SERVER_BIN либо
|
||||
берётся из PATH. Ctrl+C останавливает дочерние процессы.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PROFILES = {
|
||||
"dialogue": ("qwen3-1.7b/Qwen3-1.7B-Q8_0.gguf", "Qwen3-1.7B", 18080),
|
||||
"russian_control": ("vikhr-1b/Vikhr-Llama-3.2-1B-Q4_K_M.gguf", "Vikhr-1B", 18081),
|
||||
}
|
||||
|
||||
|
||||
def binary_path() -> str:
|
||||
bundled_mac = ROOT / "models" / "bin" / "llama-b10934" / "llama-server"
|
||||
binary = (os.environ.get("LLAMA_SERVER_BIN") or shutil.which("llama-server")
|
||||
or shutil.which("llama-server.exe")
|
||||
# The checked-out helper is Mach-O arm64. Do not select it on a
|
||||
# Windows machine merely because the whole models/ folder was
|
||||
# copied there for its platform-neutral GGUF weights.
|
||||
or (str(bundled_mac) if sys.platform == "darwin" and bundled_mac.is_file() else None))
|
||||
if not binary:
|
||||
raise RuntimeError("нет llama-server; установите бинарник llama.cpp и задайте LLAMA_SERVER_BIN")
|
||||
if not Path(binary).is_file():
|
||||
raise RuntimeError(f"llama-server не найден: {binary}")
|
||||
return binary
|
||||
|
||||
|
||||
def command(binary: str, profile: str, threads: int) -> list[str]:
|
||||
relative, alias, port = PROFILES[profile]
|
||||
model = ROOT / "models" / relative
|
||||
if not model.is_file():
|
||||
raise RuntimeError(f"нет модели: {model} — запустите make local-models")
|
||||
result = [binary, "-m", str(model), "--alias", alias,
|
||||
"--host", "127.0.0.1", "--port", str(port),
|
||||
"--ctx-size", "2048", "--threads", str(threads), "--parallel", "1",
|
||||
"--cors-origins", "localhost"]
|
||||
if profile == "dialogue":
|
||||
result += ["--reasoning-budget", "0"]
|
||||
return result
|
||||
|
||||
|
||||
def ready(port: int) -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=1) as response:
|
||||
return response.status == 200
|
||||
except (OSError, urllib.error.HTTPError):
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--mode", choices=["dialogue", "russian_control", "both"], default="dialogue")
|
||||
parser.add_argument("--threads", type=int, default=4)
|
||||
args = parser.parse_args()
|
||||
if args.threads < 1:
|
||||
parser.error("--threads должен быть положительным")
|
||||
selected = list(PROFILES) if args.mode == "both" else [args.mode]
|
||||
try:
|
||||
binary = binary_path()
|
||||
commands = [command(binary, profile, args.threads) for profile in selected]
|
||||
except RuntimeError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 2
|
||||
processes: list[subprocess.Popen] = []
|
||||
try:
|
||||
for profile, argv in zip(selected, commands):
|
||||
port = PROFILES[profile][2]
|
||||
if ready(port):
|
||||
raise RuntimeError(f"порт {port} уже занят сервером — не запускаю дубликат")
|
||||
print(f"запускаю {profile} на 127.0.0.1:{port}", flush=True)
|
||||
# Ctrl+C должен достаться управляющему процессу один раз: он сам
|
||||
# остановит дочерний сервер. Иначе llama.cpp получает двойной
|
||||
# SIGINT и на Metal иногда падает во время освобождения памяти.
|
||||
flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
|
||||
processes.append(subprocess.Popen(
|
||||
argv, cwd=ROOT, creationflags=flags, start_new_session=os.name != "nt"
|
||||
))
|
||||
deadline = time.monotonic() + 120
|
||||
while time.monotonic() < deadline:
|
||||
if any(process.poll() is not None for process in processes):
|
||||
raise RuntimeError("один из серверов модели завершился до готовности")
|
||||
if all(ready(PROFILES[profile][2]) for profile in selected):
|
||||
print("локальные модели готовы; Ctrl+C остановит их", flush=True)
|
||||
while all(process.poll() is None for process in processes):
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError("один из серверов модели неожиданно завершился")
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError("модели не стали готовы за 120 секунд")
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
except RuntimeError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
for process in processes:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
for process in processes:
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
84
backend/scripts/local_stt.py
Normal file
84
backend/scripts/local_stt.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Локальный whisper.cpp server для Whisper small (macOS / Windows / Linux).
|
||||
|
||||
Сначала скачайте GGML-вес через download_local_models.py. Исполняемый файл
|
||||
задаётся WHISPER_SERVER_BIN или ищется в PATH / локальной сборке. Сеть не нужна.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MODEL = ROOT / "models" / "whisper-small" / "ggml-small-q5_1.bin"
|
||||
PORT = 18082
|
||||
|
||||
|
||||
def binary_path() -> str:
|
||||
built = ROOT / "models" / "bin" / "whisper.cpp-1.9.4" / "build" / "bin" / "whisper-server"
|
||||
binary = (os.environ.get("WHISPER_SERVER_BIN") or shutil.which("whisper-server")
|
||||
or shutil.which("whisper-server.exe")
|
||||
# The in-tree build is macOS arm64, not a portable executable.
|
||||
or (str(built) if sys.platform == "darwin" and built.is_file() else None))
|
||||
if not binary or not Path(binary).is_file():
|
||||
raise RuntimeError("нет whisper-server; установите бинарник whisper.cpp и задайте WHISPER_SERVER_BIN")
|
||||
return binary
|
||||
|
||||
|
||||
def command(binary: str, threads: int = 4) -> list[str]:
|
||||
if not MODEL.is_file():
|
||||
raise RuntimeError(f"нет модели: {MODEL} — запустите make local-models")
|
||||
return [binary, "-m", str(MODEL), "--host", "127.0.0.1", "--port", str(PORT),
|
||||
"--language", "ru", "--threads", str(threads), "--no-fallback"]
|
||||
|
||||
|
||||
def ready() -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1) as response:
|
||||
return response.status == 200
|
||||
except (OSError, urllib.error.HTTPError):
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
if ready():
|
||||
raise RuntimeError(f"порт {PORT} уже занят сервером")
|
||||
argv = command(binary_path())
|
||||
except RuntimeError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 2
|
||||
flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
|
||||
process = subprocess.Popen(argv, cwd=ROOT, creationflags=flags, start_new_session=os.name != "nt")
|
||||
try:
|
||||
deadline = time.monotonic() + 120
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError("whisper-server завершился до готовности")
|
||||
if ready():
|
||||
print(f"Whisper small готов на 127.0.0.1:{PORT}; Ctrl+C остановит сервер", flush=True)
|
||||
while process.poll() is None:
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError("whisper-server неожиданно завершился")
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError("Whisper small не стал готов за 120 секунд")
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
except RuntimeError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,15 +1,21 @@
|
|||
"""make models: веса моделей в backend/models/.
|
||||
"""make models: вспомогательные веса голоса в backend/models/.
|
||||
|
||||
Эмбеддинги, распознавание (GigaAM v3, int8) и VAD качаются с Hugging Face.
|
||||
Эмбеддинги и VAD качаются с Hugging Face. Whisper small, Qwen3 и Vikhr
|
||||
скачиваются отдельным `make local-models` с проверкой SHA-256. Старый GigaAM
|
||||
доступен через `--legacy-gigaam`, но в выбранный стек больше не входит.
|
||||
Синтез (Silero TTS v5) лежит на models.silero.ai — российском хосте, который
|
||||
не отвечает из-под VPN: его скрипт не качает, а проверяет и говорит, что делать.
|
||||
Докачка продолжается с места обрыва: сеть на стенде бывает медленной.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import certifi
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MODELS = ROOT / "models"
|
||||
|
||||
|
|
@ -20,16 +26,16 @@ FILES = {
|
|||
"e5-small/config.json": E5 + "config.json",
|
||||
"e5-small/tokenizer.json": E5 + "tokenizer.json",
|
||||
"e5-small/model_quantized.onnx": E5 + "onnx/model_quantized.onnx",
|
||||
# RNNT — основная модель, CTC — запасная на случай, если RNNT не загрузится
|
||||
# (по скорости CTC не выигрывает — docs/LATENCY.md).
|
||||
"silero-vad/config.json": VAD + "config.json",
|
||||
"silero-vad/silero_vad.onnx": VAD + "silero_vad.onnx",
|
||||
}
|
||||
LEGACY_GIGAAM_FILES = {
|
||||
"gigaam-v3-onnx/config.json": GIGAAM + "config.json",
|
||||
"gigaam-v3-onnx/v3_vocab.txt": GIGAAM + "v3_vocab.txt",
|
||||
"gigaam-v3-onnx/v3_rnnt_encoder.int8.onnx": GIGAAM + "v3_rnnt_encoder.int8.onnx",
|
||||
"gigaam-v3-onnx/v3_rnnt_decoder.int8.onnx": GIGAAM + "v3_rnnt_decoder.int8.onnx",
|
||||
"gigaam-v3-onnx/v3_rnnt_joint.int8.onnx": GIGAAM + "v3_rnnt_joint.int8.onnx",
|
||||
"gigaam-v3-onnx/v3_ctc.int8.onnx": GIGAAM + "v3_ctc.int8.onnx",
|
||||
"silero-vad/config.json": VAD + "config.json",
|
||||
"silero-vad/silero_vad.onnx": VAD + "silero_vad.onnx",
|
||||
}
|
||||
|
||||
SILERO_TTS = "silero-tts/v5_ru.pt"
|
||||
|
|
@ -40,19 +46,27 @@ def fetch(url: str, target: Path) -> None:
|
|||
partial = target.with_suffix(target.suffix + ".part")
|
||||
offset = partial.stat().st_size if partial.exists() else 0
|
||||
request = urllib.request.Request(url, headers={"Range": f"bytes={offset}-"} if offset else {})
|
||||
with urllib.request.urlopen(request, timeout=60) as response, partial.open("ab") as out:
|
||||
total = offset + int(response.headers.get("Content-Length", 0))
|
||||
while chunk := response.read(1 << 20):
|
||||
out.write(chunk)
|
||||
done = out.tell()
|
||||
if total:
|
||||
print(f"\r {target.name}: {done // (1 << 20)} из {total // (1 << 20)} МБ", end="", flush=True)
|
||||
with urllib.request.urlopen(
|
||||
request, timeout=60, context=ssl.create_default_context(cafile=certifi.where())
|
||||
) as response:
|
||||
resumed = offset > 0 and response.status == 206
|
||||
with partial.open("ab" if resumed else "wb") as out:
|
||||
total = (offset if resumed else 0) + int(response.headers.get("Content-Length", 0))
|
||||
while chunk := response.read(1 << 20):
|
||||
out.write(chunk)
|
||||
done = out.tell()
|
||||
if total:
|
||||
print(f"\r {target.name}: {done // (1 << 20)} из {total // (1 << 20)} МБ", end="", flush=True)
|
||||
partial.rename(target)
|
||||
print()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for relative, url in FILES.items():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--legacy-gigaam", action="store_true")
|
||||
args = parser.parse_args()
|
||||
files = {**FILES, **(LEGACY_GIGAAM_FILES if args.legacy_gigaam else {})}
|
||||
for relative, url in files.items():
|
||||
target = MODELS / relative
|
||||
if target.exists():
|
||||
print(f" {relative}: уже есть")
|
||||
|
|
|
|||
|
|
@ -54,7 +54,10 @@ async def main() -> None:
|
|||
|
||||
slots = SlotMachine(scenario, embedder)
|
||||
persona = PersonaState(scenario.persona)
|
||||
caller = build_caller(scenario.id)
|
||||
caller = build_caller(
|
||||
scenario.id,
|
||||
use_pregenerated=scenario.tree.pregenerated,
|
||||
)
|
||||
|
||||
print(f"── {scenario.title} ({scenario.level.value}) ──")
|
||||
print("Вы — оператор 112. Команды: /подсказка /факты /итог /выход\n")
|
||||
|
|
|
|||
40
backend/scripts/smoke_voice.py
Normal file
40
backend/scripts/smoke_voice.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Проверка локального TTS → Whisper small без микрофона и внешней сети.
|
||||
|
||||
Это не замер сквозной задержки звонка: TTS создаёт синтетический образец, а
|
||||
загрузка моделей оплачивается отдельно. На Windows выполнить ту же команду.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from scipy.signal import resample_poly
|
||||
|
||||
from app.voice.models import Synthesizer, WhisperRecognizer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PHRASE = "Пожар на улице Ленина, дом четырнадцать. Нужна помощь."
|
||||
|
||||
|
||||
def main() -> None:
|
||||
started = time.monotonic()
|
||||
tts = Synthesizer(ROOT / "models" / "silero-tts" / "v5_ru.pt")
|
||||
print(f"TTS загружен за {time.monotonic() - started:.2f} с", flush=True)
|
||||
started = time.monotonic()
|
||||
pcm = tts.synthesize(PHRASE)
|
||||
audio = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768
|
||||
audio = resample_poly(audio, 2, 3).astype(np.float32)
|
||||
print(f"TTS: {time.monotonic() - started:.2f} с, звук {len(audio) / 16000:.2f} с", flush=True)
|
||||
|
||||
started = time.monotonic()
|
||||
stt = WhisperRecognizer("http://127.0.0.1:18082")
|
||||
stt.warmup()
|
||||
print(f"Whisper server готов за {time.monotonic() - started:.2f} с", flush=True)
|
||||
started = time.monotonic()
|
||||
answer = stt.transcribe(audio)
|
||||
print(f"Whisper: {time.monotonic() - started:.2f} с", flush=True)
|
||||
print(f"ожидалось: {PHRASE}\nполучено: {answer}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -30,7 +30,7 @@ from app.domain.roles import Role # noqa: E402
|
|||
PEOPLE = [
|
||||
("admin", "Администратор стенда", Role.ADMIN, None),
|
||||
("teacher", "Преподаватель", Role.INSTRUCTOR, None),
|
||||
("trainee", "Курсант", Role.TRAINEE, "ДДС района"),
|
||||
("trainee", "Курсант", Role.TRAINEE, "Служба 101"),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue