FastAPI + HTMX web app that orchestrates the per-parliament tooling
(currently DE) — queues and runs download/parse/merge/NEL/alignment/NER jobs,
schedules recurring runs, streams logs, and publishes results to the
OpenParliamentTV-Data-* repos.
The legacy optv bash script lives alongside and is unchanged.
- Conductor (this repo) — FastAPI app at
src/, runs in Docker. - Tools — cloned into
data/OpenParliamentTV-Tools. Each job spawnspython -m optv.parliaments.<id>.workflow ...as a fresh subprocess withPYTHONPATH=/data/OpenParliamentTV-Tools, so Tools changes pulled before the job take effect immediately. Per-parliament metadata (name, language, periods, supported stages, official entity-dump URL) lives inoptv/parliaments/<id>/manifest.yamlinside the Tools repo. - Data repos — cloned into
data/OpenParliamentTV-Data-<ID>per theparliaments.yamlconfig. Push uses a dedicated SSH key generated bysetup.shatconfig/ssh/id_ed25519, installed as a deploy key (write access) on each Data repo.
./scripts/setup.sh
setup.sh is idempotent. It:
- Clones the Tools repo into
data/(skipped if present). - Copies
config/*.sample→config/*.yaml(skipped per-file if present). - Generates
JWT_SECRETif empty. - Generates a dedicated SSH key at
config/ssh/id_ed25519if missing (no passphrase — required for non-interactive container use). - Iterates over enabled parliaments in
config/parliaments.yaml, clones eachgit_remoteintodata/OpenParliamentTV-Data-<ID>, and downloads the per-parliament entity dump (URL from the Tools manifest) if applicable. - Prints the public key + GitHub deploy-key URLs at the end.
Then:
- Add the printed public key as a deploy key with write access on each
Data repo (URLs printed by
setup.sh). Re-runsetup.shif any clones failed the first time. - Edit
config/secrets.env:GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET,BASE_URL,GIT_USER_NAME,GIT_USER_EMAIL, optionallySLACK_WEBHOOK_URL.JWT_SECRETis already filled by step 3. - Edit
config/users.yaml: add at least one GitHub username with roleadmin. The manager refuses to start otherwise.
Adding a new parliament after install: ship a manifest.yaml + workflow code
in the Tools repo, add a git_remote + stages block to
config/parliaments.yaml, re-run setup.sh (clones the new Data repo +
downloads its entity dump), then docker compose restart.
docker compose up -d # dev: plain HTTP on :8000
docker compose --profile production up -d # + nginx TLS on :443
docker compose --profile ner up -d # + entity-fishing on :8090
Health check: curl http://localhost:8000/health
python3.13 -m venv .venv
.venv/bin/pip install -r requirements.txt
PYTHONPATH=data/OpenParliamentTV-Tools .venv/bin/uvicorn src.main:app --reload
.venv/bin/pytest -q
src/
api/ # JSON API routes (/api/*)
auth/ # GitHub OAuth + JWT + RBAC
services/ # job manager, scheduler, status tracker, log streamer, notifier
workflow/ # adapter that runs Tools stages with progress + cancellation
web/pages.py # HTMX page + fragment routes
templates/ # Jinja2 + Tailwind CDN + HTMX + Alpine
static/
config/ # *.yaml + secrets.env (gitignored, see *.sample)
status/ # file-backed queue, current job, history, log files (gitignored)
data/ # cloned Tools + Data-* repos (gitignored)
scripts/ # setup.sh, backup.sh, compat_check.sh
nginx/ # reverse proxy config + ssl/ (mount real certs here for prod)
# config/*.conf — drop-in server-block snippets (gitignored)
tests/ # pytest
optv # legacy bash script — untouched
- Backup:
./scripts/backup.shtarsconfig/(without secrets) andstatus/intobackups/optv-backup-<utc>.tar.gz. - Compatibility check:
./scripts/compat_check.sh <session-id>runs the legacy path + a manager job over the same session and diffsprocessed/. Expects the manager to be reachable at$BASE_URLwith$COMPAT_COOKIEset to a validoptv_token. - Logs: live tail at
/jobs/<id>(WebSocket), persisted understatus/logs/jobs/<id>.log. - Schedules: edit
config/schedules.yaml— changes hot-reload within seconds viawatchfiles. The UI at/schedulesalso toggles/triggers. - Custom nginx config (production profile only): drop
*.conffiles intonginx/config/and they'reincluded into the nginxserverblock (the dir is bind-mounted read-only into the nginx container). Use this for extralocationblocks, headers, redirects, etc. without touching the trackednginx/nginx.conf. The whole directory is gitignored (only.gitkeepis tracked), so your overrides stay local. Reload withdocker compose --profile production restart nginxafter editing.
- Startup fails with "No parliaments configured" — copy the samples in
config/and fill them in.parliaments.yaml+users.yamlare required. - "User '…' is not in users.yaml allow-list" — add the GitHub login (the
URL
login, not email) toconfig/users.yamlwith a role. - GitHub OAuth callback mismatch — set the GitHub OAuth app's callback
URL to
<BASE_URL>/auth/callback;BASE_URLis configured insecrets.env. - Jobs stuck in queue — check
docker logs openparliamenttv-conductor; the worker runs inside the app container and polls every 1s. - Slack not firing — flip
slack.enabled: trueinconfig/notifications.yamland confirmSLACK_WEBHOOK_URLis set. git push/git pullfails from container — by default the container mountsconfig/ssh/(created bysetup.sh). The public key (config/ssh/id_ed25519.pub) must be installed as a deploy key with write access on each Data repo. Override withSSH_KEY_DIR=~/.sshinconfig/secrets.envif you want the legacy behavior.git commitfails with "Please tell me who you are" — setGIT_USER_NAMEandGIT_USER_EMAILinconfig/secrets.env. They're threaded intogit -c user.name=... -c user.email=...per commit.
- All secrets live in
config/secrets.env, which is gitignored. -
JWT_SECRET≥ 32 chars; rotated by restarting the container. - Auth cookie is
httpOnly,SameSite=Lax,SecurewhenBASE_URLis HTTPS. - RBAC enforced in every
/api/*handler viarequire_role(...). - Page routes redirect unauthenticated requests to
/logininstead of leaking 401 pages. - Nginx profile terminates TLS; app binds to
:8000inside the container only. -
config/is mounted read-only inside the container.