# Private VPS model connection

Phase 6 milestone 15, 2026-10-15. The backend supports either the existing laptop loopback or the fixed private Docker service `ollama`. Assistant routing and email extraction use the same destination helper. Arbitrary URLs/hostnames, redirects, environment proxies and cloud-model descriptors are rejected; no API key is forwarded and there is no automatic cloud fallback.

The base staging recipe remains unchanged with AI off. An explicit optional overlay adds a CPU-only Ollama service. This is deployment preparation, not evidence of a running VPS, adequate CPU/RAM, model accuracy or training. Staff-facing web/iOS/Android APIs are unchanged.

## Configuration

`PB_OLLAMA_HOST` accepts only `127.0.0.1` (default) or `ollama`. `PB_OLLAMA_PORT` retains its laptop default of 11435. The Docker overlay sets `ollama:11434` for both API and worker. This fixed service name is trusted private deployment configuration; do not create a public DNS mapping or proxy to it. A remote authenticated model endpoint would require a separately reviewed integration.

The overlay `deploy/staging/compose.ai.yml` adds:

- A model network marked `internal`, shared only by API, extraction worker and Ollama. Web, proxy and database do not join it; Ollama publishes no host port.
- A persistent model volume, cloud features disabled, one loaded model, one parallel inference and a bounded model queue.
- Explicit operator-selected image, model, CPU limit and memory limit. No GPU allocation or model download happens automatically.
- AI disabled unless `STAGING_AI_ENABLED=true` is explicitly selected. Both extraction teaching and assistant teaching remain forced off in this initial pilot overlay.

Select a reviewed Ollama image digest and explicit local model tag. Add these non-secret values to the existing private `.local/staging/compose.env` without replacing its hostnames or other settings:

```dotenv
STAGING_OLLAMA_IMAGE=ollama/ollama@sha256:REPLACE_WITH_REVIEWED_IMAGE_DIGEST
STAGING_OLLAMA_MODEL=qwen3:4b
STAGING_OLLAMA_CPUS=REPLACE_WITH_REVIEWED_CPU_LIMIT
STAGING_OLLAMA_MEMORY=REPLACE_WITH_REVIEWED_MEMORY_LIMIT
STAGING_AI_ENABLED=false
```

The image and resource values above are placeholders, not deployable defaults. `qwen3:4b` is the existing development model, not a guarantee that it meets staff needs. Model files and their license notices are provisioned separately; they are not in the server ZIP. The 32k extraction context needs capacity testing as well as the shorter assistant requests. Do not buy a GPU until measurements establish the need. Ordinary application hosting and model resources should be sized separately.

For the commands below, define a shell function in the extracted `platform` directory. All commands use the same existing Compose project and private settings:

```sh
pbmodel() {
  docker compose --env-file .local/staging/compose.env \
    -f deploy/staging/compose.yml -f deploy/staging/compose.ai.yml "$@"
}
pbmodel --profile local-ai config --quiet
```

## Provision weights before enabling staff requests

Ollama normally has no internet route in this setup. The separately named `compose.ai-download.yml` temporarily attaches only Ollama to a network with outbound connectivity so the operator can download the approved model. It still exposes no host ports. Keep `STAGING_AI_ENABLED=false` and the extraction worker stopped during provisioning:

```sh
docker compose --env-file .local/staging/compose.env \
  -f deploy/staging/compose.yml -f deploy/staging/compose.ai.yml \
  -f deploy/staging/compose.ai-download.yml --profile local-ai \
  up -d --wait ollama

docker compose --env-file .local/staging/compose.env \
  -f deploy/staging/compose.yml -f deploy/staging/compose.ai.yml \
  -f deploy/staging/compose.ai-download.yml --profile local-ai \
  exec ollama ollama pull qwen3:4b

# Recreate without the provisioning overlay to remove outbound connectivity.
pbmodel --profile local-ai up -d --force-recreate --wait ollama
```

