Stabilné spustenie projektu
This commit is contained in:
parent
d8961f9db6
commit
b943632fd1
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,6 +1,8 @@
|
|||||||
.env
|
.env
|
||||||
data/
|
data/
|
||||||
docs/
|
docs/
|
||||||
|
runtime_assets/*
|
||||||
|
!runtime_assets/.gitkeep
|
||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|||||||
16
Dockerfile
16
Dockerfile
@ -26,6 +26,22 @@ RUN python -m pip install --no-cache-dir \
|
|||||||
|
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY tools ./tools
|
COPY tools ./tools
|
||||||
|
COPY runtime_assets/ /opt/zp-assets/
|
||||||
|
|
||||||
|
ARG EMBEDDING_MODEL=intfloat/multilingual-e5-small
|
||||||
|
ARG EMBEDDING_REVISION=""
|
||||||
|
ARG CHUNK_TOKEN_ENCODING=cl100k_base
|
||||||
|
ARG RAG_LLM_TOKEN_ENCODING=cl100k_base
|
||||||
|
|
||||||
|
RUN python -m tools.runtime_assets prepare --output /opt/zp-assets \
|
||||||
|
--model "$EMBEDDING_MODEL" --revision "$EMBEDDING_REVISION" \
|
||||||
|
--encoding "$CHUNK_TOKEN_ENCODING" --encoding "$RAG_LLM_TOKEN_ENCODING"
|
||||||
|
|
||||||
|
ENV ZP_RUNTIME_ASSETS=/opt/zp-assets
|
||||||
|
ENV ZP_ASSETS_REQUIRED=1
|
||||||
|
ENV HF_HUB_OFFLINE=1
|
||||||
|
ENV TRANSFORMERS_OFFLINE=1
|
||||||
|
ENV TIKTOKEN_CACHE_DIR=/opt/zp-assets/tiktoken
|
||||||
|
|
||||||
RUN mkdir -p data
|
RUN mkdir -p data
|
||||||
|
|
||||||
|
|||||||
45
README.md
45
README.md
@ -619,10 +619,49 @@ CHUNK_TOKEN_ENCODING=cl100k_base
|
|||||||
|
|
||||||
## Spustenie cez Docker
|
## Spustenie cez Docker
|
||||||
|
|
||||||
|
Potrebný je dostupný Docker Engine, natívny Linux Compose v2 a Buildx.
|
||||||
|
Vo WSL používaj kontext, ku ktorému sa Linux klient vie pripojiť; Windows
|
||||||
|
kontext s `npipe` tu nie je náhradou Linux socketu. Overenie:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
docker --context default info
|
||||||
|
docker compose version
|
||||||
|
docker buildx version
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Po vytvorení `.env`, klonu `../zpwiki` a SSH konfigurácie spusti:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/start_project.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Skript štandardne používa kontext `default`, nemení globálny Docker kontext
|
||||||
|
a spravuje iba projekt `zp-agent`. Iný cieľ nastav cez `ZP_DOCKER_CONTEXT`.
|
||||||
|
Zostaví image, počká na zdravý Neo4j, overí existujúce indexy a podľa
|
||||||
|
potreby ich koordinovane vytvorí. Až potom spustí API a počká na readiness.
|
||||||
|
Pri opakovanom spustení ponechá konzistentné a aktuálne indexy.
|
||||||
|
|
||||||
|
Model aj tokenizér sú súčasťou image v `/opt/zp-assets`. Bežný štart
|
||||||
|
nevyžaduje internet ani existujúcu používateľskú Hugging Face/tiktoken cache.
|
||||||
|
Prvý build potrebuje balíky a modelové súbory; build overuje aj načítanie
|
||||||
|
pripravených assets so zakázanou sieťou. Prázdny HF cache volume neskrýva
|
||||||
|
model pribalený v image. Git pripojenie je samostatná sieťová operácia;
|
||||||
|
príznaky offline zakazujú sťahovanie modelu, nie synchronizáciu wiki.
|
||||||
|
|
||||||
|
Voliteľne môžeš pred buildom pripraviť ignorované lokálne podklady a využiť
|
||||||
|
už stiahnutý model z hostiteľa:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m tools.runtime_assets prepare
|
||||||
|
```
|
||||||
|
|
||||||
|
`runtime_assets/` obsahuje veľké modelové súbory a do Gitu nepatrí; verzovaný
|
||||||
|
je iba prázdny adresár pre čistý build. Bez pripravených lokálnych podkladov
|
||||||
|
si ich Docker build vytvorí sám. Súbory majú manifest so SHA-256 a model je
|
||||||
|
pripnutý na konkrétny commit. Iný model/revízia vyžaduje nový build s
|
||||||
|
`EMBEDDING_MODEL`/`EMBEDDING_REVISION` a zodpovedajúce pripravené assets.
|
||||||
|
API pri nesúlade neprejde potichu na sťahovanie iného modelu.
|
||||||
|
|
||||||
Kontrola služby:
|
Kontrola služby:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -647,8 +686,8 @@ Zastavenie:
|
|||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
Hugging Face cache je uložená v persistentnom Docker volume, takže sa
|
Hugging Face cache volume zostáva persistentné; predvolený embeddingový
|
||||||
embeddingový model pri bežnom reštarte kontajnera nemusí znova sťahovať.
|
model a tokenizér však štartujú priamo z overených súborov v image.
|
||||||
|
|
||||||
Neo4j je spustené ako samostatná služba v rovnakom Docker Compose
|
Neo4j je spustené ako samostatná služba v rovnakom Docker Compose
|
||||||
prostredí. Stav služieb je možné skontrolovať:
|
prostredí. Stav služieb je možné skontrolovať:
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
zp-agent-api:
|
zp-agent-api:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-intfloat/multilingual-e5-small}
|
||||||
|
EMBEDDING_REVISION: ${EMBEDDING_REVISION:-}
|
||||||
|
CHUNK_TOKEN_ENCODING: ${CHUNK_TOKEN_ENCODING:-cl100k_base}
|
||||||
|
RAG_LLM_TOKEN_ENCODING: ${RAG_LLM_TOKEN_ENCODING:-cl100k_base}
|
||||||
container_name: zp-agent-api
|
container_name: zp-agent-api
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
@ -13,10 +19,14 @@ services:
|
|||||||
CHUNK_MAX_TOKENS: "450"
|
CHUNK_MAX_TOKENS: "450"
|
||||||
CHUNK_OVERLAP_TOKENS: "70"
|
CHUNK_OVERLAP_TOKENS: "70"
|
||||||
CHUNK_MIN_TOKENS: "80"
|
CHUNK_MIN_TOKENS: "80"
|
||||||
CHUNK_TOKEN_ENCODING: cl100k_base
|
CHUNK_TOKEN_ENCODING: ${CHUNK_TOKEN_ENCODING:-cl100k_base}
|
||||||
RAG_LLM_MAX_TOKENS: ${RAG_LLM_MAX_TOKENS:-12000}
|
RAG_LLM_MAX_TOKENS: ${RAG_LLM_MAX_TOKENS:-12000}
|
||||||
RAG_LLM_TOKEN_ENCODING: ${RAG_LLM_TOKEN_ENCODING:-cl100k_base}
|
RAG_LLM_TOKEN_ENCODING: ${RAG_LLM_TOKEN_ENCODING:-cl100k_base}
|
||||||
HF_HOME: /cache/huggingface
|
HF_HOME: /cache/huggingface
|
||||||
|
HF_HUB_OFFLINE: "1"
|
||||||
|
TRANSFORMERS_OFFLINE: "1"
|
||||||
|
ZP_RUNTIME_ASSETS: /opt/zp-assets
|
||||||
|
ZP_ASSETS_REQUIRED: "1"
|
||||||
GIT_SSH_COMMAND: >-
|
GIT_SSH_COMMAND: >-
|
||||||
ssh -F /dev/null -i /run/secrets/zpwiki_ssh_key
|
ssh -F /dev/null -i /run/secrets/zpwiki_ssh_key
|
||||||
-o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes
|
-o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes
|
||||||
@ -45,6 +55,15 @@ services:
|
|||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
stop_grace_period: 5m
|
stop_grace_period: 5m
|
||||||
|
depends_on:
|
||||||
|
neo4j:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-m", "tools.runtime_health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 12
|
||||||
|
start_period: 90s
|
||||||
|
|
||||||
neo4j:
|
neo4j:
|
||||||
image: neo4j:2026.09.0
|
image: neo4j:2026.09.0
|
||||||
|
|||||||
0
runtime_assets/.gitkeep
Normal file
0
runtime_assets/.gitkeep
Normal file
16
scripts/start_project.sh
Executable file
16
scripts/start_project.sh
Executable file
@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
project_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
docker_context="${ZP_DOCKER_CONTEXT:-default}"
|
||||||
|
compose=(docker --context "$docker_context" compose --project-name zp-agent
|
||||||
|
--project-directory "$project_root" -f "$project_root/docker-compose.yml")
|
||||||
|
|
||||||
|
docker --context "$docker_context" info --format '{{.ServerVersion}}' >/dev/null
|
||||||
|
"${compose[@]}" version >/dev/null
|
||||||
|
"${compose[@]}" config --quiet
|
||||||
|
"${compose[@]}" build zp-agent-api
|
||||||
|
"${compose[@]}" up -d --wait neo4j
|
||||||
|
"${compose[@]}" run --rm -T --no-deps zp-agent-api python -m tools.bootstrap_indexes
|
||||||
|
"${compose[@]}" up -d --no-build --wait zp-agent-api
|
||||||
|
"${compose[@]}" ps
|
||||||
100
test/test_runtime_assets.py
Normal file
100
test/test_runtime_assets.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tools import runtime_assets as assets
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bundle(tmp_path):
|
||||||
|
files = {'model/config.json': b'{}', 'model/modules.json': b'[]',
|
||||||
|
'model/tokenizer.json': b'{}', 'model/model.safetensors': b'weights',
|
||||||
|
'tiktoken/token-file': b'tokenizer'}
|
||||||
|
for name, value in files.items():
|
||||||
|
path = tmp_path / name
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(value)
|
||||||
|
manifest = {'schema_version': 1, 'embedding': {'model': assets.DEFAULT_MODEL, 'revision': assets.DEFAULT_REVISION},
|
||||||
|
'encodings': ['cl100k_base'], 'files': {name: hashlib.sha256(value).hexdigest() for name, value in files.items()}}
|
||||||
|
(tmp_path / 'manifest.json').write_text(json.dumps(manifest))
|
||||||
|
return tmp_path, manifest
|
||||||
|
|
||||||
|
|
||||||
|
def test_assets_verify_integrity_and_reject_corruption(bundle):
|
||||||
|
root, manifest = bundle
|
||||||
|
assert assets.verify(root) == manifest
|
||||||
|
(root / 'model/model.safetensors').write_bytes(b'corrupted')
|
||||||
|
with pytest.raises(RuntimeError, match='poškodený'):
|
||||||
|
assets.verify(root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepared_assets_override_empty_user_cache_and_pin_revision(bundle, monkeypatch, tmp_path):
|
||||||
|
root, _ = bundle
|
||||||
|
monkeypatch.setenv('ZP_RUNTIME_ASSETS', str(root))
|
||||||
|
monkeypatch.setenv('ZP_ASSETS_REQUIRED', '1')
|
||||||
|
monkeypatch.setenv('TIKTOKEN_CACHE_DIR', str(tmp_path / 'empty'))
|
||||||
|
assets.configure_tokenizer()
|
||||||
|
assert os.environ['TIKTOKEN_CACHE_DIR'] == str(root / 'tiktoken')
|
||||||
|
assert assets.local_model(assets.DEFAULT_MODEL, None) == str(root / 'model')
|
||||||
|
with pytest.raises(RuntimeError, match='revízia'):
|
||||||
|
assets.local_model(assets.DEFAULT_MODEL, 'a' * 40)
|
||||||
|
with pytest.raises(RuntimeError, match='nie je v image'):
|
||||||
|
assets.local_model('other/model', None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_required_assets_never_silently_fall_back_to_remote_download(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv('ZP_RUNTIME_ASSETS', str(tmp_path))
|
||||||
|
monkeypatch.setenv('ZP_ASSETS_REQUIRED', '1')
|
||||||
|
with pytest.raises(RuntimeError, match='Chýbajú runtime assets'):
|
||||||
|
assets.configure_tokenizer()
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_assets_are_verified_without_download(bundle, monkeypatch):
|
||||||
|
root, manifest = bundle
|
||||||
|
probes = []
|
||||||
|
monkeypatch.setattr(assets, 'offline_probe', lambda path: probes.append(path))
|
||||||
|
assert assets.prepare(root, assets.DEFAULT_MODEL, None, ['cl100k_base']) == manifest
|
||||||
|
assert probes == [root]
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_cannot_escape_asset_directory(bundle):
|
||||||
|
root, manifest = bundle
|
||||||
|
manifest['files']['../outside'] = 'a' * 64
|
||||||
|
(root / 'manifest.json').write_text(json.dumps(manifest))
|
||||||
|
with pytest.raises(RuntimeError, match='mimo runtime assets'):
|
||||||
|
assets.verify(root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fresh_api_lifespan_loads_model_and_tokenizer_without_network_or_user_cache(tmp_path):
|
||||||
|
environment = {**os.environ, 'ZP_RUNTIME_ASSETS': str(assets.asset_root().resolve()),
|
||||||
|
'ZP_ASSETS_REQUIRED': '1', 'HF_HOME': str(tmp_path / 'empty-hf'),
|
||||||
|
'TIKTOKEN_CACHE_DIR': str(tmp_path / 'empty-tokenizer'),
|
||||||
|
'HF_HUB_OFFLINE': '1', 'TRANSFORMERS_OFFLINE': '1', 'TEST_JOB_DIR': str(tmp_path / 'jobs')}
|
||||||
|
code = '''
|
||||||
|
import socket, asyncio, os
|
||||||
|
from pathlib import Path
|
||||||
|
def denied(*args, **kwargs): raise AssertionError('Unexpected network access')
|
||||||
|
socket.socket.connect = denied
|
||||||
|
socket.socket.connect_ex = denied
|
||||||
|
socket.getaddrinfo = denied
|
||||||
|
import app.main as main
|
||||||
|
from tools.rag.build_chunks import TOKEN_COUNTER
|
||||||
|
from tools.rag.embedding_utils import embed_query
|
||||||
|
main.DATA_DIR = Path(os.environ['TEST_JOB_DIR'])
|
||||||
|
async def run():
|
||||||
|
async with main.lifespan(main.app):
|
||||||
|
assert main.app.state.embedding_ready
|
||||||
|
assert TOKEN_COUNTER.encoding is not None
|
||||||
|
assert embed_query('štart bez siete').shape[0] == 384
|
||||||
|
asyncio.run(run())
|
||||||
|
print('offline API lifespan OK')
|
||||||
|
'''
|
||||||
|
result = subprocess.run([sys.executable, '-c', code], env=environment,
|
||||||
|
capture_output=True, text=True, timeout=60)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert 'offline API lifespan OK' in result.stdout
|
||||||
34
tools/bootstrap_indexes.py
Normal file
34
tools/bootstrap_indexes.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
"""Keep a valid generation, or create both indexes before starting the API."""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.security import validate_security_configuration
|
||||||
|
from tools.rag.common import DATA_DIR, DB_FILE, ZPWIKI_ROOT
|
||||||
|
from tools.rag.embedding_utils import embed_query
|
||||||
|
from tools.rag.rebuild_index import rebuild_index
|
||||||
|
from tools.runtime_assets import asset_root, verify
|
||||||
|
from tools.sync_indexes import git_revision, source_files
|
||||||
|
from tools.tool_health import tools_readiness
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
validate_security_configuration()
|
||||||
|
verify(asset_root())
|
||||||
|
embed_query('overenie pripravenosti')
|
||||||
|
tools, version = tools_readiness(DB_FILE, security_configured=True, embedding_ready=True)
|
||||||
|
source_hash = hashlib.sha256(json.dumps(source_files(ZPWIKI_ROOT), sort_keys=True).encode()).hexdigest()
|
||||||
|
current = (all(value['ready'] for value in tools.values()) and version
|
||||||
|
and version.get('git_revision') == git_revision(ZPWIKI_ROOT)
|
||||||
|
and version.get('source_sha256') == source_hash)
|
||||||
|
if not current:
|
||||||
|
result = rebuild_index(pull_git=False)
|
||||||
|
tools, version = tools_readiness(DB_FILE, security_configured=True, embedding_ready=True)
|
||||||
|
if not all(value['ready'] for value in tools.values()):
|
||||||
|
raise RuntimeError('Nové indexy nie sú pripravené; API sa nespustí.')
|
||||||
|
print(json.dumps({'bootstrap': 'rebuilt', 'counts': result['counts'], 'index_version': version}))
|
||||||
|
else:
|
||||||
|
print(json.dumps({'bootstrap': 'existing', 'index_version': version}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@ -24,6 +24,9 @@ from tools.rag.common import (
|
|||||||
write_json,
|
write_json,
|
||||||
)
|
)
|
||||||
from tools.text_budget import split_to_fit
|
from tools.text_budget import split_to_fit
|
||||||
|
from tools.runtime_assets import configure_tokenizer
|
||||||
|
|
||||||
|
configure_tokenizer()
|
||||||
|
|
||||||
|
|
||||||
MAX_TOKENS = int(os.getenv("CHUNK_MAX_TOKENS", "450"))
|
MAX_TOKENS = int(os.getenv("CHUNK_MAX_TOKENS", "450"))
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from typing import Any
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from tools.text_budget import split_to_fit
|
from tools.text_budget import split_to_fit
|
||||||
|
from tools.runtime_assets import local_model
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_EMBEDDING_MODEL = (
|
DEFAULT_EMBEDDING_MODEL = (
|
||||||
@ -59,9 +60,11 @@ def _load_embedding_model(model_name: str, revision: str | None):
|
|||||||
SentenceTransformer,
|
SentenceTransformer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
bundled = local_model(model_name, revision)
|
||||||
return SentenceTransformer(
|
return SentenceTransformer(
|
||||||
model_name,
|
bundled or model_name,
|
||||||
revision=revision,
|
revision=None if bundled else revision,
|
||||||
|
local_files_only=bool(bundled) or os.getenv('HF_HUB_OFFLINE') == '1',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
143
tools/runtime_assets.py
Normal file
143
tools/runtime_assets.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"""Prepare immutable model/tokenizer assets during build, never during startup."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_MODEL = 'intfloat/multilingual-e5-small'
|
||||||
|
DEFAULT_REVISION = '614241f622f53c4eeff9890bdc4f31cfecc418b3'
|
||||||
|
DEFAULT_ROOT = Path(__file__).resolve().parents[1] / 'runtime_assets'
|
||||||
|
|
||||||
|
|
||||||
|
def asset_root() -> Path:
|
||||||
|
return Path(os.getenv('ZP_RUNTIME_ASSETS', str(DEFAULT_ROOT)))
|
||||||
|
|
||||||
|
|
||||||
|
def manifest(root: Path | None = None) -> dict | None:
|
||||||
|
path = (root or asset_root()) / 'manifest.json'
|
||||||
|
if not path.exists():
|
||||||
|
if os.getenv('ZP_ASSETS_REQUIRED') == '1':
|
||||||
|
raise RuntimeError('Chýbajú runtime assets; zostavte image alebo spustite python -m tools.runtime_assets prepare.')
|
||||||
|
return None
|
||||||
|
return json.loads(path.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def configure_tokenizer() -> None:
|
||||||
|
data = manifest()
|
||||||
|
if data is not None:
|
||||||
|
# Bundled immutable files take precedence over an empty user cache.
|
||||||
|
os.environ['TIKTOKEN_CACHE_DIR'] = str(asset_root() / 'tiktoken')
|
||||||
|
|
||||||
|
|
||||||
|
def local_model(model_name: str, revision: str | None) -> str | None:
|
||||||
|
data = manifest()
|
||||||
|
if data and data['embedding']['model'] == model_name:
|
||||||
|
if revision and revision != data['embedding']['revision']:
|
||||||
|
raise RuntimeError('Požadovaná revízia modelu nie je v image; vytvorte nový image s touto revíziou.')
|
||||||
|
return str(asset_root() / 'model')
|
||||||
|
if data and os.getenv('ZP_ASSETS_REQUIRED') == '1':
|
||||||
|
raise RuntimeError('Požadovaný embeddingový model nie je v image; vytvorte nový image.')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def verify(root: Path) -> dict:
|
||||||
|
data = json.loads((root / 'manifest.json').read_text())
|
||||||
|
for relative, digest in data['files'].items():
|
||||||
|
path = root / relative
|
||||||
|
if not path.resolve().is_relative_to(root.resolve()):
|
||||||
|
raise RuntimeError('Manifest obsahuje cestu mimo runtime assets')
|
||||||
|
if not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
|
||||||
|
raise RuntimeError(f'Chýbajúci alebo poškodený runtime asset: {relative}')
|
||||||
|
if not any(name.startswith('tiktoken/') for name in data['files']):
|
||||||
|
raise RuntimeError('Manifest neobsahuje tokenizér')
|
||||||
|
required = {'model/config.json', 'model/modules.json', 'model/tokenizer.json'}
|
||||||
|
if not required.issubset(data['files']) or not any(name.endswith('.safetensors') for name in data['files']):
|
||||||
|
raise RuntimeError('Manifest neobsahuje kompletný embeddingový model')
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def offline_probe(root: Path) -> None:
|
||||||
|
"""Fresh interpreter, with network calls forbidden even on an online builder."""
|
||||||
|
environment = {**os.environ, 'ZP_RUNTIME_ASSETS': str(root.resolve()), 'ZP_ASSETS_REQUIRED': '1',
|
||||||
|
'HF_HUB_OFFLINE': '1', 'TRANSFORMERS_OFFLINE': '1', 'HF_HOME': str(root / 'unused-user-cache'),
|
||||||
|
'TIKTOKEN_CACHE_DIR': str(root / 'unused-token-cache')}
|
||||||
|
code = '''
|
||||||
|
import socket
|
||||||
|
def denied(*args, **kwargs): raise RuntimeError('Network access during offline asset probe')
|
||||||
|
socket.socket.connect = denied
|
||||||
|
socket.socket.connect_ex = denied
|
||||||
|
socket.getaddrinfo = denied
|
||||||
|
from tools.runtime_assets import configure_tokenizer, manifest, local_model
|
||||||
|
configure_tokenizer()
|
||||||
|
import tiktoken
|
||||||
|
for encoding in manifest()['encodings']:
|
||||||
|
assert tiktoken.get_encoding(encoding).encode('Overenie štartu bez siete.')
|
||||||
|
from sentence_transformers import SentenceTransformer
|
||||||
|
data = manifest()['embedding']
|
||||||
|
model = SentenceTransformer(local_model(data['model'], data['revision']), local_files_only=True)
|
||||||
|
assert model.encode(['query: overenie bez siete'], show_progress_bar=False).shape[0] == 1
|
||||||
|
print('Offline model/tokenizer probe: OK')
|
||||||
|
'''
|
||||||
|
subprocess.run([sys.executable, '-c', code], env=environment, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(root: Path, model: str, revision: str | None, encodings: list[str]) -> dict:
|
||||||
|
revision = revision or (DEFAULT_REVISION if model == DEFAULT_MODEL else '')
|
||||||
|
if not re.fullmatch('[0-9a-f]{40}', revision):
|
||||||
|
raise ValueError('Príprava modelu vyžaduje presnú 40-znakovú Git revíziu')
|
||||||
|
if (root / 'manifest.json').is_file():
|
||||||
|
data = verify(root)
|
||||||
|
if data['embedding'] == {'model': model, 'revision': revision} and data['encodings'] == encodings:
|
||||||
|
offline_probe(root)
|
||||||
|
return data
|
||||||
|
raise RuntimeError('Assets patria inej konfigurácii; použite nový/prázdny pracovný adresár.')
|
||||||
|
from huggingface_hub import snapshot_download
|
||||||
|
from huggingface_hub.errors import LocalEntryNotFoundError
|
||||||
|
try:
|
||||||
|
source = snapshot_download(model, revision=revision, local_files_only=True)
|
||||||
|
except (FileNotFoundError, LocalEntryNotFoundError):
|
||||||
|
source = snapshot_download(model, revision=revision,
|
||||||
|
allow_patterns=['*.json', '*.safetensors', '*.model', '*.txt'],
|
||||||
|
ignore_patterns=['onnx/*', 'openvino/*'])
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copytree(source, root / 'model', dirs_exist_ok=True, symlinks=False,
|
||||||
|
ignore=shutil.ignore_patterns('onnx', 'openvino', '*.bin', '*.md', '.cache', '.gitattributes'))
|
||||||
|
environment = {**os.environ, 'TIKTOKEN_CACHE_DIR': str(root.resolve() / 'tiktoken')}
|
||||||
|
subprocess.run([sys.executable, '-c',
|
||||||
|
'import sys,tiktoken; [tiktoken.get_encoding(name) for name in sys.argv[1:]]', *encodings],
|
||||||
|
env=environment, check=True)
|
||||||
|
files = {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest()
|
||||||
|
for folder in ['model', 'tiktoken'] for p in sorted((root / folder).rglob('*')) if p.is_file()}
|
||||||
|
data = {'schema_version': 1, 'embedding': {'model': model, 'revision': revision},
|
||||||
|
'encodings': encodings, 'files': files}
|
||||||
|
(root / 'manifest.json').write_text(json.dumps(data, sort_keys=True, indent=2) + '\n')
|
||||||
|
verify(root)
|
||||||
|
offline_probe(root)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('command', choices=['prepare', 'verify'])
|
||||||
|
parser.add_argument('--output', type=Path, default=asset_root())
|
||||||
|
parser.add_argument('--model', default=DEFAULT_MODEL)
|
||||||
|
parser.add_argument('--revision')
|
||||||
|
parser.add_argument('--encoding', action='append')
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.command == 'prepare':
|
||||||
|
data = prepare(args.output, args.model, args.revision, list(dict.fromkeys(args.encoding or ['cl100k_base'])))
|
||||||
|
else:
|
||||||
|
data = verify(args.output)
|
||||||
|
offline_probe(args.output)
|
||||||
|
print(json.dumps({'model': data['embedding'], 'encodings': data['encodings'], 'files': len(data['files'])}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
13
tools/runtime_health.py
Normal file
13
tools/runtime_health.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
"""Container readiness: HTTP 200 alone does not mean that indexes are ready."""
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=8) as response:
|
||||||
|
health = json.load(response)
|
||||||
|
raise SystemExit(0 if health.get('ready') is True else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue
Block a user