mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 04:14:42 +00:00
feat: air-gapped deployment guide, no implicit downloads
- Ship tiktoken's cl100k_base inside the package and build the encoding from it, so token counting never downloads anything. - Default EMBEDDINGS_CACHE_DIR to <data home>/models instead of FastEmbed's temp dir, and read tokenizer.json and repo metadata from that cache, so a model downloads once and survives reboots. - TTS_PROVIDER=none and STT_PROVIDER=none switch the speech features off: the endpoints return 404, audio files fail to ingest with a clear message, /api/config reports tts_available/stt_available, and the UI hides the Speak and microphone buttons. - Drop the Google Fonts Roboto import from the web UI. - prefetch-models fills the cache the app reads; verify-offline checks the packaged encoding. - Docs: new Air-Gapped Deployment guide, settings and cache notes.
This commit is contained in:
1 parent
ca69d1ea29
commit
7da46c2bea
38 files changed
+100892
-95
No files matched your search
+1
-2
@@ -108,11 +108,10 @@ COPY --from=builder /venv /venv
|
||||
# EMBEDDINGS_CACHE_DIR / HF_HUB_CACHE FastEmbed models and their tokenizers
|
||||
# (chunking reads tokenizer.json from
|
||||
# the same hub-layout snapshot)
|
||||
# TIKTOKEN_CACHE_DIR cl100k_base for token accounting
|
||||
# DOCLING_ARTIFACTS_PATH docling's models (docling extra only)
|
||||
# tiktoken's cl100k_base encoding ships inside the package.
|
||||
ENV EMBEDDINGS_CACHE_DIR=/app/models \
|
||||
HF_HUB_CACHE=/app/models \
|
||||
TIKTOKEN_CACHE_DIR=/app/models/tiktoken \
|
||||
DOCLING_ARTIFACTS_PATH=/app/models/docling \
|
||||
HF_HUB_DISABLE_TELEMETRY=1 \
|
||||
PATH="/venv/bin:$PATH"
|
||||
|
||||
@@ -345,6 +345,15 @@ class StoreAttachment(Resource):
|
||||
return make_response(jsonify({"success": False, "error": "Failed to store attachment"}), 400)
|
||||
|
||||
|
||||
_STT_DISABLED_MESSAGE = "Speech-to-text is disabled on this server."
|
||||
_TTS_DISABLED_MESSAGE = "Text-to-speech is disabled on this server."
|
||||
|
||||
|
||||
def _feature_disabled(message: str):
|
||||
"""404 for a speech feature whose provider is set to ``none``."""
|
||||
return make_response(jsonify({"success": False, "message": message}), 404)
|
||||
|
||||
|
||||
@attachments_ns.route("/stt")
|
||||
class SpeechToText(Resource):
|
||||
@api.expect(
|
||||
@@ -368,6 +377,8 @@ class SpeechToText(Resource):
|
||||
jsonify({"success": False, "message": "Authentication required"}),
|
||||
401,
|
||||
)
|
||||
if not STTCreator.is_enabled(settings.STT_PROVIDER):
|
||||
return _feature_disabled(_STT_DISABLED_MESSAGE)
|
||||
|
||||
file = request.files.get("file")
|
||||
if not file or file.filename == "":
|
||||
@@ -440,6 +451,8 @@ class LiveSpeechToTextStart(Resource):
|
||||
jsonify({"success": False, "message": "Authentication required"}),
|
||||
401,
|
||||
)
|
||||
if not STTCreator.is_enabled(settings.STT_PROVIDER):
|
||||
return _feature_disabled(_STT_DISABLED_MESSAGE)
|
||||
|
||||
redis_client = _require_live_stt_redis()
|
||||
if hasattr(redis_client, "status_code"):
|
||||
@@ -501,6 +514,8 @@ class LiveSpeechToTextChunk(Resource):
|
||||
jsonify({"success": False, "message": "Authentication required"}),
|
||||
401,
|
||||
)
|
||||
if not STTCreator.is_enabled(settings.STT_PROVIDER):
|
||||
return _feature_disabled(_STT_DISABLED_MESSAGE)
|
||||
|
||||
redis_client = _require_live_stt_redis()
|
||||
if hasattr(redis_client, "status_code"):
|
||||
@@ -817,6 +832,8 @@ class TextToSpeech(Resource):
|
||||
@api.expect(tts_model)
|
||||
@api.doc(description="Synthesize audio speech from text")
|
||||
def post(self):
|
||||
if not TTSCreator.is_enabled(settings.TTS_PROVIDER):
|
||||
return _feature_disabled(_TTS_DISABLED_MESSAGE)
|
||||
data = request.get_json()
|
||||
text = data["text"]
|
||||
try:
|
||||
|
||||
@@ -203,12 +203,16 @@ def health():
|
||||
@app.route("/api/config")
|
||||
def get_config():
|
||||
from docsgpt.graphrag import graphrag_available
|
||||
from docsgpt.stt.stt_creator import STTCreator
|
||||
from docsgpt.tts.tts_creator import TTSCreator
|
||||
|
||||
response = {
|
||||
"auth_type": settings.AUTH_TYPE,
|
||||
"requires_auth": settings.AUTH_TYPE in ["simple_jwt", "session_jwt", "oidc"],
|
||||
"graphrag_available": graphrag_available(),
|
||||
"hybrid_available": settings.VECTOR_STORE == "pgvector",
|
||||
"tts_available": TTSCreator.is_enabled(settings.TTS_PROVIDER),
|
||||
"stt_available": STTCreator.is_enabled(settings.STT_PROVIDER),
|
||||
}
|
||||
if settings.AUTH_TYPE == "oidc":
|
||||
response["oidc"] = {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -57,7 +57,8 @@ class Settings(BaseSettings):
|
||||
# Intra-op threads for the local ONNX runner; None = every core. It scales sub-linearly,
|
||||
# so several single-threaded workers beat one many-threaded process on the same cores.
|
||||
EMBEDDINGS_THREADS: Optional[int] = None
|
||||
EMBEDDINGS_CACHE_DIR: Optional[str] = None # where FastEmbed caches model artifacts
|
||||
# Embedding models and their tokenizers. Persistent by default: FastEmbed's own default is the temp dir.
|
||||
EMBEDDINGS_CACHE_DIR: Optional[str] = Field(default_factory=lambda: str(home_dir() / "models"))
|
||||
# Pooling ("cls"/"mean") and L2 normalisation. Read from the model's own repository;
|
||||
# set these only for a repository that declares neither, or to override what it declares.
|
||||
EMBEDDINGS_POOLING: Optional[str] = None
|
||||
@@ -360,9 +361,9 @@ class Settings(BaseSettings):
|
||||
# Encryption settings
|
||||
ENCRYPTION_SECRET_KEY: str = "default-docsgpt-encryption-key"
|
||||
|
||||
TTS_PROVIDER: str = "google_tts" # google_tts or elevenlabs
|
||||
TTS_PROVIDER: str = "google_tts" # google_tts, elevenlabs, or none to switch text-to-speech off
|
||||
ELEVENLABS_API_KEY: Optional[str] = None
|
||||
STT_PROVIDER: str = "openai" # openai or faster_whisper
|
||||
STT_PROVIDER: str = "openai" # openai, faster_whisper, or none to switch speech-to-text off
|
||||
OPENAI_STT_MODEL: str = "gpt-4o-mini-transcribe"
|
||||
STT_LANGUAGE: Optional[str] = None
|
||||
STT_MAX_FILE_SIZE_MB: int = 50
|
||||
|
||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
from typing import Dict, Union
|
||||
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.parser.file.base_parser import BaseParser
|
||||
from docsgpt.parser.file.base_parser import BaseParser, DocumentParseError
|
||||
from docsgpt.stt.stt_creator import STTCreator
|
||||
from docsgpt.stt.upload_limits import enforce_audio_file_size_limit
|
||||
|
||||
@@ -17,6 +17,10 @@ class AudioParser(BaseParser):
|
||||
|
||||
def parse_file(self, file: Path, errors: str = "ignore") -> Union[str, list[str]]:
|
||||
_ = errors
|
||||
if not STTCreator.is_enabled(settings.STT_PROVIDER):
|
||||
raise DocumentParseError(
|
||||
f"{file.name}: audio files need speech-to-text, which is disabled (STT_PROVIDER=none)."
|
||||
)
|
||||
try:
|
||||
enforce_audio_file_size_limit(file.stat().st_size)
|
||||
except OSError:
|
||||
|
||||
@@ -217,19 +217,21 @@ class HuggingFaceCounter(TokenCounter):
|
||||
|
||||
|
||||
def _tokenizer_file(repo: str) -> str:
|
||||
"""Path to ``repo``'s ``tokenizer.json``, from the hub cache when present.
|
||||
"""Path to ``repo``'s ``tokenizer.json``, from the embedding model cache when present.
|
||||
|
||||
A warmed cache (the Docker image bakes the default models) answers without
|
||||
touching the network. ``hf_hub_download`` would otherwise revalidate the
|
||||
revision with a HEAD request on every process start, and stall for the
|
||||
etag timeout on a host that cannot reach huggingface.co.
|
||||
FastEmbed's snapshot of the repository in ``EMBEDDINGS_CACHE_DIR`` already
|
||||
holds the file, so a warmed cache answers without touching the network.
|
||||
``hf_hub_download`` would otherwise revalidate the revision with a HEAD
|
||||
request on every process start, and stall for the etag timeout on a host
|
||||
that cannot reach huggingface.co.
|
||||
"""
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
cache_dir = settings.EMBEDDINGS_CACHE_DIR or None
|
||||
try:
|
||||
return hf_hub_download(repo, "tokenizer.json", local_files_only=True)
|
||||
return hf_hub_download(repo, "tokenizer.json", local_files_only=True, cache_dir=cache_dir)
|
||||
except Exception: # noqa: BLE001 -- not cached: fetch it
|
||||
return hf_hub_download(repo, "tokenizer.json")
|
||||
return hf_hub_download(repo, "tokenizer.json", cache_dir=cache_dir)
|
||||
|
||||
|
||||
def _load_hf_counter(repo: str) -> Optional[HuggingFaceCounter]:
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"""Download the model artifacts a fresh container would otherwise fetch.
|
||||
"""Download the embedding models a fresh install would otherwise fetch.
|
||||
|
||||
Run at image build time so a fresh container does not download on its first
|
||||
request, and an air-gapped install works at all. Two things are warmed:
|
||||
|
||||
* Embedding models, into FastEmbed's cache. Both the legacy and the current
|
||||
default are baked: an upgraded deployment keeps using mpnet until it runs
|
||||
``reembed``, while a new one starts on granite.
|
||||
* tiktoken's ``cl100k_base`` encoding, which token accounting uses on every
|
||||
chat. tiktoken caches it under ``TIKTOKEN_CACHE_DIR`` (a temp dir when
|
||||
unset), so the image sets that variable and this warms it.
|
||||
request, and before moving an install onto a host without internet access.
|
||||
Both the legacy and the current default embedding model are fetched: an
|
||||
upgraded deployment keeps using mpnet until it runs ``reembed``, while a new
|
||||
one starts on granite. Each model lands in ``EMBEDDINGS_CACHE_DIR`` together
|
||||
with its tokenizer, which chunking reads from the same snapshot. tiktoken's
|
||||
``cl100k_base`` encoding ships inside the package, so there is nothing to warm
|
||||
for it.
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -34,23 +33,6 @@ logger = logging.getLogger("prefetch_models")
|
||||
#: Fetched when no names are given.
|
||||
DEFAULT_MODELS = (DEFAULT_LEGACY, DEFAULT_NEW_INSTALL)
|
||||
|
||||
#: tiktoken encodings the application loads (``docsgpt.utils.get_encoding``).
|
||||
TIKTOKEN_ENCODINGS = ("cl100k_base",)
|
||||
|
||||
|
||||
def prefetch_tiktoken(names: Sequence[str] = TIKTOKEN_ENCODINGS) -> List[str]:
|
||||
"""Warm tiktoken's cache for each encoding in ``names``.
|
||||
|
||||
Returns:
|
||||
The encodings fetched.
|
||||
"""
|
||||
import tiktoken
|
||||
|
||||
for name in names:
|
||||
logger.info("Fetching tiktoken encoding %s", name)
|
||||
tiktoken.get_encoding(name)
|
||||
return list(names)
|
||||
|
||||
|
||||
def prefetch(names: Sequence[str], cache_dir: Optional[str] = None) -> List[str]:
|
||||
"""Fetch each named model's artifacts.
|
||||
@@ -97,6 +79,22 @@ def prefetch(names: Sequence[str], cache_dir: Optional[str] = None) -> List[str]
|
||||
return fetched
|
||||
|
||||
|
||||
def _cache_dir() -> Optional[str]:
|
||||
"""``EMBEDDINGS_CACHE_DIR`` from the environment, else the directory the app reads.
|
||||
|
||||
The image build sets the variable and copies in only this module's imports,
|
||||
so settings are loaded only when the variable is absent.
|
||||
"""
|
||||
import os
|
||||
|
||||
configured = os.environ.get("EMBEDDINGS_CACHE_DIR")
|
||||
if configured:
|
||||
return configured
|
||||
from docsgpt.core.settings import settings
|
||||
|
||||
return settings.EMBEDDINGS_CACHE_DIR or None
|
||||
|
||||
|
||||
def _parse(argv: Optional[Sequence[str]], prog: str, description: str) -> list[str]:
|
||||
import argparse
|
||||
|
||||
@@ -109,13 +107,11 @@ def _parse(argv: Optional[Sequence[str]], prog: str, description: str) -> list[s
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
import os
|
||||
|
||||
names = _parse(argv, "prefetch-models", "Download the embedding models and the tiktoken encodings into the local caches.")
|
||||
fetched = prefetch(names, os.environ.get("EMBEDDINGS_CACHE_DIR"))
|
||||
logger.info("Cached %d model(s): %s", len(fetched), ", ".join(fetched))
|
||||
encodings = prefetch_tiktoken()
|
||||
logger.info("Cached tiktoken encoding(s): %s", ", ".join(encodings))
|
||||
names = _parse(argv, "prefetch-models", "Download the embedding models and their tokenizers into the model cache.")
|
||||
cache_dir = _cache_dir()
|
||||
fetched = prefetch(names, cache_dir)
|
||||
logger.info("Cached %d model(s) in %s: %s", len(fetched), cache_dir or "FastEmbed's default cache", ", ".join(fetched))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import time
|
||||
from typing import Callable, List, Optional, Sequence
|
||||
|
||||
from docsgpt.core.optional_deps import is_available
|
||||
from docsgpt.scripts.prefetch_models import DEFAULT_MODELS, TIKTOKEN_ENCODINGS
|
||||
from docsgpt.scripts.prefetch_models import DEFAULT_MODELS
|
||||
from docsgpt.vectorstore.model_registry import resolve
|
||||
|
||||
logger = logging.getLogger("verify_offline")
|
||||
@@ -50,16 +50,12 @@ def verify(models: Sequence[str]) -> bool:
|
||||
"""Run every check; return whether all passed."""
|
||||
ok = True
|
||||
|
||||
def tiktoken_check(encoding: str) -> Callable[[], object]:
|
||||
def run() -> object:
|
||||
import tiktoken
|
||||
def tiktoken_check() -> object:
|
||||
from docsgpt.utils import get_encoding
|
||||
|
||||
return f"{len(tiktoken.get_encoding(encoding).encode('hello world'))} tokens"
|
||||
return f"{len(get_encoding().encode('hello world'))} tokens"
|
||||
|
||||
return run
|
||||
|
||||
for encoding in TIKTOKEN_ENCODINGS:
|
||||
ok &= _check(f"tiktoken {encoding}", tiktoken_check(encoding))
|
||||
ok &= _check("tiktoken cl100k_base", tiktoken_check)
|
||||
|
||||
for name in models:
|
||||
spec = resolve(name)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
from typing import Optional
|
||||
|
||||
from docsgpt.stt.base import BaseSTT
|
||||
from docsgpt.stt.faster_whisper_stt import FasterWhisperSTT
|
||||
from docsgpt.stt.openai_stt import OpenAISTT
|
||||
|
||||
#: STT_PROVIDER value that switches speech-to-text off.
|
||||
DISABLED = "none"
|
||||
|
||||
|
||||
class STTCreator:
|
||||
stt_providers = {
|
||||
@@ -9,6 +14,11 @@ class STTCreator:
|
||||
"faster_whisper": FasterWhisperSTT,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def is_enabled(stt_type: Optional[str]) -> bool:
|
||||
"""False when the provider is ``none`` or empty: speech-to-text is switched off."""
|
||||
return (stt_type or "").strip().lower() not in ("", DISABLED)
|
||||
|
||||
@classmethod
|
||||
def create_stt(cls, stt_type, *args, **kwargs) -> BaseSTT:
|
||||
stt_class = cls.stt_providers.get(stt_type.lower())
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from typing import Optional
|
||||
|
||||
from docsgpt.tts.google_tts import GoogleTTS
|
||||
from docsgpt.tts.elevenlabs import ElevenlabsTTS
|
||||
from docsgpt.tts.base import BaseTTS
|
||||
|
||||
#: TTS_PROVIDER value that switches text-to-speech off.
|
||||
DISABLED = "none"
|
||||
|
||||
|
||||
class TTSCreator:
|
||||
@@ -10,9 +14,14 @@ class TTSCreator:
|
||||
"elevenlabs": ElevenlabsTTS,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def is_enabled(tts_type: Optional[str]) -> bool:
|
||||
"""False when the provider is ``none`` or empty: text-to-speech is switched off."""
|
||||
return (tts_type or "").strip().lower() not in ("", DISABLED)
|
||||
|
||||
@classmethod
|
||||
def create_tts(cls, tts_type, *args, **kwargs)-> BaseTTS:
|
||||
tts_class = cls.tts_providers.get(tts_type.lower())
|
||||
if not tts_class:
|
||||
raise ValueError(f"No tts class found for type {tts_type}")
|
||||
return tts_class(*args, **kwargs)
|
||||
return tts_class(*args, **kwargs)
|
||||
+36
-3
@@ -6,7 +6,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import PurePosixPath
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import List
|
||||
|
||||
import tiktoken
|
||||
@@ -22,11 +22,44 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_encoding = None
|
||||
|
||||
# cl100k_base ships with the package; tiktoken would download it on first use.
|
||||
_CL100K_BASE_FILE = Path(__file__).resolve().parent / "core" / "encodings" / "cl100k_base.tiktoken"
|
||||
_CL100K_BASE_SHA256 = "223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7"
|
||||
# Pattern and special tokens exactly as tiktoken_ext.openai_public defines cl100k_base.
|
||||
_CL100K_BASE_PAT_STR = (
|
||||
r"""'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}++|\p{N}{1,3}+| ?[^\s\p{L}\p{N}]++[\r\n]*+|\s++$|\s*[\r\n]|\s+(?!\S)|\s"""
|
||||
)
|
||||
_CL100K_BASE_SPECIAL_TOKENS = {
|
||||
"<|endoftext|>": 100257,
|
||||
"<|fim_prefix|>": 100258,
|
||||
"<|fim_middle|>": 100259,
|
||||
"<|fim_suffix|>": 100260,
|
||||
"<|endofprompt|>": 100276,
|
||||
}
|
||||
|
||||
def get_encoding():
|
||||
|
||||
def _load_cl100k_base() -> tiktoken.Encoding:
|
||||
"""Build cl100k_base from the packaged ranks file."""
|
||||
data = _CL100K_BASE_FILE.read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != _CL100K_BASE_SHA256:
|
||||
raise ValueError(f"{_CL100K_BASE_FILE} does not match the cl100k_base checksum")
|
||||
ranks = {
|
||||
base64.b64decode(token): int(rank)
|
||||
for token, rank in (line.split() for line in data.splitlines() if line)
|
||||
}
|
||||
return tiktoken.Encoding(
|
||||
name="cl100k_base",
|
||||
pat_str=_CL100K_BASE_PAT_STR,
|
||||
mergeable_ranks=ranks,
|
||||
special_tokens=_CL100K_BASE_SPECIAL_TOKENS,
|
||||
)
|
||||
|
||||
|
||||
def get_encoding() -> tiktoken.Encoding:
|
||||
"""The cl100k_base encoding, loaded once per process."""
|
||||
global _encoding
|
||||
if _encoding is None:
|
||||
_encoding = tiktoken.get_encoding("cl100k_base")
|
||||
_encoding = _load_cl100k_base()
|
||||
return _encoding
|
||||
|
||||
|
||||
|
||||
@@ -97,17 +97,18 @@ def _register(model: EmbeddingModel) -> None:
|
||||
def _read_repo_json(repo: str, filename: str) -> Optional[dict]:
|
||||
"""Fetch one small JSON from ``repo``, or ``None`` when it is not there.
|
||||
|
||||
Reads through the Hugging Face hub cache, so a warmed image finds it
|
||||
Reads through the embedding model cache, so a warmed install finds it
|
||||
offline. Every failure -- absent file, no network, malformed JSON -- is the
|
||||
same answer to the caller: this repository does not tell us.
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
cache_dir = settings.EMBEDDINGS_CACHE_DIR or None
|
||||
try:
|
||||
path = hf_hub_download(repo_id=repo, filename=filename, local_files_only=True)
|
||||
path = hf_hub_download(repo_id=repo, filename=filename, local_files_only=True, cache_dir=cache_dir)
|
||||
except Exception: # noqa: BLE001 -- not cached: fetch it
|
||||
path = hf_hub_download(repo_id=repo, filename=filename)
|
||||
path = hf_hub_download(repo_id=repo, filename=filename, cache_dir=cache_dir)
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except Exception as exc:
|
||||
|
||||
Reference in new issue
Block a user