Spaces:
Running
Running
Restore Space from backup SmartDataAlex-Jupyter-c7b4fea26ef24db6722dbe9f25079b9021943a46.tar.gz
Browse files- .dockerignore +1 -2
- .github/workflows/ci-deploy.yml +80 -0
- .gitignore +7 -0
- Dockerfile +14 -9
- README.md +51 -109
- VERSION +1 -1
- app/batching.py +0 -133
- app/main.py +56 -109
- app/models.py +0 -183
- app/voyage_api.py +0 -130
- pyproject.toml +3 -0
- requirements-dev.txt +3 -0
- requirements.txt +2 -5
- scripts/backup_space.py +40 -0
- scripts/bench_local.py +0 -131
- scripts/deploy_space.py +53 -0
- scripts/download_model.py +16 -20
- scripts/restore_space.py +55 -0
- scripts/smoke_live.py +109 -0
.dockerignore
CHANGED
|
@@ -8,5 +8,4 @@
|
|
| 8 |
backup*
|
| 9 |
tests
|
| 10 |
scripts/*
|
| 11 |
-
!scripts/download_model.py
|
| 12 |
-
!scripts/bench_local.py
|
|
|
|
| 8 |
backup*
|
| 9 |
tests
|
| 10 |
scripts/*
|
| 11 |
+
!scripts/download_model.py
|
|
|
.github/workflows/ci-deploy.yml
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Test, backup, deploy and verify
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
workflow_dispatch:
|
| 7 |
+
|
| 8 |
+
permissions:
|
| 9 |
+
contents: read
|
| 10 |
+
|
| 11 |
+
concurrency:
|
| 12 |
+
group: vectorization-hf-space
|
| 13 |
+
cancel-in-progress: false
|
| 14 |
+
|
| 15 |
+
jobs:
|
| 16 |
+
unit-tests:
|
| 17 |
+
runs-on: ubuntu-latest
|
| 18 |
+
steps:
|
| 19 |
+
- uses: actions/checkout@v4
|
| 20 |
+
- uses: actions/setup-python@v5
|
| 21 |
+
with:
|
| 22 |
+
python-version: '3.11'
|
| 23 |
+
cache: pip
|
| 24 |
+
- name: Install CPU-only dependencies
|
| 25 |
+
run: |
|
| 26 |
+
python -m pip install --upgrade pip
|
| 27 |
+
python -m pip install torch==2.6.0 --index-url https://download.pytorch.org/whl/cpu
|
| 28 |
+
python -m pip install -r requirements-dev.txt
|
| 29 |
+
- name: Test validation and authorization
|
| 30 |
+
run: pytest -q tests/test_app.py --junitxml=unit-results.xml
|
| 31 |
+
- uses: actions/upload-artifact@v4
|
| 32 |
+
if: always()
|
| 33 |
+
with:
|
| 34 |
+
name: unit-test-results
|
| 35 |
+
path: unit-results.xml
|
| 36 |
+
retention-days: 90
|
| 37 |
+
|
| 38 |
+
backup-and-deploy:
|
| 39 |
+
needs: unit-tests
|
| 40 |
+
if: github.ref == 'refs/heads/main' && vars.HF_DEPLOY_ENABLED == 'true'
|
| 41 |
+
runs-on: ubuntu-latest
|
| 42 |
+
timeout-minutes: 70
|
| 43 |
+
env:
|
| 44 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 45 |
+
API_TEST_KEY: ${{ secrets.API_TEST_KEY }}
|
| 46 |
+
SPACE_URL: https://smartdataalex-jupyter.hf.space
|
| 47 |
+
EXPECTED_SHA: ${{ github.sha }}
|
| 48 |
+
steps:
|
| 49 |
+
- uses: actions/checkout@v4
|
| 50 |
+
- uses: actions/setup-python@v5
|
| 51 |
+
with:
|
| 52 |
+
python-version: '3.11'
|
| 53 |
+
- run: python -m pip install 'huggingface-hub==0.30.2' 'httpx==0.28.1'
|
| 54 |
+
- name: Verify deployment credentials exist
|
| 55 |
+
run: |
|
| 56 |
+
test -n "$HF_TOKEN" || { echo 'Missing HF_TOKEN GitHub secret'; exit 1; }
|
| 57 |
+
test -n "$API_TEST_KEY" || { echo 'Missing API_TEST_KEY GitHub secret'; exit 1; }
|
| 58 |
+
- name: Backup existing Space before any modification
|
| 59 |
+
run: python scripts/backup_space.py
|
| 60 |
+
- name: Upload original Space snapshot
|
| 61 |
+
uses: actions/upload-artifact@v4
|
| 62 |
+
with:
|
| 63 |
+
name: space-backup-${{ github.run_id }}
|
| 64 |
+
path: ${{ runner.temp }}/hf-space-backup/
|
| 65 |
+
retention-days: 90
|
| 66 |
+
if-no-files-found: error
|
| 67 |
+
- name: Set release identifier
|
| 68 |
+
run: echo "$GITHUB_SHA" > VERSION
|
| 69 |
+
- name: Deploy backed-up revision to Hugging Face Docker Space
|
| 70 |
+
run: python scripts/deploy_space.py
|
| 71 |
+
- name: Smoke-test actual published API (must match this commit)
|
| 72 |
+
run: python scripts/smoke_live.py
|
| 73 |
+
- name: Upload deployment test results
|
| 74 |
+
if: always()
|
| 75 |
+
uses: actions/upload-artifact@v4
|
| 76 |
+
with:
|
| 77 |
+
name: live-api-results-${{ github.run_id }}
|
| 78 |
+
path: smoke-result.json
|
| 79 |
+
if-no-files-found: ignore
|
| 80 |
+
retention-days: 90
|
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
.pytest_cache/
|
| 3 |
+
.venv/
|
| 4 |
+
.env
|
| 5 |
+
*.pyc
|
| 6 |
+
backup*/
|
| 7 |
+
smoke-result.json
|
Dockerfile
CHANGED
|
@@ -1,26 +1,31 @@
|
|
| 1 |
FROM python:3.11-slim
|
|
|
|
| 2 |
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 3 |
PYTHONUNBUFFERED=1 \
|
| 4 |
PIP_NO_CACHE_DIR=1 \
|
| 5 |
E5_MODEL_DIR=/opt/models/multilingual-e5-small \
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
MEMORY_BUDGET_MB=15360 \
|
| 9 |
-
CPU_THREADS=2 \
|
| 10 |
-
OMP_NUM_THREADS=2 \
|
| 11 |
-
TOKENIZERS_PARALLELISM=false
|
| 12 |
WORKDIR /service
|
|
|
|
| 13 |
RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 \
|
| 14 |
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
| 17 |
RUN pip install --no-cache-dir -r requirements.txt
|
|
|
|
|
|
|
| 18 |
COPY scripts/download_model.py ./scripts/download_model.py
|
| 19 |
-
COPY scripts/bench_local.py ./scripts/bench_local.py
|
| 20 |
RUN python scripts/download_model.py
|
|
|
|
| 21 |
COPY app ./app
|
| 22 |
COPY VERSION ./VERSION
|
|
|
|
|
|
|
| 23 |
ENV HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1
|
|
|
|
| 24 |
RUN useradd --uid 1000 --create-home appuser
|
| 25 |
USER appuser
|
| 26 |
EXPOSE 7860
|
|
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
PYTHONUNBUFFERED=1 \
|
| 5 |
PIP_NO_CACHE_DIR=1 \
|
| 6 |
E5_MODEL_DIR=/opt/models/multilingual-e5-small \
|
| 7 |
+
CPU_THREADS=2
|
| 8 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
WORKDIR /service
|
| 10 |
+
|
| 11 |
RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 \
|
| 12 |
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
# Explicit CPU-only PyTorch wheel; no CUDA or GPU required.
|
| 15 |
+
RUN pip install --no-cache-dir torch==2.6.0 --index-url https://download.pytorch.org/whl/cpu
|
| 16 |
+
COPY requirements.txt .
|
| 17 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 18 |
+
|
| 19 |
+
# Download only one copy of weights while building (immutable model revision).
|
| 20 |
COPY scripts/download_model.py ./scripts/download_model.py
|
|
|
|
| 21 |
RUN python scripts/download_model.py
|
| 22 |
+
|
| 23 |
COPY app ./app
|
| 24 |
COPY VERSION ./VERSION
|
| 25 |
+
|
| 26 |
+
# CPU inference is fully local at runtime.
|
| 27 |
ENV HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1
|
| 28 |
+
|
| 29 |
RUN useradd --uid 1000 --create-home appuser
|
| 30 |
USER appuser
|
| 31 |
EXPOSE 7860
|
README.md
CHANGED
|
@@ -8,147 +8,89 @@ app_port: 7860
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
-
# Vectorization API
|
| 12 |
|
| 13 |
-
CPU-only FastAPI
|
| 14 |
|
| 15 |
-
|
| 16 |
-
| --- | --- | ---: | --- |
|
| 17 |
-
| Standard | `intfloat/multilingual-e5-small` | 512 | 384D |
|
| 18 |
-
| Literature | `Qwen/Qwen3-Embedding-0.6B` | 32768 | 1024D |
|
| 19 |
-
| ColBERT | `lightonai/mLateOn` | 8192 | 128D per token |
|
| 20 |
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
- `POST /embed`: `{"text":"Your text","type":"query"}` defaults to E5. The optional `model` accepts `e5`, `qwen`, or the corresponding full model name. The existing E5 response fields are unchanged: `text`, `type`, `tokens_count`, `embedding_model`, `dimensions`, `embedding`.
|
| 26 |
-
- `POST /colbert/encode`: `{"text":"Your text","type":"passage"}` returns `embedding_model`, `tokens_count`, `dimensions` and `token_embeddings` (matrix of individual 128D vectors).
|
| 27 |
-
- `GET /models`: readiness, dimensions, limits, normalization method, current/peak RSS and startup RAM readings for all models.
|
| 28 |
-
- `GET /model`: original E5-only metadata contract.
|
| 29 |
-
- `GET /health`: returns 200 only when all three models have completed startup.
|
| 30 |
-
|
| 31 |
-
Example (replace URL and key):
|
| 32 |
|
| 33 |
```bash
|
| 34 |
-
curl -
|
| 35 |
-
-H '
|
| 36 |
-
-
|
| 37 |
-
|
| 38 |
-
curl -sS -X POST 'http://localhost:7860/colbert/encode' \
|
| 39 |
-
-H 'X-API-Key: YOUR_API_KEY' -H 'Content-Type: application/json' \
|
| 40 |
-
-d '{"text":"שלום עולם","type":"passage"}'
|
| 41 |
```
|
| 42 |
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
|
| 46 |
|
| 47 |
-
|
| 48 |
-
The base URL includes `/v1` (not `/v1/embeddings`):
|
| 49 |
|
| 50 |
-
|
| 51 |
-
import voyageai
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
|
| 58 |
-
|
| 59 |
-
["Первый чанк", "Второй чанк"],
|
| 60 |
-
model="e5", # or "qwen"
|
| 61 |
-
input_type="document",
|
| 62 |
-
truncation=False,
|
| 63 |
-
)
|
| 64 |
-
print(documents.embeddings) # list of 384D or 1024D vectors
|
| 65 |
-
print(documents.total_tokens)
|
| 66 |
|
| 67 |
-
|
| 68 |
-
["Что происходило в книге?"],
|
| 69 |
-
model="qwen",
|
| 70 |
-
input_type="query",
|
| 71 |
-
)
|
| 72 |
-
```
|
| 73 |
|
| 74 |
-
|
| 75 |
-
`input`, `model`, `input_type`, `truncation`, and optional output settings.
|
| 76 |
-
Our response implements Voyage's standard `object`/`data`/`model`/`usage`
|
| 77 |
-
envelope; the SDK's `EmbeddingsObject.embeddings` and `total_tokens` work.
|
| 78 |
-
|
| 79 |
-
Model names: `e5`, `qwen`, `colbert` (or the corresponding full Hugging Face IDs).
|
| 80 |
-
`input_type="query"` selects query instructions, `input_type="document"` selects
|
| 81 |
-
document/passage encoding; an omitted type defaults to document.
|
| 82 |
-
A single string or a list of 1–1000 strings is accepted.
|
| 83 |
-
|
| 84 |
-
`model="colbert"` is a **non-standard multi-vector extension**:
|
| 85 |
-
the standard Voyage client will expose `embeddings` as a 3-D array
|
| 86 |
-
`[document][token][128]`. This is not interchangeable with regular
|
| 87 |
-
Voyage dense embeddings or dense-only vector database integrations.
|
| 88 |
-
|
| 89 |
-
All three models are already loaded in RAM. Batch calls use **actual model
|
| 90 |
-
batch inference** where possible: E5 and Qwen run padded tensor batches,
|
| 91 |
-
and mLateOn uses PyLate's batch encoding. The server partitions requests
|
| 92 |
-
by padded token cost to protect 16-GiB CPU memory. Requests are limited to
|
| 93 |
-
65,536 aggregate tokens for dense vectors or 32,768 for ColBERT.
|
| 94 |
-
Requests beyond these limits return HTTP 422 rather than truncating content;
|
| 95 |
-
even `truncation=True` from Voyage's default SDK settings cannot override
|
| 96 |
-
our no-silent-truncation policy. Only float output and the fixed model
|
| 97 |
-
dimensions are supported; quantization, changing dimensions, and Voyage's
|
| 98 |
-
other API families are not implemented.
|
| 99 |
-
|
| 100 |
-
Unmodified-SDK integration smoke test:
|
| 101 |
|
| 102 |
```bash
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
Docker downloads immutable checkpoints at image build, and runtime operates offline. Startup fails if any of the three models cannot be loaded or exceeds the configured resident memory budget. The default `MEMORY_BUDGET_MB=15360` reserves memory within a nominal 16-GiB container. Run exactly **one** Uvicorn worker and one concurrent inference.
|
| 111 |
-
|
| 112 |
-
```bash
|
| 113 |
-
docker build -t vectorization:three-models .
|
| 114 |
-
docker run --rm -p 7860:7860 --memory 16g \
|
| 115 |
-
-e API_KEY='your-strong-api-key' vectorization:three-models
|
| 116 |
```
|
| 117 |
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
### Tests
|
| 121 |
|
| 122 |
```bash
|
| 123 |
-
|
| 124 |
-
python -m pip install -r requirements-dev.txt
|
| 125 |
-
pytest -q tests/test_app.py --junitxml=unit-results.xml
|
| 126 |
-
|
| 127 |
-
SPACE_URL=http://localhost:7860 API_TEST_KEY='your-strong-api-key' \
|
| 128 |
-
python scripts/smoke_live.py
|
| 129 |
```
|
| 130 |
|
| 131 |
-
|
| 132 |
|
| 133 |
```bash
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
vectorization:three-models scripts/bench_local.py
|
| 138 |
```
|
| 139 |
|
| 140 |
-
The
|
| 141 |
-
|
| 142 |
-
### Preserve the existing Space
|
| 143 |
|
| 144 |
-
|
| 145 |
|
| 146 |
-
|
| 147 |
|
| 148 |
```bash
|
| 149 |
-
HF_TOKEN='space-scoped-write-token' python scripts/restore_space.py \
|
| 150 |
SmartDataAlex-Jupyter-<revision>.tar.gz \
|
| 151 |
--confirm-space SmartDataAlex/Jupyter
|
| 152 |
```
|
| 153 |
|
| 154 |
-
|
|
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Vectorization API
|
| 12 |
|
| 13 |
+
CPU-only FastAPI embedding service for `intfloat/multilingual-e5-small` (384 dimensions).
|
| 14 |
|
| 15 |
+
## Endpoints
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
+
- `POST /embed` (requires `X-API-Key`) takes `{ "text": "Example text", "type": "query" }` or type `passage`.
|
| 18 |
+
- `GET /model` (requires `X-API-Key`) returns metadata, including dimensions and maximum token count.
|
| 19 |
+
- `GET /health` is public, and returns ready only after the model has loaded.
|
| 20 |
+
- `GET /docs` is public Swagger UI. Click **Authorize** and enter the API key to call protected endpoints.
|
| 21 |
|
| 22 |
+
Example:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
```bash
|
| 25 |
+
curl -fsS -X POST 'https://smartdataalex-jupyter.hf.space/embed' \
|
| 26 |
+
-H 'Content-Type: application/json' \
|
| 27 |
+
-H 'X-API-Key: YOUR_API_KEY' \
|
| 28 |
+
-d '{"text":"How to find similar documents?","type":"query"}'
|
|
|
|
|
|
|
|
|
|
| 29 |
```
|
| 30 |
|
| 31 |
+
Sample response structure (embedding values are illustrative, complete real response contains 384 floats):
|
| 32 |
+
|
| 33 |
+
```json
|
| 34 |
+
{
|
| 35 |
+
"text": "How to find similar documents?",
|
| 36 |
+
"type": "query",
|
| 37 |
+
"tokens_count": 10,
|
| 38 |
+
"embedding_model": "intfloat/multilingual-e5-small",
|
| 39 |
+
"dimensions": 384,
|
| 40 |
+
"embedding": [0.12, -0.03]
|
| 41 |
+
}
|
| 42 |
+
```
|
| 43 |
|
| 44 |
+
The service adds `query: ` / `passage: ` according to `type`, uses the model's own tokenizer and attention-mask-aware mean pooling. `tokens_count` includes the prefix and tokenizer's special tokens. Inputs over 512 tokens, including prefix and special tokens, get HTTP 422; inputs are **never silently truncated**. Input length is limited to 4096 characters as an additional resource guard.
|
| 45 |
|
| 46 |
+
**No extra L2 normalization** is applied by the service. Configure your Qdrant vector collection with dimension `384` and distance `Cosine`; Qdrant will normalize stored vectors. No database, search, or document storage is implemented here.
|
|
|
|
| 47 |
|
| 48 |
+
## Secrets and one-time deployment setup
|
|
|
|
| 49 |
|
| 50 |
+
1. In **Hugging Face → SmartDataAlex/Jupyter → Settings → Secrets**, set `API_KEY` to a long, random, private value. Secrets are exposed to Docker as environment variables. Never commit this value.
|
| 51 |
+
2. In GitHub repository secrets, set `HF_TOKEN` to a **fine-grained Hugging Face write token** restricted to the existing Space, and `API_TEST_KEY` to the *same* API key value (for post-deploy authenticated smoke tests).
|
| 52 |
+
3. After verifying GitHub is connected to this repository and that you are ready for the Space's contents to be replaced, add GitHub repository variable `HF_DEPLOY_ENABLED` with value `true`.
|
| 53 |
+
4. Each subsequent push to `main` tests source code, **backs up the current Space first**, deploys using a guarded Hugging Face Hub commit, and tests the deployed public HTTPS API. CI uploads JSON test results and the pre-deploy backup as GitHub Actions artifacts. If backup fails, deployment does **not** run.
|
| 54 |
|
| 55 |
+
**Important:** deployment replaces Space files after successful backup. It also refuses to overwrite the Space if its revision changed after the backup. This workflow intentionally does not deploy without `HF_DEPLOY_ENABLED=true`. Backups are retained as GitHub Actions artifacts for 90 days. Keep an additional durable copy of the initial backup before deleting old workflow artifacts; the Space's Git history also provides rollback revisions.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
+
No additional `API_KEY` parameter is added to the JSON request body. All API key checks happen via the `X-API-Key` request header. If the runtime secret is missing, the application fails to start rather than permitting anonymous access.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
+
## Local development
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
```bash
|
| 62 |
+
# CPU PyTorch: install per https://pytorch.org/get-started/locally/
|
| 63 |
+
python -m pip install -r requirements-dev.txt
|
| 64 |
+
# Download the pinned model into E5_MODEL_DIR (Docker does this at build time)
|
| 65 |
+
export API_KEY='local-only-long-random-value'
|
| 66 |
+
export E5_MODEL_DIR='/path/to/downloaded/model'
|
| 67 |
+
uvicorn app.main:app --host 127.0.0.1 --port 7860
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
```
|
| 69 |
|
| 70 |
+
Unit tests do not download model weights:
|
|
|
|
|
|
|
| 71 |
|
| 72 |
```bash
|
| 73 |
+
pytest -q tests/test_app.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
```
|
| 75 |
|
| 76 |
+
Post-deploy check:
|
| 77 |
|
| 78 |
```bash
|
| 79 |
+
SPACE_URL='https://smartdataalex-jupyter.hf.space' \
|
| 80 |
+
API_TEST_KEY='your-key' \
|
| 81 |
+
python scripts/smoke_live.py
|
|
|
|
| 82 |
```
|
| 83 |
|
| 84 |
+
The actual Space URL must be verified once the Space is published.
|
|
|
|
|
|
|
| 85 |
|
| 86 |
+
## Rollback
|
| 87 |
|
| 88 |
+
Retrieve the `space-backup-<run_id>` artifact from a previous GitHub Actions run, unzip it to obtain `SmartDataAlex-Jupyter-<revision>.tar.gz`, and run:
|
| 89 |
|
| 90 |
```bash
|
| 91 |
+
HF_TOKEN='your-space-scoped-write-token' python scripts/restore_space.py \
|
| 92 |
SmartDataAlex-Jupyter-<revision>.tar.gz \
|
| 93 |
--confirm-space SmartDataAlex/Jupyter
|
| 94 |
```
|
| 95 |
|
| 96 |
+
This operation explicitly overwrites the current Space content to restore the saved files; it does not run automatically after a failed smoke test, because automatic rollback could mask a deployment failure. Secrets managed in Hugging Face Space Settings are stored separately from the repository and are not contained in the archive.
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
development
|
app/batching.py
DELETED
|
@@ -1,133 +0,0 @@
|
|
| 1 |
-
"""True token-budgeted batched inference with resident models.
|
| 2 |
-
|
| 3 |
-
The external service-wide inference gate is held by ModelRegistry for the full call.
|
| 4 |
-
No model initialization, truncation, or extra E5 normalization occurs here.
|
| 5 |
-
"""
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
from app.embedding import EmbeddingResult, TooManyTokens
|
| 9 |
-
from app.models import ColbertResult, validate_vector, INSTRUCTION
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def _split_by_padding_cost(counts, token_budget, max_size):
|
| 13 |
-
"""Partition ordered inputs, accounting for batch padding to longest text."""
|
| 14 |
-
groups = []
|
| 15 |
-
current = []
|
| 16 |
-
current_max = 0
|
| 17 |
-
for index, count in enumerate(counts):
|
| 18 |
-
proposed_max = max(current_max, count)
|
| 19 |
-
if current and (len(current) >= max_size or
|
| 20 |
-
proposed_max * (len(current) + 1) > token_budget):
|
| 21 |
-
groups.append(current)
|
| 22 |
-
current = []
|
| 23 |
-
current_max = 0
|
| 24 |
-
current.append(index)
|
| 25 |
-
current_max = max(current_max, count)
|
| 26 |
-
if current:
|
| 27 |
-
groups.append(current)
|
| 28 |
-
return groups
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def _token_count(tokenizer, text):
|
| 32 |
-
encoded = tokenizer(text, add_special_tokens=True, truncation=False)
|
| 33 |
-
return len(encoded["input_ids"])
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def _dense_batch(engine, texts, kind, is_qwen):
|
| 37 |
-
if is_qwen:
|
| 38 |
-
strings = [
|
| 39 |
-
f"Instruct: {INSTRUCTION}\nQuery:{text}" if kind == "query" else text
|
| 40 |
-
for text in texts
|
| 41 |
-
]
|
| 42 |
-
# Smaller padded batches limit CPU activation memory; single long
|
| 43 |
-
# documents are still allowed up to the model's advertised context.
|
| 44 |
-
budget, max_size = 4096, 16
|
| 45 |
-
else:
|
| 46 |
-
strings = [f"{kind}: {text}" for text in texts]
|
| 47 |
-
budget, max_size = 8192, 64
|
| 48 |
-
|
| 49 |
-
counts = [_token_count(engine.tokenizer, text) for text in strings]
|
| 50 |
-
for count in counts:
|
| 51 |
-
if count > engine.max_tokens:
|
| 52 |
-
raise TooManyTokens(count, engine.max_tokens)
|
| 53 |
-
# Bound total work in one HTTP request (not the model context limit).
|
| 54 |
-
if sum(counts) > 65536:
|
| 55 |
-
raise ValueError("Batch exceeds 65536 total input tokens")
|
| 56 |
-
|
| 57 |
-
results = [None] * len(texts)
|
| 58 |
-
for group in _split_by_padding_cost(counts, budget, max_size):
|
| 59 |
-
inputs = [strings[i] for i in group]
|
| 60 |
-
batch = engine.tokenizer(
|
| 61 |
-
inputs, return_tensors="pt", padding=True,
|
| 62 |
-
truncation=False, add_special_tokens=True,
|
| 63 |
-
)
|
| 64 |
-
with engine.torch.inference_mode():
|
| 65 |
-
hidden = engine.model(**batch).last_hidden_state
|
| 66 |
-
if is_qwen:
|
| 67 |
-
# Qwen tokenizer is explicitly left padded. The final index
|
| 68 |
-
# therefore refers to the final real token in every row.
|
| 69 |
-
vectors = engine.torch.nn.functional.normalize(
|
| 70 |
-
hidden[:, -1, :], p=2, dim=1,
|
| 71 |
-
)
|
| 72 |
-
else:
|
| 73 |
-
mask = batch["attention_mask"].unsqueeze(-1)
|
| 74 |
-
vectors = (hidden * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1)
|
| 75 |
-
|
| 76 |
-
vectors = vectors.float().cpu().tolist()
|
| 77 |
-
|
| 78 |
-
for index, vector in zip(group, vectors):
|
| 79 |
-
validate_vector(vector, engine.dimensions)
|
| 80 |
-
results[index] = EmbeddingResult(
|
| 81 |
-
tokens_count=counts[index], embedding=vector,
|
| 82 |
-
)
|
| 83 |
-
return results
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
def _colbert_batch(engine, texts, kind):
|
| 87 |
-
model = engine.model
|
| 88 |
-
query = kind == "query"
|
| 89 |
-
prefix = model.query_prefix if query else model.document_prefix
|
| 90 |
-
counts = [_token_count(model.tokenizer, prefix + text) for text in texts]
|
| 91 |
-
for count in counts:
|
| 92 |
-
if count > engine.max_tokens:
|
| 93 |
-
raise TooManyTokens(count, engine.max_tokens)
|
| 94 |
-
if sum(counts) > 32768:
|
| 95 |
-
raise ValueError("ColBERT request exceeds 32768 total input tokens")
|
| 96 |
-
|
| 97 |
-
results = [None] * len(texts)
|
| 98 |
-
for group in _split_by_padding_cost(counts, 4096, 8):
|
| 99 |
-
if query:
|
| 100 |
-
model.query_length = max(32, max(counts[i] for i in group))
|
| 101 |
-
outputs = model.encode(
|
| 102 |
-
[texts[i] for i in group],
|
| 103 |
-
is_query=query, batch_size=len(group), show_progress_bar=False,
|
| 104 |
-
)
|
| 105 |
-
if len(outputs) != len(group):
|
| 106 |
-
raise RuntimeError("Unexpected PyLate batch length")
|
| 107 |
-
for index, matrix in zip(group, outputs):
|
| 108 |
-
if hasattr(matrix, "detach"):
|
| 109 |
-
matrix = matrix.detach().float().cpu()
|
| 110 |
-
if hasattr(matrix, "tolist"):
|
| 111 |
-
matrix = matrix.tolist()
|
| 112 |
-
vectors = [[float(value) for value in row] for row in matrix]
|
| 113 |
-
if not vectors or len(vectors) > engine.max_tokens:
|
| 114 |
-
raise RuntimeError("Unexpected PyLate output token count")
|
| 115 |
-
for vector in vectors:
|
| 116 |
-
validate_vector(vector, engine.dimensions)
|
| 117 |
-
# Note: encoded tokens and emitted token vectors may differ
|
| 118 |
-
# because PyLate can filter special/padding tokens.
|
| 119 |
-
results[index] = ColbertResult(
|
| 120 |
-
tokens_count=counts[index], token_embeddings=vectors,
|
| 121 |
-
)
|
| 122 |
-
return results
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
def batch_encode(engine, texts, kind, is_qwen=False, is_colbert=False):
|
| 126 |
-
"""Native tensor batch for real models, single-item fallback for test doubles."""
|
| 127 |
-
if is_colbert:
|
| 128 |
-
if not hasattr(engine, "model") or not hasattr(engine.model, "tokenizer"):
|
| 129 |
-
return [engine.encode(text, kind) for text in texts]
|
| 130 |
-
return _colbert_batch(engine, texts, kind)
|
| 131 |
-
if not hasattr(engine, "tokenizer") or not hasattr(engine, "model"):
|
| 132 |
-
return [engine.encode(text, kind) for text in texts]
|
| 133 |
-
return _dense_batch(engine, texts, kind, is_qwen)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/main.py
CHANGED
|
@@ -1,9 +1,12 @@
|
|
| 1 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 2 |
import logging
|
| 3 |
import os
|
| 4 |
import secrets
|
| 5 |
-
from pathlib import Path
|
| 6 |
from contextlib import asynccontextmanager
|
|
|
|
| 7 |
from typing import Literal
|
| 8 |
|
| 9 |
from fastapi import Depends, FastAPI, HTTPException, Security
|
|
@@ -11,66 +14,51 @@ from fastapi.responses import RedirectResponse
|
|
| 11 |
from fastapi.security import APIKeyHeader
|
| 12 |
from pydantic import BaseModel, Field, field_validator
|
| 13 |
|
| 14 |
-
from app.embedding import
|
| 15 |
-
|
| 16 |
-
|
|
|
|
| 17 |
)
|
| 18 |
|
| 19 |
-
|
| 20 |
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
| 21 |
-
EmbeddingModel = Literal[
|
| 22 |
-
"e5", "qwen", "intfloat/multilingual-e5-small", "Qwen/Qwen3-Embedding-0.6B"
|
| 23 |
-
]
|
| 24 |
|
| 25 |
|
| 26 |
-
def deployment_version():
|
| 27 |
path = Path(__file__).resolve().parent.parent / "VERSION"
|
| 28 |
return path.read_text(encoding="utf-8").strip() if path.is_file() else "development"
|
| 29 |
|
| 30 |
|
| 31 |
@asynccontextmanager
|
| 32 |
-
async def lifespan(
|
|
|
|
| 33 |
if not os.getenv("API_KEY"):
|
| 34 |
-
raise RuntimeError("API_KEY must be configured")
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
yield
|
| 39 |
-
finally:
|
| 40 |
-
application.state.registry = None
|
| 41 |
-
|
| 42 |
|
| 43 |
-
from app.voyage_api import router as voyage_router
|
| 44 |
|
| 45 |
app = FastAPI(
|
| 46 |
title="Vectorization API",
|
| 47 |
-
version="
|
| 48 |
-
description="
|
| 49 |
lifespan=lifespan,
|
| 50 |
)
|
| 51 |
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
class TextRequest(BaseModel):
|
| 57 |
-
text: str = Field(min_length=1, description="Full input, no silent truncation")
|
| 58 |
-
type: Literal["query", "passage"] = Field(examples=["query"])
|
| 59 |
|
| 60 |
@field_validator("text")
|
| 61 |
@classmethod
|
| 62 |
-
def
|
| 63 |
if not text.strip():
|
| 64 |
raise ValueError("Text must not be blank")
|
| 65 |
return text
|
| 66 |
|
| 67 |
|
| 68 |
-
class EmbedRequest(TextRequest):
|
| 69 |
-
model: EmbeddingModel | None = Field(
|
| 70 |
-
default=None, examples=["qwen"], description="E5 by default"
|
| 71 |
-
)
|
| 72 |
-
|
| 73 |
-
|
| 74 |
class EmbedResponse(BaseModel):
|
| 75 |
text: str
|
| 76 |
type: Literal["query", "passage"]
|
|
@@ -80,13 +68,6 @@ class EmbedResponse(BaseModel):
|
|
| 80 |
embedding: list[float]
|
| 81 |
|
| 82 |
|
| 83 |
-
class ColbertResponse(BaseModel):
|
| 84 |
-
embedding_model: str
|
| 85 |
-
tokens_count: int
|
| 86 |
-
dimensions: int
|
| 87 |
-
token_embeddings: list[list[float]]
|
| 88 |
-
|
| 89 |
-
|
| 90 |
class ModelResponse(BaseModel):
|
| 91 |
embedding_model: str
|
| 92 |
dimensions: int
|
|
@@ -95,31 +76,21 @@ class ModelResponse(BaseModel):
|
|
| 95 |
version: str
|
| 96 |
|
| 97 |
|
| 98 |
-
def require_api_key(provided: str | None = Security(api_key_header)):
|
| 99 |
expected = os.getenv("API_KEY")
|
| 100 |
if not expected or provided is None or not secrets.compare_digest(provided, expected):
|
| 101 |
-
raise HTTPException(
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
| 104 |
|
| 105 |
-
def get_registry():
|
| 106 |
-
registry = getattr(app.state, "registry", None)
|
| 107 |
-
if registry is None:
|
| 108 |
-
raise HTTPException(status_code=503, detail="Models are not ready")
|
| 109 |
-
return registry
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
| 117 |
-
except ServiceBusy as exc:
|
| 118 |
-
raise HTTPException(status_code=429, detail=str(exc),
|
| 119 |
-
headers={"Retry-After": "2"}) from exc
|
| 120 |
-
except Exception as exc:
|
| 121 |
-
log.exception("Inference failed")
|
| 122 |
-
raise HTTPException(status_code=500, detail="Inference failed") from exc
|
| 123 |
|
| 124 |
|
| 125 |
@app.get("/", include_in_schema=False)
|
|
@@ -129,59 +100,35 @@ def root():
|
|
| 129 |
|
| 130 |
@app.get("/health")
|
| 131 |
def health():
|
| 132 |
-
if getattr(app.state, "
|
| 133 |
-
raise HTTPException(status_code=503, detail="
|
| 134 |
return {"status": "ok", "version": deployment_version()}
|
| 135 |
|
| 136 |
|
| 137 |
@app.get("/model", response_model=ModelResponse, dependencies=[Depends(require_api_key)])
|
| 138 |
-
def model_info(
|
| 139 |
return ModelResponse(
|
| 140 |
-
embedding_model=MODEL_ID,
|
| 141 |
-
|
|
|
|
|
|
|
| 142 |
)
|
| 143 |
|
| 144 |
|
| 145 |
-
@app.
|
| 146 |
-
def
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
{"embedding_model": QWEN_ID, "purpose": "literature",
|
| 155 |
-
"dimensions": registry.qwen.dimensions,
|
| 156 |
-
"max_input_tokens": registry.qwen.max_tokens,
|
| 157 |
-
"normalization": "l2", "ready": True},
|
| 158 |
-
{"embedding_model": COLBERT_ID, "purpose": "colbert",
|
| 159 |
-
"dimensions": registry.colbert.dimensions,
|
| 160 |
-
"max_input_tokens": registry.colbert.max_tokens,
|
| 161 |
-
"normalization": "model-native", "ready": True},
|
| 162 |
-
],
|
| 163 |
-
"resources": memory_mb(),
|
| 164 |
-
"startup_memory": registry.startup_memory,
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
@app.post("/embed", response_model=EmbedResponse,
|
| 169 |
-
dependencies=[Depends(require_api_key)])
|
| 170 |
-
def embed(payload: EmbedRequest, registry=Depends(get_registry)):
|
| 171 |
-
identifier, dimensions, result = guard(
|
| 172 |
-
lambda: registry.embed(payload.text, payload.type, payload.model)
|
| 173 |
-
)
|
| 174 |
return EmbedResponse(
|
| 175 |
-
text=payload.text,
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
dependencies=[Depends(require_api_key)])
|
| 182 |
-
def colbert_encode(payload: TextRequest, registry=Depends(get_registry)):
|
| 183 |
-
result = guard(lambda: registry.colbert_encode(payload.text, payload.type))
|
| 184 |
-
return ColbertResponse(
|
| 185 |
-
embedding_model=COLBERT_ID, dimensions=registry.colbert.dimensions,
|
| 186 |
-
tokens_count=result.tokens_count, token_embeddings=result.token_embeddings,
|
| 187 |
)
|
|
|
|
| 1 |
+
"""HTTP service: authenticate, validate, and generate embeddings only."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
import logging
|
| 6 |
import os
|
| 7 |
import secrets
|
|
|
|
| 8 |
from contextlib import asynccontextmanager
|
| 9 |
+
from pathlib import Path
|
| 10 |
from typing import Literal
|
| 11 |
|
| 12 |
from fastapi import Depends, FastAPI, HTTPException, Security
|
|
|
|
| 14 |
from fastapi.security import APIKeyHeader
|
| 15 |
from pydantic import BaseModel, Field, field_validator
|
| 16 |
|
| 17 |
+
from app.embedding import (
|
| 18 |
+
MODEL_ID,
|
| 19 |
+
EmbeddingEngine,
|
| 20 |
+
TooManyTokens,
|
| 21 |
)
|
| 22 |
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
+
def deployment_version() -> str:
|
| 28 |
path = Path(__file__).resolve().parent.parent / "VERSION"
|
| 29 |
return path.read_text(encoding="utf-8").strip() if path.is_file() else "development"
|
| 30 |
|
| 31 |
|
| 32 |
@asynccontextmanager
|
| 33 |
+
async def lifespan(app: FastAPI):
|
| 34 |
+
# Fail closed: an unset secret must never expose unauthenticated inference.
|
| 35 |
if not os.getenv("API_KEY"):
|
| 36 |
+
raise RuntimeError("API_KEY must be configured in Hugging Face Space Secrets")
|
| 37 |
+
app.state.engine = EmbeddingEngine.load()
|
| 38 |
+
yield
|
| 39 |
+
app.state.engine = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
|
|
|
| 41 |
|
| 42 |
app = FastAPI(
|
| 43 |
title="Vectorization API",
|
| 44 |
+
version="1.0.0",
|
| 45 |
+
description="CPU embedding service backed by intfloat/multilingual-e5-small.",
|
| 46 |
lifespan=lifespan,
|
| 47 |
)
|
| 48 |
|
| 49 |
|
| 50 |
+
class EmbedRequest(BaseModel):
|
| 51 |
+
text: str = Field(min_length=1, max_length=4096)
|
| 52 |
+
type: Literal["query", "passage"]
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
@field_validator("text")
|
| 55 |
@classmethod
|
| 56 |
+
def reject_blank_text(cls, text: str) -> str:
|
| 57 |
if not text.strip():
|
| 58 |
raise ValueError("Text must not be blank")
|
| 59 |
return text
|
| 60 |
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
class EmbedResponse(BaseModel):
|
| 63 |
text: str
|
| 64 |
type: Literal["query", "passage"]
|
|
|
|
| 68 |
embedding: list[float]
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
class ModelResponse(BaseModel):
|
| 72 |
embedding_model: str
|
| 73 |
dimensions: int
|
|
|
|
| 76 |
version: str
|
| 77 |
|
| 78 |
|
| 79 |
+
def require_api_key(provided: str | None = Security(api_key_header)) -> None:
|
| 80 |
expected = os.getenv("API_KEY")
|
| 81 |
if not expected or provided is None or not secrets.compare_digest(provided, expected):
|
| 82 |
+
raise HTTPException(
|
| 83 |
+
status_code=401,
|
| 84 |
+
detail="Invalid or missing API key",
|
| 85 |
+
headers={"WWW-Authenticate": "ApiKey"},
|
| 86 |
+
)
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
+
def get_engine() -> EmbeddingEngine:
|
| 90 |
+
engine = getattr(app.state, "engine", None)
|
| 91 |
+
if engine is None:
|
| 92 |
+
raise HTTPException(status_code=503, detail="Model is not ready")
|
| 93 |
+
return engine
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
@app.get("/", include_in_schema=False)
|
|
|
|
| 100 |
|
| 101 |
@app.get("/health")
|
| 102 |
def health():
|
| 103 |
+
if getattr(app.state, "engine", None) is None:
|
| 104 |
+
raise HTTPException(status_code=503, detail="Model is not ready")
|
| 105 |
return {"status": "ok", "version": deployment_version()}
|
| 106 |
|
| 107 |
|
| 108 |
@app.get("/model", response_model=ModelResponse, dependencies=[Depends(require_api_key)])
|
| 109 |
+
def model_info(engine: EmbeddingEngine = Depends(get_engine)) -> ModelResponse:
|
| 110 |
return ModelResponse(
|
| 111 |
+
embedding_model=MODEL_ID,
|
| 112 |
+
dimensions=engine.dimensions,
|
| 113 |
+
max_input_tokens=engine.max_tokens,
|
| 114 |
+
version=deployment_version(),
|
| 115 |
)
|
| 116 |
|
| 117 |
|
| 118 |
+
@app.post("/embed", response_model=EmbedResponse, dependencies=[Depends(require_api_key)])
|
| 119 |
+
def embed(payload: EmbedRequest, engine: EmbeddingEngine = Depends(get_engine)) -> EmbedResponse:
|
| 120 |
+
try:
|
| 121 |
+
result = engine.encode(payload.text, payload.type)
|
| 122 |
+
except TooManyTokens as exc:
|
| 123 |
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
| 124 |
+
except Exception as exc:
|
| 125 |
+
logger.exception("Embedding generation failed")
|
| 126 |
+
raise HTTPException(status_code=500, detail="Embedding generation failed") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
return EmbedResponse(
|
| 128 |
+
text=payload.text,
|
| 129 |
+
type=payload.type,
|
| 130 |
+
tokens_count=result.tokens_count,
|
| 131 |
+
embedding_model=MODEL_ID,
|
| 132 |
+
dimensions=engine.dimensions,
|
| 133 |
+
embedding=result.embedding,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
)
|
app/models.py
DELETED
|
@@ -1,183 +0,0 @@
|
|
| 1 |
-
"""Resident E5, Qwen3 and PyLate mLateOn. No per-request model loading."""
|
| 2 |
-
import gc
|
| 3 |
-
import math
|
| 4 |
-
import os
|
| 5 |
-
import resource
|
| 6 |
-
import threading
|
| 7 |
-
from pathlib import Path
|
| 8 |
-
from dataclasses import dataclass
|
| 9 |
-
|
| 10 |
-
from app.embedding import EmbeddingEngine, EmbeddingResult, MODEL_ID as E5_ID, TooManyTokens
|
| 11 |
-
|
| 12 |
-
QWEN_ID = "Qwen/Qwen3-Embedding-0.6B"
|
| 13 |
-
COLBERT_ID = "lightonai/mLateOn"
|
| 14 |
-
QWEN_MAX = 32768
|
| 15 |
-
COLBERT_MAX = 8192
|
| 16 |
-
INSTRUCTION = "Given a question, retrieve relevant passages that answer the question"
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
class ServiceBusy(Exception):
|
| 20 |
-
pass
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def memory_mb():
|
| 24 |
-
current = None
|
| 25 |
-
try:
|
| 26 |
-
with open("/proc/self/status", encoding="utf-8") as f:
|
| 27 |
-
for line in f:
|
| 28 |
-
if line.startswith("VmRSS:"):
|
| 29 |
-
current = round(int(line.split()[1]) / 1024, 1)
|
| 30 |
-
break
|
| 31 |
-
except OSError:
|
| 32 |
-
pass
|
| 33 |
-
peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
| 34 |
-
return {"rss_mb": current, "peak_rss_mb": round(peak / 1024, 1),
|
| 35 |
-
"budget_mb": int(os.getenv("MEMORY_BUDGET_MB", "15360"))}
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
def validate_vector(vector, dimensions):
|
| 39 |
-
if len(vector) != dimensions or any(not math.isfinite(v) for v in vector):
|
| 40 |
-
raise RuntimeError("Invalid embedding vector")
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
class QwenEngine:
|
| 44 |
-
dimensions = 1024
|
| 45 |
-
max_tokens = QWEN_MAX
|
| 46 |
-
|
| 47 |
-
def __init__(self, tokenizer, model, torch):
|
| 48 |
-
self.tokenizer, self.model, self.torch = tokenizer, model, torch
|
| 49 |
-
|
| 50 |
-
@classmethod
|
| 51 |
-
def load(cls):
|
| 52 |
-
import torch
|
| 53 |
-
from transformers import AutoModel, AutoTokenizer
|
| 54 |
-
path = os.getenv("QWEN_MODEL_DIR", "/opt/models/qwen3-embedding-0.6b")
|
| 55 |
-
tokenizer = AutoTokenizer.from_pretrained(path, padding_side="left",
|
| 56 |
-
local_files_only=True)
|
| 57 |
-
model = AutoModel.from_pretrained(path, torch_dtype=torch.float32,
|
| 58 |
-
attn_implementation="sdpa",
|
| 59 |
-
local_files_only=True).to("cpu").eval()
|
| 60 |
-
if int(model.config.hidden_size) != 1024:
|
| 61 |
-
raise RuntimeError("Incorrect Qwen output size")
|
| 62 |
-
return cls(tokenizer, model, torch)
|
| 63 |
-
|
| 64 |
-
def encode(self, text, kind):
|
| 65 |
-
inp = (f"Instruct: {INSTRUCTION}\nQuery:{text}"
|
| 66 |
-
if kind == "query" else text)
|
| 67 |
-
tokens = self.tokenizer(inp, add_special_tokens=True, truncation=False,
|
| 68 |
-
return_tensors="pt")
|
| 69 |
-
count = int(tokens["attention_mask"].sum().item())
|
| 70 |
-
if count > self.max_tokens:
|
| 71 |
-
raise TooManyTokens(count, self.max_tokens)
|
| 72 |
-
with self.torch.inference_mode():
|
| 73 |
-
last = self.model(**tokens).last_hidden_state[:, -1, :]
|
| 74 |
-
normalized = self.torch.nn.functional.normalize(last, p=2, dim=1)
|
| 75 |
-
values = normalized[0].float().cpu().tolist()
|
| 76 |
-
validate_vector(values, self.dimensions)
|
| 77 |
-
return EmbeddingResult(tokens_count=count, embedding=values)
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
@dataclass(frozen=True)
|
| 81 |
-
class ColbertResult:
|
| 82 |
-
tokens_count: int
|
| 83 |
-
token_embeddings: list[list[float]]
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
class ColbertEngine:
|
| 87 |
-
dimensions = 128
|
| 88 |
-
max_tokens = COLBERT_MAX
|
| 89 |
-
|
| 90 |
-
def __init__(self, model):
|
| 91 |
-
self.model = model
|
| 92 |
-
|
| 93 |
-
@classmethod
|
| 94 |
-
def load(cls):
|
| 95 |
-
from pylate import models
|
| 96 |
-
model = models.ColBERT(
|
| 97 |
-
model_name_or_path=os.getenv("COLBERT_MODEL_DIR", "/opt/models/mlateon"),
|
| 98 |
-
device="cpu", query_length=32, document_length=COLBERT_MAX,
|
| 99 |
-
)
|
| 100 |
-
model.eval()
|
| 101 |
-
model.document_length = COLBERT_MAX
|
| 102 |
-
return cls(model)
|
| 103 |
-
|
| 104 |
-
def encode(self, text, kind):
|
| 105 |
-
is_query = kind == "query"
|
| 106 |
-
prefix = self.model.query_prefix if is_query else self.model.document_prefix
|
| 107 |
-
tokens = self.model.tokenizer(f"{prefix}{text}", add_special_tokens=True,
|
| 108 |
-
truncation=False)
|
| 109 |
-
count = len(tokens["input_ids"])
|
| 110 |
-
if count > self.max_tokens:
|
| 111 |
-
raise TooManyTokens(count, self.max_tokens)
|
| 112 |
-
if is_query:
|
| 113 |
-
# PyLate pads queries to query_length. Never pad short queries to 8192.
|
| 114 |
-
self.model.query_length = max(32, count)
|
| 115 |
-
output = self.model.encode([text], is_query=is_query, batch_size=1,
|
| 116 |
-
show_progress_bar=False)
|
| 117 |
-
if len(output) != 1:
|
| 118 |
-
raise RuntimeError("Unexpected PyLate batch size")
|
| 119 |
-
matrix = output[0]
|
| 120 |
-
if hasattr(matrix, "detach"):
|
| 121 |
-
matrix = matrix.detach().float().cpu()
|
| 122 |
-
if hasattr(matrix, "tolist"):
|
| 123 |
-
matrix = matrix.tolist()
|
| 124 |
-
vectors = [[float(x) for x in row] for row in matrix]
|
| 125 |
-
if not vectors or len(vectors) > COLBERT_MAX:
|
| 126 |
-
raise RuntimeError("Unexpected PyLate output length")
|
| 127 |
-
for row in vectors:
|
| 128 |
-
validate_vector(row, self.dimensions)
|
| 129 |
-
return ColbertResult(tokens_count=len(vectors), token_embeddings=vectors)
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
class ModelRegistry:
|
| 133 |
-
def __init__(self, e5, qwen, colbert, startup_memory=None):
|
| 134 |
-
self.e5, self.qwen, self.colbert = e5, qwen, colbert
|
| 135 |
-
self.startup_memory = startup_memory or {}
|
| 136 |
-
self._gate = threading.BoundedSemaphore(1)
|
| 137 |
-
|
| 138 |
-
@classmethod
|
| 139 |
-
def load(cls):
|
| 140 |
-
import torch
|
| 141 |
-
torch.set_num_threads(max(1, min(4, int(os.getenv("CPU_THREADS", "2")))))
|
| 142 |
-
readings = {}
|
| 143 |
-
e5 = EmbeddingEngine.load()
|
| 144 |
-
readings["e5"] = memory_mb()
|
| 145 |
-
qwen = QwenEngine.load()
|
| 146 |
-
readings["qwen"] = memory_mb()
|
| 147 |
-
colbert = ColbertEngine.load()
|
| 148 |
-
readings["colbert"] = memory_mb()
|
| 149 |
-
gc.collect()
|
| 150 |
-
ram = memory_mb()
|
| 151 |
-
if ram["rss_mb"] is not None and ram["rss_mb"] > ram["budget_mb"]:
|
| 152 |
-
raise MemoryError("All resident models exceed RAM budget")
|
| 153 |
-
return cls(e5, qwen, colbert, readings)
|
| 154 |
-
|
| 155 |
-
def _run(self, callback):
|
| 156 |
-
if not self._gate.acquire(blocking=False):
|
| 157 |
-
raise ServiceBusy("Inference busy; retry later")
|
| 158 |
-
try:
|
| 159 |
-
return callback()
|
| 160 |
-
finally:
|
| 161 |
-
self._gate.release()
|
| 162 |
-
|
| 163 |
-
def embed(self, text, kind, selected):
|
| 164 |
-
engine = self.qwen if selected in ("qwen", QWEN_ID) else self.e5
|
| 165 |
-
result = self._run(lambda: engine.encode(text, kind))
|
| 166 |
-
return (QWEN_ID if engine is self.qwen else E5_ID), engine.dimensions, result
|
| 167 |
-
|
| 168 |
-
def colbert_encode(self, text, kind):
|
| 169 |
-
return self._run(lambda: self.colbert.encode(text, kind))
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
def voyage_encode(self, texts, kind, canonical_model):
|
| 173 |
-
"""Atomic inference gate protects all microbatches in one HTTP request."""
|
| 174 |
-
from app.batching import batch_encode
|
| 175 |
-
if canonical_model == E5_ID:
|
| 176 |
-
engine, mode = self.e5, {}
|
| 177 |
-
elif canonical_model == QWEN_ID:
|
| 178 |
-
engine, mode = self.qwen, {"is_qwen": True}
|
| 179 |
-
elif canonical_model == COLBERT_ID:
|
| 180 |
-
engine, mode = self.colbert, {"is_colbert": True}
|
| 181 |
-
else:
|
| 182 |
-
raise ValueError("Unknown model")
|
| 183 |
-
return self._run(lambda: batch_encode(engine, texts, kind, **mode))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/voyage_api.py
DELETED
|
@@ -1,130 +0,0 @@
|
|
| 1 |
-
"""Voyage AI Python SDK compatible /v1/embeddings facade.
|
| 2 |
-
|
| 3 |
-
Voyage Client(base_url="https://host/v1", api_key="...").embed(...)
|
| 4 |
-
works without a custom client. ColBERT's nested token vectors are an
|
| 5 |
-
intentional 3-D extension; Voyage ordinarily returns one dense vector/input.
|
| 6 |
-
"""
|
| 7 |
-
import os
|
| 8 |
-
import secrets
|
| 9 |
-
from typing import Literal
|
| 10 |
-
|
| 11 |
-
from fastapi import APIRouter, Depends, HTTPException, Security
|
| 12 |
-
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 13 |
-
from pydantic import BaseModel, Field, field_validator
|
| 14 |
-
|
| 15 |
-
from app.embedding import MODEL_ID as E5_ID, TooManyTokens
|
| 16 |
-
from app.models import COLBERT_ID, QWEN_ID, ServiceBusy, ModelRegistry
|
| 17 |
-
|
| 18 |
-
router = APIRouter()
|
| 19 |
-
bearer = HTTPBearer(auto_error=False)
|
| 20 |
-
ModelName = Literal[
|
| 21 |
-
"e5", "qwen", "colbert",
|
| 22 |
-
"intfloat/multilingual-e5-small",
|
| 23 |
-
"Qwen/Qwen3-Embedding-0.6B",
|
| 24 |
-
"lightonai/mLateOn",
|
| 25 |
-
]
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
class VoyageEmbedRequest(BaseModel):
|
| 29 |
-
input: str | list[str] = Field(
|
| 30 |
-
description="One text or a list of texts; max 1000 texts per request",
|
| 31 |
-
examples=[["The first chunk", "The second chunk"]],
|
| 32 |
-
)
|
| 33 |
-
model: ModelName = Field(examples=["qwen"])
|
| 34 |
-
input_type: Literal["query", "document"] | None = Field(default=None)
|
| 35 |
-
truncation: bool = Field(
|
| 36 |
-
default=True,
|
| 37 |
-
description="Accepted for SDK compatibility; this service never truncates silently",
|
| 38 |
-
)
|
| 39 |
-
output_dtype: Literal["float", "float32"] | None = None
|
| 40 |
-
output_dimension: int | None = None
|
| 41 |
-
encoding_format: Literal["base64", "float"] | None = None
|
| 42 |
-
|
| 43 |
-
@field_validator("input")
|
| 44 |
-
@classmethod
|
| 45 |
-
def check_input(cls, value):
|
| 46 |
-
texts = [value] if isinstance(value, str) else value
|
| 47 |
-
if not texts or len(texts) > 1000:
|
| 48 |
-
raise ValueError("Batch must contain between 1 and 1000 texts")
|
| 49 |
-
if any(not isinstance(text, str) or not text.strip() for text in texts):
|
| 50 |
-
raise ValueError("Each input must be a nonblank string")
|
| 51 |
-
return value
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
def authenticate(credentials: HTTPAuthorizationCredentials | None = Security(bearer)):
|
| 55 |
-
expected = os.getenv("API_KEY")
|
| 56 |
-
if (
|
| 57 |
-
not expected or credentials is None
|
| 58 |
-
or not secrets.compare_digest(credentials.credentials, expected)
|
| 59 |
-
):
|
| 60 |
-
raise HTTPException(
|
| 61 |
-
status_code=401, detail="Invalid or missing API key",
|
| 62 |
-
headers={"WWW-Authenticate": "Bearer"},
|
| 63 |
-
)
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
def resident_models():
|
| 67 |
-
# import here avoids app/router circular module initialization.
|
| 68 |
-
from app.main import app
|
| 69 |
-
registry = getattr(app.state, "registry", None)
|
| 70 |
-
if registry is None:
|
| 71 |
-
raise HTTPException(status_code=503, detail="Models are not ready")
|
| 72 |
-
return registry
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
@router.post("/v1/embeddings", dependencies=[Depends(authenticate)])
|
| 76 |
-
def embeddings(payload: VoyageEmbedRequest, registry: ModelRegistry = Depends(resident_models)):
|
| 77 |
-
texts = [payload.input] if isinstance(payload.input, str) else payload.input
|
| 78 |
-
model = payload.model
|
| 79 |
-
canonical = {
|
| 80 |
-
"e5": E5_ID, E5_ID: E5_ID,
|
| 81 |
-
"qwen": QWEN_ID, QWEN_ID: QWEN_ID,
|
| 82 |
-
"colbert": COLBERT_ID, COLBERT_ID: COLBERT_ID,
|
| 83 |
-
}[model]
|
| 84 |
-
dimensions = {
|
| 85 |
-
E5_ID: registry.e5.dimensions,
|
| 86 |
-
QWEN_ID: registry.qwen.dimensions,
|
| 87 |
-
COLBERT_ID: registry.colbert.dimensions,
|
| 88 |
-
}[canonical]
|
| 89 |
-
if payload.output_dimension is not None and payload.output_dimension != dimensions:
|
| 90 |
-
raise HTTPException(
|
| 91 |
-
status_code=422,
|
| 92 |
-
detail=f"Model {canonical} only supports {dimensions} dimensions",
|
| 93 |
-
)
|
| 94 |
-
|
| 95 |
-
# Voyage's None mode has no equivalent for asymmetric E5/Qwen/ColBERT.
|
| 96 |
-
# Consistently treat it as 'document'. input_type remains recommended.
|
| 97 |
-
kind = "query" if payload.input_type == "query" else "passage"
|
| 98 |
-
try:
|
| 99 |
-
results = registry.voyage_encode(texts, kind, canonical)
|
| 100 |
-
except TooManyTokens as exc:
|
| 101 |
-
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
| 102 |
-
except ServiceBusy as exc:
|
| 103 |
-
raise HTTPException(
|
| 104 |
-
status_code=429, detail=str(exc), headers={"Retry-After": "2"}
|
| 105 |
-
) from exc
|
| 106 |
-
except ValueError as exc:
|
| 107 |
-
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
| 108 |
-
except Exception as exc:
|
| 109 |
-
import logging
|
| 110 |
-
logging.getLogger(__name__).exception("Voyage embedding failed")
|
| 111 |
-
raise HTTPException(status_code=500, detail="Embedding failed") from exc
|
| 112 |
-
|
| 113 |
-
return {
|
| 114 |
-
"object": "list",
|
| 115 |
-
"data": [
|
| 116 |
-
{
|
| 117 |
-
"object": "embedding",
|
| 118 |
-
"index": index,
|
| 119 |
-
"embedding": (
|
| 120 |
-
result.token_embeddings
|
| 121 |
-
if canonical == COLBERT_ID else result.embedding
|
| 122 |
-
),
|
| 123 |
-
}
|
| 124 |
-
for index, result in enumerate(results)
|
| 125 |
-
],
|
| 126 |
-
"model": model,
|
| 127 |
-
"usage": {
|
| 128 |
-
"total_tokens": sum(item.tokens_count for item in results),
|
| 129 |
-
},
|
| 130 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pyproject.toml
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[tool.pytest.ini_options]
|
| 2 |
+
pythonpath = ["."]
|
| 3 |
+
testpaths = ["tests"]
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
pytest==8.3.5
|
| 3 |
+
httpx==0.28.1
|
requirements.txt
CHANGED
|
@@ -1,9 +1,6 @@
|
|
| 1 |
-
torch==2.8.0+cpu
|
| 2 |
fastapi==0.115.12
|
| 3 |
uvicorn==0.34.2
|
| 4 |
-
transformers==4.
|
| 5 |
-
huggingface-hub==0.
|
| 6 |
-
sentence-transformers==5.1.1
|
| 7 |
-
pylate==1.3.4
|
| 8 |
sentencepiece==0.2.0
|
| 9 |
safetensors==0.5.3
|
|
|
|
|
|
|
| 1 |
fastapi==0.115.12
|
| 2 |
uvicorn==0.34.2
|
| 3 |
+
transformers==4.51.3
|
| 4 |
+
huggingface-hub==0.30.2
|
|
|
|
|
|
|
| 5 |
sentencepiece==0.2.0
|
| 6 |
safetensors==0.5.3
|
scripts/backup_space.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fail-closed predeployment backup of the existing Hugging Face Space."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import tarfile
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from huggingface_hub import HfApi, snapshot_download
|
| 9 |
+
|
| 10 |
+
SPACE = "SmartDataAlex/Jupyter"
|
| 11 |
+
ROOT = Path(os.environ.get("RUNNER_TEMP", "/tmp")) / "hf-space-backup"
|
| 12 |
+
TOKEN = os.environ.get("HF_TOKEN")
|
| 13 |
+
if not TOKEN:
|
| 14 |
+
raise RuntimeError("HF_TOKEN is required to read and back up the existing Space")
|
| 15 |
+
|
| 16 |
+
api = HfApi(token=TOKEN)
|
| 17 |
+
info = api.repo_info(repo_id=SPACE, repo_type="space")
|
| 18 |
+
revision = info.sha
|
| 19 |
+
if not revision:
|
| 20 |
+
raise RuntimeError("Cannot determine the current Space revision; refusing to deploy")
|
| 21 |
+
|
| 22 |
+
ROOT.mkdir(parents=True, exist_ok=True)
|
| 23 |
+
snapshot_path = Path(snapshot_download(
|
| 24 |
+
repo_id=SPACE,
|
| 25 |
+
repo_type="space",
|
| 26 |
+
revision=revision,
|
| 27 |
+
token=TOKEN,
|
| 28 |
+
local_dir=ROOT / "snapshot",
|
| 29 |
+
))
|
| 30 |
+
files = [p for p in snapshot_path.rglob("*") if p.is_file() and ".cache" not in p.parts]
|
| 31 |
+
if not files:
|
| 32 |
+
raise RuntimeError("Space snapshot is empty; refusing to overwrite it")
|
| 33 |
+
archive = ROOT / f"SmartDataAlex-Jupyter-{revision}.tar.gz"
|
| 34 |
+
with tarfile.open(archive, "w:gz") as output:
|
| 35 |
+
for path in files:
|
| 36 |
+
output.add(path, arcname=path.relative_to(snapshot_path))
|
| 37 |
+
(ROOT / "backup-manifest.json").write_text(json.dumps({
|
| 38 |
+
"space": SPACE, "revision": revision, "files": len(files), "archive": archive.name
|
| 39 |
+
}, indent=2), encoding="utf-8")
|
| 40 |
+
print(f"Space backed up: revision={revision}, files={len(files)}, archive={archive}")
|
scripts/bench_local.py
DELETED
|
@@ -1,131 +0,0 @@
|
|
| 1 |
-
"""Opt-in on-host three-resident-model CPU/RSS benchmark.
|
| 2 |
-
|
| 3 |
-
Run from service root with model snapshots downloaded and API_KEY configured:
|
| 4 |
-
BENCH_LONG=1 python scripts/bench_local.py
|
| 5 |
-
Reports are checkpointed after every case to survive failures/OOM.
|
| 6 |
-
"""
|
| 7 |
-
from __future__ import annotations
|
| 8 |
-
|
| 9 |
-
import json
|
| 10 |
-
import os
|
| 11 |
-
import threading
|
| 12 |
-
import time
|
| 13 |
-
from datetime import datetime, timezone
|
| 14 |
-
from pathlib import Path
|
| 15 |
-
|
| 16 |
-
from app.embedding import MODEL_ID as E5_ID
|
| 17 |
-
from app.models import ModelRegistry, QWEN_ID, COLBERT_ID, memory_mb
|
| 18 |
-
|
| 19 |
-
REPORT = Path(os.getenv("BENCH_REPORT", "bench-result.json"))
|
| 20 |
-
LONG = os.getenv("BENCH_LONG", "0") == "1"
|
| 21 |
-
rows = []
|
| 22 |
-
report = {"time": datetime.now(timezone.utc).isoformat(),
|
| 23 |
-
"long_context": LONG, "startup": None, "cases": rows}
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
def save():
|
| 27 |
-
REPORT.write_text(json.dumps(report, indent=2, ensure_ascii=False),
|
| 28 |
-
encoding="utf-8")
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def repeated_text(tokenizer, max_tokens: int, prefix: str = "") -> str:
|
| 32 |
-
"""Find a repetition count whose true tokenizer length is <= target.
|
| 33 |
-
|
| 34 |
-
Model-specific input overhead (query instructions/markers) is counted.
|
| 35 |
-
A result slightly shorter than target is reported, not mislabeled.
|
| 36 |
-
"""
|
| 37 |
-
base = "Planet earth history. "
|
| 38 |
-
def length(n):
|
| 39 |
-
return len(tokenizer(prefix + base * n, add_special_tokens=True,
|
| 40 |
-
truncation=False)["input_ids"])
|
| 41 |
-
lo, hi = 0, max_tokens
|
| 42 |
-
while lo < hi:
|
| 43 |
-
mid = (lo + hi + 1) // 2
|
| 44 |
-
if length(mid) <= max_tokens:
|
| 45 |
-
lo = mid
|
| 46 |
-
else:
|
| 47 |
-
hi = mid - 1
|
| 48 |
-
if lo == 0:
|
| 49 |
-
raise RuntimeError("Model prefix exceeds the test target")
|
| 50 |
-
return base * lo
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
def measure(name, runner, token_count=None):
|
| 54 |
-
readings = []
|
| 55 |
-
finish = threading.Event()
|
| 56 |
-
|
| 57 |
-
def monitor():
|
| 58 |
-
while not finish.wait(0.05):
|
| 59 |
-
value = memory_mb()["rss_mb"]
|
| 60 |
-
if value is not None:
|
| 61 |
-
readings.append(value)
|
| 62 |
-
|
| 63 |
-
sampler = threading.Thread(target=monitor, daemon=True)
|
| 64 |
-
sampler.start()
|
| 65 |
-
started = time.perf_counter()
|
| 66 |
-
result = {"case": name, "requested_tokens": token_count}
|
| 67 |
-
try:
|
| 68 |
-
payload = runner()
|
| 69 |
-
result["status"] = "pass"
|
| 70 |
-
result["output_tokens"] = payload.tokens_count
|
| 71 |
-
if hasattr(payload, "embedding"):
|
| 72 |
-
result["dimensions"] = len(payload.embedding)
|
| 73 |
-
else:
|
| 74 |
-
result["dimensions"] = len(payload.token_embeddings[0])
|
| 75 |
-
result["vector_count"] = len(payload.token_embeddings)
|
| 76 |
-
except Exception as error:
|
| 77 |
-
result["status"] = "fail"
|
| 78 |
-
result["error"] = f"{type(error).__name__}: {error}"
|
| 79 |
-
finally:
|
| 80 |
-
finish.set()
|
| 81 |
-
sampler.join()
|
| 82 |
-
result["elapsed_seconds"] = round(time.perf_counter() - started, 3)
|
| 83 |
-
result["rss_mb"] = memory_mb()["rss_mb"]
|
| 84 |
-
result["peak_sampled_rss_mb"] = max(readings, default=None)
|
| 85 |
-
result["peak_process_rss_mb"] = memory_mb()["peak_rss_mb"]
|
| 86 |
-
rows.append(result)
|
| 87 |
-
save()
|
| 88 |
-
print(result, flush=True)
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
def main():
|
| 92 |
-
started = time.perf_counter()
|
| 93 |
-
registry = ModelRegistry.load() # all 3 weights resident, once
|
| 94 |
-
report["startup"] = {
|
| 95 |
-
"seconds": round(time.perf_counter() - started, 3),
|
| 96 |
-
"per_model_rss": registry.startup_memory,
|
| 97 |
-
"post_load_ram": memory_mb(),
|
| 98 |
-
}
|
| 99 |
-
save()
|
| 100 |
-
|
| 101 |
-
for kind in ("query", "passage"):
|
| 102 |
-
for language, text in (
|
| 103 |
-
("en", "A long voyage through the forest."),
|
| 104 |
-
("ru", "Долгое путешествие сквозь лес."),
|
| 105 |
-
("he", "מסע ארוך דרך היער."),
|
| 106 |
-
):
|
| 107 |
-
measure(f"e5-{kind}-{language}",
|
| 108 |
-
lambda t=text, k=kind: registry.e5.encode(t, k))
|
| 109 |
-
measure(f"qwen-{kind}-{language}",
|
| 110 |
-
lambda t=text, k=kind: registry.qwen.encode(t, k))
|
| 111 |
-
measure(f"colbert-{kind}-{language}",
|
| 112 |
-
lambda t=text, k=kind: registry.colbert.encode(t, k))
|
| 113 |
-
|
| 114 |
-
if LONG:
|
| 115 |
-
for target in (8192, 32768):
|
| 116 |
-
text = repeated_text(registry.qwen.tokenizer, target)
|
| 117 |
-
actual = len(registry.qwen.tokenizer(
|
| 118 |
-
text, add_special_tokens=True, truncation=False)["input_ids"])
|
| 119 |
-
measure(f"qwen-passage-{target}", lambda t=text: registry.qwen.encode(t, "passage"), actual)
|
| 120 |
-
|
| 121 |
-
model = registry.colbert.model
|
| 122 |
-
prefix = model.document_prefix
|
| 123 |
-
text = repeated_text(model.tokenizer, 8192, prefix)
|
| 124 |
-
count = len(model.tokenizer(
|
| 125 |
-
prefix + text, add_special_tokens=True, truncation=False)["input_ids"])
|
| 126 |
-
measure("colbert-passage-8192",
|
| 127 |
-
lambda: registry.colbert.encode(text, "passage"), count)
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
if __name__ == "__main__":
|
| 131 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/deploy_space.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Atomically replace Space content only if its predeployment backup is current."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from huggingface_hub import HfApi
|
| 10 |
+
|
| 11 |
+
SPACE = "SmartDataAlex/Jupyter"
|
| 12 |
+
ROOT = Path(os.environ.get("RUNNER_TEMP", "/tmp")) / "hf-space-backup"
|
| 13 |
+
TOKEN = os.environ.get("HF_TOKEN")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def main() -> None:
|
| 17 |
+
if not TOKEN:
|
| 18 |
+
raise RuntimeError("Missing HF_TOKEN")
|
| 19 |
+
manifest_path = ROOT / "backup-manifest.json"
|
| 20 |
+
if not manifest_path.is_file():
|
| 21 |
+
raise RuntimeError("No completed backup manifest; refusing to deploy")
|
| 22 |
+
backup = json.loads(manifest_path.read_text(encoding="utf-8"))
|
| 23 |
+
revision = backup.get("revision")
|
| 24 |
+
if backup.get("space") != SPACE or not revision:
|
| 25 |
+
raise RuntimeError("Backup is for a different Space or lacks a revision")
|
| 26 |
+
archive = ROOT / backup.get("archive", "")
|
| 27 |
+
if not archive.is_file() or archive.stat().st_size == 0:
|
| 28 |
+
raise RuntimeError("Backup archive missing; refusing to deploy")
|
| 29 |
+
|
| 30 |
+
api = HfApi(token=TOKEN)
|
| 31 |
+
current = api.repo_info(repo_id=SPACE, repo_type="space").sha
|
| 32 |
+
if current != revision:
|
| 33 |
+
raise RuntimeError("Space changed after backup; refusing to overwrite newer content")
|
| 34 |
+
|
| 35 |
+
# Upload_folder handles deletions and additions within the same commit.
|
| 36 |
+
# parent_commit prevents accidentally overwriting a concurrent Space update.
|
| 37 |
+
result = api.upload_folder(
|
| 38 |
+
repo_id=SPACE,
|
| 39 |
+
repo_type="space",
|
| 40 |
+
folder_path=".",
|
| 41 |
+
allow_patterns=[
|
| 42 |
+
"README.md", "Dockerfile", ".dockerignore", "VERSION",
|
| 43 |
+
"requirements.txt", "app/**", "scripts/download_model.py",
|
| 44 |
+
],
|
| 45 |
+
delete_patterns="*",
|
| 46 |
+
parent_commit=revision,
|
| 47 |
+
commit_message=f"Deploy vectorization service from GitHub {os.environ.get('GITHUB_SHA', 'unknown')}",
|
| 48 |
+
)
|
| 49 |
+
print(f"Deployed {SPACE}: {result.commit_url}")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
if __name__ == "__main__":
|
| 53 |
+
main()
|
scripts/download_model.py
CHANGED
|
@@ -1,23 +1,19 @@
|
|
| 1 |
-
"""
|
|
|
|
| 2 |
import os
|
|
|
|
| 3 |
from huggingface_hub import snapshot_download
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
repo_id=repo_id, revision=revision,
|
| 19 |
-
local_dir=os.getenv(env_name, default),
|
| 20 |
-
ignore_patterns=["*.onnx", "*.h5", "*.msgpack", "*.ot",
|
| 21 |
-
"*.bin", "*.pt", "*.md", "*.tflite", "*.gguf"],
|
| 22 |
-
)
|
| 23 |
-
print(f"Downloaded {repo_id}@{revision} to {path}", flush=True)
|
|
|
|
| 1 |
+
"""Cache an immutable, minimal E5 model snapshot in the Docker image."""
|
| 2 |
+
|
| 3 |
import os
|
| 4 |
+
|
| 5 |
from huggingface_hub import snapshot_download
|
| 6 |
|
| 7 |
+
snapshot_download(
|
| 8 |
+
repo_id="intfloat/multilingual-e5-small",
|
| 9 |
+
revision="fd1525a9fd15316a2d503bf26ab031a61d056e98",
|
| 10 |
+
local_dir=os.getenv("E5_MODEL_DIR", "/opt/models/multilingual-e5-small"),
|
| 11 |
+
allow_patterns=[
|
| 12 |
+
"config.json",
|
| 13 |
+
"model.safetensors",
|
| 14 |
+
"sentencepiece.bpe.model",
|
| 15 |
+
"special_tokens_map.json",
|
| 16 |
+
"tokenizer.json",
|
| 17 |
+
"tokenizer_config.json",
|
| 18 |
+
],
|
| 19 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/restore_space.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Explicit/manual rollback of a previously backed-up Hugging Face Space.
|
| 2 |
+
|
| 3 |
+
Use only with a trusted backup archive produced by backup_space.py.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import argparse
|
| 9 |
+
import os
|
| 10 |
+
import tarfile
|
| 11 |
+
import tempfile
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
from huggingface_hub import HfApi
|
| 15 |
+
|
| 16 |
+
SPACE = "SmartDataAlex/Jupyter"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def main() -> None:
|
| 20 |
+
parser = argparse.ArgumentParser(description="Restore existing Space files from a backup archive")
|
| 21 |
+
parser.add_argument("archive", type=Path)
|
| 22 |
+
parser.add_argument("--confirm-space", required=True, help=f"Must be exactly {SPACE}")
|
| 23 |
+
args = parser.parse_args()
|
| 24 |
+
if args.confirm_space != SPACE:
|
| 25 |
+
raise SystemExit(f"Refusing to restore: --confirm-space must equal {SPACE}")
|
| 26 |
+
token = os.getenv("HF_TOKEN")
|
| 27 |
+
if not token:
|
| 28 |
+
raise SystemExit("HF_TOKEN environment variable is required")
|
| 29 |
+
if not args.archive.is_file():
|
| 30 |
+
raise SystemExit("Backup archive not found")
|
| 31 |
+
|
| 32 |
+
with tempfile.TemporaryDirectory(prefix="space-rollback-") as temp:
|
| 33 |
+
dest = Path(temp)
|
| 34 |
+
with tarfile.open(args.archive, "r:gz") as backup:
|
| 35 |
+
members = backup.getmembers()
|
| 36 |
+
if not members or len(members) > 10000:
|
| 37 |
+
raise SystemExit("Invalid backup archive")
|
| 38 |
+
for member in members:
|
| 39 |
+
if (not member.isfile() or member.name.startswith("/")
|
| 40 |
+
or ".." in Path(member.name).parts):
|
| 41 |
+
raise SystemExit("Unsafe or unsupported backup entry")
|
| 42 |
+
backup.extractall(path=dest, filter="data")
|
| 43 |
+
|
| 44 |
+
HfApi(token=token).upload_folder(
|
| 45 |
+
repo_id=SPACE,
|
| 46 |
+
repo_type="space",
|
| 47 |
+
folder_path=str(dest),
|
| 48 |
+
delete_patterns="*",
|
| 49 |
+
commit_message=f"Restore Space from backup {args.archive.name}",
|
| 50 |
+
)
|
| 51 |
+
print(f"Restored {SPACE} from {args.archive.name}")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
if __name__ == "__main__":
|
| 55 |
+
main()
|
scripts/smoke_live.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Test the deployed HTTPS service, not local code. Store JSON report for AI review."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import math
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
import time
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
|
| 15 |
+
SPACE_URL = os.getenv("SPACE_URL", "https://smartdataalex-jupyter.hf.space").rstrip("/")
|
| 16 |
+
KEY = os.environ.get("API_TEST_KEY", "")
|
| 17 |
+
EXPECTED_SHA = os.environ.get("EXPECTED_SHA", "")
|
| 18 |
+
MAX_WAIT_SECONDS = int(os.getenv("LIVE_WAIT_SECONDS", "2400"))
|
| 19 |
+
REPORT = Path(os.getenv("SMOKE_REPORT", "smoke-result.json"))
|
| 20 |
+
TESTS = []
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def check(name: str, condition: bool, details: str = "") -> None:
|
| 24 |
+
TESTS.append({"name": name, "passed": bool(condition), "details": details})
|
| 25 |
+
if not condition:
|
| 26 |
+
raise AssertionError(f"{name} failed: {details}")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def cosine(a: list[float], b: list[float]) -> float:
|
| 30 |
+
dot = sum(x * y for x, y in zip(a, b))
|
| 31 |
+
na = math.sqrt(sum(x * x for x in a))
|
| 32 |
+
nb = math.sqrt(sum(y * y for y in b))
|
| 33 |
+
return dot / (na * nb)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def run() -> None:
|
| 37 |
+
if not KEY:
|
| 38 |
+
raise ValueError("API_TEST_KEY is required for real authenticated tests")
|
| 39 |
+
headers = {"X-API-Key": KEY}
|
| 40 |
+
with httpx.Client(timeout=90.0) as client:
|
| 41 |
+
deadline = time.monotonic() + MAX_WAIT_SECONDS
|
| 42 |
+
while time.monotonic() < deadline:
|
| 43 |
+
try:
|
| 44 |
+
health = client.get(f"{SPACE_URL}/health", timeout=20)
|
| 45 |
+
if health.status_code == 200 and health.json().get("status") == "ok":
|
| 46 |
+
version = health.json().get("version", "")
|
| 47 |
+
if not EXPECTED_SHA or version == EXPECTED_SHA:
|
| 48 |
+
break
|
| 49 |
+
except (httpx.HTTPError, ValueError):
|
| 50 |
+
pass
|
| 51 |
+
time.sleep(15)
|
| 52 |
+
else:
|
| 53 |
+
raise TimeoutError("Live service did not reach the expected release version")
|
| 54 |
+
check("public_health", health.status_code == 200)
|
| 55 |
+
check("expected_deployment", not EXPECTED_SHA or health.json().get("version") == EXPECTED_SHA)
|
| 56 |
+
|
| 57 |
+
unauthorized = client.post(f"{SPACE_URL}/embed", json={"text": "hello", "type": "query"})
|
| 58 |
+
check("missing_key_rejected", unauthorized.status_code == 401, str(unauthorized.status_code))
|
| 59 |
+
forbidden = client.post(f"{SPACE_URL}/embed", json={"text": "hello", "type": "query"}, headers={"X-API-Key": "wrong-key"})
|
| 60 |
+
check("wrong_key_rejected", forbidden.status_code == 401, str(forbidden.status_code))
|
| 61 |
+
check("model_metadata_protected", client.get(f"{SPACE_URL}/model").status_code == 401)
|
| 62 |
+
|
| 63 |
+
model_response = client.get(f"{SPACE_URL}/model", headers=headers)
|
| 64 |
+
check("model_metadata_authorized", model_response.status_code == 200)
|
| 65 |
+
info = model_response.json()
|
| 66 |
+
check("model_and_dimensions", info.get("embedding_model") == "intfloat/multilingual-e5-small" and info.get("dimensions") == 384)
|
| 67 |
+
check("no_extra_normalization", info.get("normalization") == "none")
|
| 68 |
+
|
| 69 |
+
def embed(text: str, kind: str = "query") -> dict:
|
| 70 |
+
response = client.post(f"{SPACE_URL}/embed", headers=headers, json={"text": text, "type": kind})
|
| 71 |
+
check(f"embed_{len(TESTS)}", response.status_code == 200, str(response.status_code))
|
| 72 |
+
return response.json()
|
| 73 |
+
|
| 74 |
+
sample = embed("How to find similar documents?")
|
| 75 |
+
sample_again = embed("How to find similar documents?")
|
| 76 |
+
check("response_echo", sample.get("text") == "How to find similar documents?" and sample.get("type") == "query")
|
| 77 |
+
check("token_count", isinstance(sample.get("tokens_count"), int) and sample["tokens_count"] > 0)
|
| 78 |
+
vector = sample["embedding"]
|
| 79 |
+
check("dimension_consistent", len(vector) == sample.get("dimensions") == 384)
|
| 80 |
+
check("vector_numeric", all(isinstance(x, (int, float)) and not isinstance(x, bool) and math.isfinite(x) for x in vector))
|
| 81 |
+
check("reproducibility", max(abs(a - b) for a, b in zip(vector, sample_again["embedding"])) < 1e-5)
|
| 82 |
+
check("passage_mode", embed("A short sentence.", "passage")["type"] == "passage")
|
| 83 |
+
check("unicode", embed("Проверка текста на русском языке")["dimensions"] == 384)
|
| 84 |
+
|
| 85 |
+
related = embed("How to search for similar documents?")
|
| 86 |
+
unrelated = embed("The strawberry cake is delicious.")
|
| 87 |
+
related_score = cosine(vector, related["embedding"])
|
| 88 |
+
unrelated_score = cosine(vector, unrelated["embedding"])
|
| 89 |
+
check("semantic_sanity", related_score > unrelated_score, f"related={related_score:.4f} unrelated={unrelated_score:.4f}")
|
| 90 |
+
|
| 91 |
+
for invalid in [{"text": "", "type": "query"}, {"text": "ok", "type": "invalid"}, {"type": "query"}]:
|
| 92 |
+
response = client.post(f"{SPACE_URL}/embed", headers=headers, json=invalid)
|
| 93 |
+
check("invalid_input_rejected", response.status_code == 422, str(response.status_code))
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
if __name__ == "__main__":
|
| 97 |
+
started = datetime.now(timezone.utc).isoformat()
|
| 98 |
+
error = None
|
| 99 |
+
try:
|
| 100 |
+
run()
|
| 101 |
+
except Exception as exc:
|
| 102 |
+
error = f"{type(exc).__name__}: {exc}"
|
| 103 |
+
report = {
|
| 104 |
+
"started_at": started, "space_url": SPACE_URL, "expected_sha": EXPECTED_SHA or None,
|
| 105 |
+
"passed": error is None, "error": error, "checks": TESTS,
|
| 106 |
+
}
|
| 107 |
+
REPORT.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 108 |
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
| 109 |
+
sys.exit(1 if error is not None else 0)
|