# 15 — Configuration ## Where configuration is defined `apps/ai-service/config.py` is the single authority for the Python service: every setting is a field on the Pydantic `Settings` class, loaded from the environment or from a `.env` file next to the process, with `extra="ignore"`. `get_settings()` is `@lru_cache`d, so values are read once per process. There is **no `.env.example` anywhere in the repository**. The only env file is `apps/ai-service/.env`, which is gitignored and local; production uses `apps/ai-service/.env.prod`, which is also gitignored and lives only on the EC2 host. A new engineer therefore has no committed template to copy — see [27-technical-debt.md](27-technical-debt.md). ## ai-service settings | Variable | Required | Default | Purpose | Secret | |---|---|---|---|---| | `APP_NAME` | no | `vsf-duoc-thu-ai-service` | FastAPI title | no | | `ENVIRONMENT` | no | `local` | Label; sent as `deployment.environment` on OTel resource | no | | `QDRANT_URL` | effectively yes | `http://localhost:6333` | Vector store | no | | `QDRANT_COLLECTION` | no | `duocthu_v1` | Collection name; the manifest sidecar is `__manifest` | no | | `QDRANT_API_KEY` | no | `None` | Qdrant auth | **yes** | | `POSTGRES_DSN` | no | `postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu` | Traces, turns, feedback. Declared `repr=False` so it is not echoed | **yes** | | `EMBEDDING_PROVIDER` | no | `cohere-v4` | `cohere-v4` or `disabled`. Any other value raises at startup | no | | `EMBEDDING_DIMENSIONS` | no | `1024` | Must match the corpus manifest or startup fails | no | | `EVIDENCE_MINIMUM_SCORE` | no | `0.12` | Dense-route score floor | no | | `AWS_REGION` | no | `us-east-1` | Bedrock region | no | | `ANSWER_PROVIDER` | no | `disabled` | `disabled` \| `stub` \| `bedrock-converse` \| `bedrock-claude`. **Chooses the operating mode** | no | | `ANSWER_MODEL_ID` | no | `deepseek.v3.2` | Bedrock model id | no | | `RERANK_ENABLED` | no | `false` | Enables `cohere.rerank-v3-5:0` on the fallback route | no | | `METRICS_ENABLED` | no | `true` | Builds the Prometheus exporter | no | | `METRICS_TOKEN` | no | `""` | Bearer token for `GET /metrics`; empty = unauthenticated | **yes** | | `OTEL_ENABLED` | no | `false` | Turns on OTLP export | no | | `OTEL_SERVICE_NAME` | no | `ai-service` | | no | | `OTEL_EXPORTER_OTLP_ENDPOINT` | no | `http://localhost:4318/v1/traces` | OTLP/HTTP traces endpoint | no | | `OTEL_SAMPLE_RATIO` | no | `1.0` (0.0–1.0) | `TraceIdRatioBased` sampler | no | | `ENTITIES_PATH` | in the container | repo-relative `ingestion/data/verified/drug_entities.json` | Drug alias catalog | no | | `MAX_WALL_CLOCK_MS` | no | `40000` | Per-turn budget | no | | `MAX_LLM_CALLS_PER_TURN` | no | `8` | Per-turn budget | no | `ENTITIES_PATH` needs an explicit value in the container: `config.py`'s default resolves two parents up from `apps/ai-service/config.py`, and the image flattens `apps/ai-service/` into `/app`, so the depth is wrong. The Dockerfile bakes the file to `./ingestion_data/drug_entities.json` and `.env.prod` points at it. ### Settings that change behaviour, not just tuning Three values are mode switches rather than knobs: | Setting | Effect | |---|---| | `EMBEDDING_PROVIDER=disabled` | `build_runtime` returns no answer service and no agent. `/v1/rag/query` answers **503**, while `/ready` still answers 200. | | `ANSWER_PROVIDER=disabled` | No `RagAgent`, no understanding, no multi-turn. Retrieval-only, single-turn, verbatim quotes. | | `EMBEDDING_DIMENSIONS` ≠ manifest | Startup raises `ManifestMismatch` and the process does not come up. | ## web settings | Variable | Required | Default | Purpose | |---|---|---|---| | `API_GATEWAY_URL` | no | — | Preferred upstream; accepts a base URL or a full `/v1/rag/...` URL | | `AI_SERVICE_URL` | no | `http://localhost:8000` | Fallback; set to `http://ai-service:8000` in prod Compose | Rate-limit rules are **hard-coded constants** in `middleware.ts`, not configuration: `/api/chat` 12/min and 120/hour; `/api/suggest` 120/min. ## ingestion settings `ingestion` takes no environment variables. Everything is a CLI flag (`--pdf`, `--out`, `--tables`, `--monographs`, `--chunks`, `--provider`, `--collection`, `--region`, `--qdrant-url`, `--slice-size`, `--attempts`, `--embed-only`). AWS credentials come from the standard boto3 chain. ## Deployment-layer configuration | Layer | File | Notes | |---|---|---| | Production Compose | `infra/docker/docker-compose.prod.yml` | `ai-service` reads `env_file: ../../apps/ai-service/.env.prod` (not in the repo) | | Observability overlay | `infra/docker/docker-compose.observability.yml` | Sets `OTEL_ENABLED=true`, `OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318/v1/traces`, `ENVIRONMENT=compose`; reads `GRAFANA_ADMIN_USER` / `GRAFANA_ADMIN_PASSWORD` from the shell | | Helm | `values.yaml` + `values-{dev,staging,prod}.yaml` | Maps to a ConfigMap of the same env vars; `POSTGRES_DSN` comes from a Secret | | CI | `.github/workflows/deploy.yml` | Uses `EC2_HOST`, `EC2_SSH_KEY`, `GRAFANA_ADMIN_PASSWORD` GitHub secrets | ### Helm chart defaults are *not* production defaults `infra/helm/medical-chatbot/values.yaml` ships `aiService.config.embeddingProvider: disabled` and `answerProvider: disabled`, i.e. a deployment of the chart as-is answers 503 on `/v1/rag/query`. It also ships `secret.postgresPassword: duoc_thu` and `secret.grafanaAdminPassword: change-me` as literal defaults. ## Secrets inventory | Secret | Where it lives | Committed? | |---|---|---| | PostgreSQL password | `docker-compose.prod.yml` env (`duoc_thu`/`duoc_thu`), Helm `secret.postgresPassword` | **Yes — a default credential is in the repository** | | Grafana admin password | `GRAFANA_ADMIN_PASSWORD` GitHub secret → shell env; Helm default `change-me` | Secret value not committed; the placeholder default is | | AWS credentials | EC2 instance IAM role | **No** — deliberately; the Compose header comment says so | | `QDRANT_API_KEY` | Unset (Qdrant is not exposed) | No | | `METRICS_TOKEN` | Unset | No | | EC2 host + SSH key | GitHub Actions secrets | No | `git ls-files` shows no `.env` file tracked, and the two IAM documents under `infra/aws/iam/` are policy JSON, not credentials. The one real issue is the PostgreSQL default credential, which is committed in two places — see [16-security.md](16-security.md). ## Configuration verified this session `apps/ai-service/.env` (local, gitignored) contains: ``` EMBEDDING_PROVIDER=cohere-v4 ANSWER_PROVIDER=bedrock-converse ANSWER_MODEL_ID=qwen.qwen3-next-80b-a3b RERANK_ENABLED=true AWS_REGION=us-east-1 QDRANT_COLLECTION=duocthu_v1 QDRANT_URL= ``` Note the drift: the **code default** for `ANSWER_MODEL_ID` is `deepseek.v3.2`, the **local `.env`** uses `qwen.qwen3-next-80b-a3b`, and the **production value is unverifiable from the repository** because `.env.prod` is not committed. Any statement about which model production runs would be a guess.