Use the exact reviewed model tag if it differs from the example. Inspect the recreated container's network membership and confirm only the internal model network remains before sending business content. The image itself is pulled by Docker on the host; internal networking does not stop a trusted host administrator from pulling or changing images. Do not leave the provisioning overlay in normal startup commands.

## Verify without enabling AI

Build the current API image first using the staging instructions. The following one-off API container reads the configured model metadata, records its digest and optionally sends three fixed synthetic cases. The API entrypoint supplies its normal private environment, but the probe makes no business database queries or writes, creates no jobs and enables no features. It needs the model service already running; `--no-deps` will not start services for it.

```sh
pbmodel run --rm --no-deps -T api python -m phonebuzz.model_probe --run
pbmodel run --rm --no-deps -T api python -m phonebuzz.model_probe --run --evaluate
```

The metadata-only command sends no question/email content. The evaluation command checks a stock lookup, rejection of a delete request, and non-trading email extraction; it records timings and compares model digests before/after. A changed tag, unavailable/remote model, invalid response or wrong expected result returns a failing exit status. Raw prompts/responses, private network diagnostics and secrets are not printed in its JSON report. These three known synthetic cases are smoke checks, not a held-out accuracy benchmark, load test or training dataset.

On the laptop the same command works with `PYTHONPATH=apps/api`, `PB_AI_MODEL=qwen3:4b` and the existing loopback server. Explicit `--run` is required. The probe ignores `.env` files directly; set environment variables explicitly or use the container entrypoint. It can run while `PB_AI_ENABLED=false` because the operator explicitly requested this diagnostic; it does not change that flag.

## Enable and stop the pilot capability

Only after the hosted checks and an approved pilot decision, set `STAGING_AI_ENABLED=true` and recreate API. Start the worker explicitly only if email extraction is included in the pilot:

```sh
pbmodel --profile local-ai up -d --wait api web proxy
pbmodel --profile local-ai --profile ai up -d worker
```

Keep using the model overlay for subsequent API/worker updates. To stop new AI work, set the flag back to false, stop the worker and recreate the API. Stopping clients does not recall inference already accepted by the model; a timeout is not proof that compute stopped. Manual staff workflows remain available during model outages.

Do not enable assistant teaching simply because smoke checks pass. Collect representative staff questions and reviewed examples, retain separate evaluation families, and establish agreed acceptance thresholds first. This milestone does not modify model weights or autonomously learn from production messages.

## Validation and remaining work

Local mock tests exercise both allowed destinations, rejected arbitrary destinations, redirect/cloud refusal, bounded responses, shared routing/extraction behavior, metadata-only diagnostics, redaction and changed-model detection. Compose resolution checks model-network isolation, absent model ports, unchanged disabled defaults and the deliberate provisioning network. Linux container startup, volume permissions, firewall/egress enforcement, VPS performance and representative staff/model evaluation remain pending. Existing per-process inference limits are not distributed production rate limits.

Implementation references: [Ollama Docker setup](https://docs.ollama.com/docker), [cloud disabling and server configuration](https://docs.ollama.com/faq), and [model-list digest metadata](https://docs.ollama.com/api/tags). These explain the provider interfaces; they do not certify this deployment.

## Reviewed assistant evaluation

After collecting administrator-reviewed holdouts, the rebuilt API image can run `python -m phonebuzz.assistant_benchmark --run --limit 20` through the same private Compose model connection. See [ASSISTANT-BENCHMARK.md](ASSISTANT-BENCHMARK.md) for protected output, exit statuses, enforced read-only database snapshots and per-call dataset/model checks. This comparison works with inference/teaching feature flags off and never enables them. Empty holdouts produce no model requests or accuracy score.

## Reviewed supplier extraction evaluation

The rebuilt API image also includes `python -m phonebuzz.extraction_benchmark --run --limit 5`. It shares private model identity/transport, uses enforced read-only snapshots and rejects changed dataset/model provenance. See [EXTRACTION-BENCHMARK.md](EXTRACTION-BENCHMARK.md) for protected output and status handling. No holdouts or no eligible examples means no model request or accuracy score. This explicit comparison does not enable production inference/teaching or train weights.
