pdf-quiz-generator/docker-compose.yml
Daniel 16aed6b6b0 feat: Cap on its own host, hints per topic, and an objective is asked for
Cap moved from /cap/ under this app to cap.pedshub.com, so anything else
on this machine can use the same instance. Caddy terminates it, the
backend keeps verifying over the compose network rather than going out
and back, and the widget endpoint is configuration rather than a path
baked into the component. Verified: a challenge is issued on the
subdomain, and a token that was never issued is still refused.

"Correct using hints" is now a per-topic figure. The knowledge profile's
accuracy bar was two-tone because /study-tools/recommendations carried
only `answered` and `correct`; the hint count existed lifetime-wide but
never per topic, and inferring one from the other would have been a
different set of answers drawn as though it were this one. The column
was already on attempt_answers, so it is a group-by, and the bar is
three-tone as the reference has it.

And the objective is asked for. It decides which questions exist, how
relevance is weighted, and what readiness measures against — and it was
possible to sit a whole board paper without ever being asked, because no
objective quietly means the entire bank. That is a reasonable default and
a poor thing to arrive at by accident. Five of six accounts here had
never set one.

It can be declined: "everything" is a real answer, and trapping somebody
behind a modal because a list failed to load would be worse than the gap
it closes. Declining is still a choice made, which is the point.

Also in this commit, from the exam-player work: Show answer in study mode
that reveals without recording an answer, review keyed on the attempt
being closed rather than every question being answered — a block that
timed out with nothing answered is over too — and the exam top and bottom
bars. That work found something worth knowing: the exam player is *served*
questions with no correct answer and no explanation, so review cannot
un-hide what it never had, and the player refetches the marked version
once the attempt closes. Nothing is revealed while a block is running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 06:24:23 +02:00

210 lines
6.1 KiB
YAML

services:
postgres:
image: pgvector/pgvector:pg16
restart: unless-stopped
environment:
POSTGRES_DB: pedquiz
POSTGRES_USER: pedquiz
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pedquiz"]
interval: 5s
timeout: 5s
retries: 10
frontend:
build: ./frontend
env_file:
- ./frontend/.env
ports:
- "127.0.0.1:8081:80"
depends_on:
- backend
restart: unless-stopped
backend:
build: ./backend
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
env_file:
- ./backend/.env
environment:
- ANONYMIZED_TELEMETRY=False
- LOG_LEVEL=${LOG_LEVEL:-INFO}
volumes:
- uploads_data:/app/uploads
- chroma_data:/app/chroma_data
networks:
- default
- danvics_speech
- danvics_milvus
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
celery:
build: ./backend
# --beat runs the embedded scheduler (single worker, so no lock needed).
command: celery -A app.tasks worker --beat --loglevel=${LOG_LEVEL:-info} --concurrency=2
env_file:
- ./backend/.env
environment:
- ANONYMIZED_TELEMETRY=False
- LOG_LEVEL=${LOG_LEVEL:-INFO}
volumes:
- uploads_data:/app/uploads
- chroma_data:/app/chroma_data
networks:
- default
- danvics_milvus
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
# Self-hosted CAPTCHA. Proof-of-work rather than a puzzle, and — the reason
# it is here rather than hCaptcha — it asks nothing of a third party about
# the person signing up. Its own Redis database, kept apart from the app's
# so a flush of one cannot clear the other's challenges.
cap:
image: tiago2/cap:latest
ports:
# Its own host, so other sites on this machine can use it too — which is
# the point of self-hosting it rather than pathing it under one app.
- "127.0.0.1:8093:3000"
environment:
ADMIN_KEY: ${CAP_ADMIN_KEY}
REDIS_URL: redis://redis:6379/3
CORS_ORIGIN: ${CAP_CORS_ORIGIN:-https://pedshub.com}
SERVER_PORT: 3000
depends_on:
- redis
restart: unless-stopped
quiz-telegram-bot:
build: ./telegram-bot
env_file:
- ./telegram-bot/.env
environment:
- DATABASE_URL=postgresql://pedquiz:${POSTGRES_PASSWORD}@postgres:5432/pedquiz
- PUBLIC_APP_URL=${APP_URL:-https://pedshub.com}
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
# ── Logging: Loki + Promtail + Grafana ──────────────────────────────
loki:
image: grafana/loki:3.3.2
restart: unless-stopped
command: -config.file=/etc/loki/loki-config.yml
volumes:
- ./loki/loki-config.yml:/etc/loki/loki-config.yml:ro
- loki_data:/loki
ports:
- "127.0.0.1:3100:3100"
promtail:
image: grafana/promtail:3.3.2
restart: unless-stopped
command: -config.file=/etc/promtail/promtail-config.yml
volumes:
- ./promtail/promtail-config.yml:/etc/promtail/promtail-config.yml:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- promtail_positions:/positions
depends_on:
- loki
grafana:
image: grafana/grafana:10.3.1
restart: unless-stopped
environment:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD}
GF_AUTH_ANONYMOUS_ENABLED: "false"
volumes:
- grafana_data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
ports:
- "127.0.0.1:3002:3000"
depends_on:
- loki
db-backup:
image: prodrigestivill/postgres-backup-local:16
restart: unless-stopped
environment:
POSTGRES_HOST: postgres
POSTGRES_DB: pedquiz
POSTGRES_USER: pedquiz
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
SCHEDULE: "@daily"
BACKUP_KEEP_DAYS: 14
BACKUP_KEEP_WEEKS: 4
BACKUP_KEEP_MONTHS: 6
HEALTHCHECK_PORT: 8080
volumes:
- ./backups:/backups
depends_on:
postgres:
condition: service_healthy
# Object storage for media. Files this size do not belong in a container
# volume that only one host can mount, and S3 semantics give presigned URLs
# and lifecycle rules that a bind mount cannot.
minio:
image: minio/minio:RELEASE.2024-10-13T13-34-11Z
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-pedshub}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}
volumes:
- minio_data:/data
networks:
default:
# An unambiguous name. The backend also sits on danvics_milvus, which
# has a MinIO of its own called `minio`, and Docker resolved that one
# first — every object read failed with InvalidAccessKeyId while the
# bucket looked simply empty.
aliases:
- quiz-minio
# No host ports: the backend reaches MinIO over the compose network, and
# 9000/9001 are already taken on this host. Publish deliberately if the
# console is ever needed from outside.
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 20s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
uploads_data:
minio_data:
chroma_data:
postgres_data:
redis_data:
loki_data:
grafana_data:
promtail_positions:
networks:
danvics_speech:
external: true
# The clinical library index. Note there are two Milvus servers on this host:
# this is the one holding mcp_bge_m3_1024, reached as `milvus`. The other, on
# ped-ai-storage_basic, is a different instance with different credentials.
danvics_milvus:
external: true