Czech Parliamentary Voting Analyzer — an OSINT tool that downloads, parses, and visualizes open voting data from the Czech Chamber of Deputies. Built with FastAPI, Polars, and HTMX.
- You can find at: https://snemovna.hlidacstatu.cz
- Supports: https://github.com/HlidacStatu
- Vision: https://www.hlidacstatu.cz/texty/vize/
- Legislation Dashboard — legislation-first homepage leading with laws & amendments: a search hero, a "recent legislative activity" feed (bills that moved most recently), and "currently active laws" linked directly to their amendments. Topic chips filter the laws browser, and each law detail embeds the original bill PDF for reading inline
- Daily Refresh — automatically picks up newly passed laws and freshly voted amendments for the current period: re-scrapes and re-analyzes any bill still in progress (terminal bills stay cached)
- Government vs Opposition — every amendment is tagged by who drives the change (government-coalition, opposition, or cross-party submitters); the coalition is auto-derived per period from the chamber's confidence vote, so new periods need no configuration
- Party Loyalty — rebellion rates: how often each MP votes against their party's majority
- Attendance — participation rates with breakdowns (active, passive, absent, excused)
- Voting Similarity — cross-party alliances via cosine similarity + PCA visualization
- Votes Browser — searchable, paginated list of all parliamentary votes with detail views
- Chart Endpoints — server-rendered PNG charts (seaborn/matplotlib)
- Multi-period Support — covers all 10 electoral periods (1993 to present)
- Tisk Pipeline — background processing that downloads parliamentary print PDFs, extracts text, and classifies topics
- AI Summaries — optional LLM-based bilingual (Czech + English) summarization and topic classification via Ollama or any OpenAI-compatible API (OpenAI, Azure, Together, Groq, vLLM)
- i18n — full Czech/English UI localization with a header language switcher
- Feedback — user feedback form on vote detail pages, submitted as GitHub Issues
- Rate Limiting & Security — per-endpoint rate limits (slowapi), CSP/HSTS/Permissions-Policy headers, CSRF protection, and XSS sanitization (nh3)
- Legislative Evolution — bill version diffs, law changes, and related bills discovery
- Laws Browser — searchable list of all parliamentary bills with detail pages showing sponsors, status, and legislative history
- Amendment Voting — third-reading amendment analysis: per-amendment vote results, coalition breakdowns, government/opposition submitter tagging, and AI summaries
- Admin Dashboard — password-protected backend (port 8001) for pipeline management, runtime config, log streaming, and system monitoring
- Docker — containerized deployment with docker-compose
- Documentation — project docs on GitHub
See detailed docs: Routes | Services | Templates | Data Model | Testing & CI/CD
Requires Python >= 3.12 and uv.
# Install dependencies
uv sync
# (Optional) Copy and edit environment variables
cp .env.example .env
# Run the frontend (public web app)
uv run python -m pspcz_analyzer.main_frontend
# (Optional) Run the admin backend on port 8001
uv run python -m pspcz_analyzer.main_backendThe app starts on http://localhost:8000. On first launch it downloads ~50 MB of open data from psp.cz and caches it locally as Parquet files.
All configuration is via environment variables. Copy .env.example to .env for local development — python-dotenv loads it automatically.
| Variable | Default | Description |
|---|---|---|
PSPCZ_CACHE_DIR |
~/.cache/pspcz-analyzer/psp |
Data cache directory |
PSPCZ_DEV |
1 |
Set to 1 for hot reload, 0 for production |
PSPCZ_CACHE_HOST_DIR |
./cache-data |
Docker only: host directory bind-mounted to /data/cache |
PORT |
8000 |
Server port (used by both local dev and Docker) |
LLM_PROVIDER |
ollama |
LLM backend: ollama or openai |
OLLAMA_BASE_URL |
http://localhost:11434 |
Ollama API endpoint |
OLLAMA_API_KEY |
(empty) | Bearer token for remote HTTPS Ollama |
OLLAMA_MODEL |
qwen3:8b |
Model for Ollama inference |
OPENAI_BASE_URL |
https://api.openai.com/v1 |
OpenAI-compatible API endpoint |
OPENAI_API_KEY |
(empty) | API key for OpenAI-compatible backend |
OPENAI_MODEL |
gpt-4o-mini |
Model for OpenAI-compatible inference |
AI_PERIODS_LIMIT |
3 |
Newest periods to process with AI (0 = all) |
TISK_SHORTENER |
0 |
Truncate tisk text for LLM (0 = full text) |
DAILY_REFRESH_ENABLED |
1 |
Enable daily re-download of psp.cz data |
DAILY_REFRESH_HOUR |
3 |
Hour (CET, 0-23) for daily data refresh |
GITHUB_FEEDBACK_ENABLED |
0 |
Enable user feedback via GitHub Issues |
GITHUB_FEEDBACK_TOKEN |
(empty) | GitHub PAT with public_repo scope |
GITHUB_FEEDBACK_REPO |
tadeasf/pspcz_analyzer |
Repository for feedback issues |
GITHUB_FEEDBACK_LABELS |
user-feedback |
Labels applied to feedback issues |
LLM_STRUCTURED_OUTPUT |
1 |
JSON schema structured output (0 = free-text regex fallback) |
LLM_EMPTY_RETRIES |
2 |
Extra LLM attempts on empty/unparseable free-text results |
ADMIN_PORT |
8001 |
Port for the admin backend server |
ADMIN_USERNAME |
admin |
Admin dashboard login username |
ADMIN_PASSWORD_HASH |
(empty) | bcrypt hash of the admin password |
ADMIN_SESSION_SECRET |
(auto-generated) | HMAC secret for signing admin session cookies |
ADMIN_ALLOWED_IPS |
127.0.0.1,::1 |
IP/CIDR whitelist for admin access |
ADMIN_TRUSTED_PROXIES |
127.0.0.1,::1 |
Proxies whose X-Forwarded-For may identify the real client |
ADMIN_COOKIE_SECURE |
1 (off in dev mode) |
Send the admin session cookie only over HTTPS |
ADMIN_BIND_ADDR |
127.0.0.1 |
Docker only: host address the admin port is bound to |
RATE_LIMIT_TRUSTED_PROXIES |
(empty) | Proxies whose X-Forwarded-For may key rate-limit buckets |
STENO_NEGATIVE_CACHE_TTL |
604800 (7 days) |
Seconds a steno-page 404 stays negatively cached |
COMPUTE_POOL_WORKERS |
0 (Python default) |
Analysis thread-pool size (0 = min(32, cpu_count + 4)) |
ANALYSIS_CACHE_MAX_ENTRIES |
512 |
Max entries in the analysis cache (LRU eviction) |
# Copy .env and configure your LLM connection
cp .env.example .env
# Edit .env to set LLM_PROVIDER and the matching provider variables
# Build and start
docker compose up --buildThe app is available at http://localhost:8000 (or the port set by PORT). Data cache is persisted via a bind mount at ./cache-data/ — set PSPCZ_CACHE_HOST_DIR in .env to place it on any disk (useful when the system disk is small). Both containers share the mount with the SELinux :z label, so it works on Fedora out of the box. Containers run as root, so cached files are root-owned on the host (sudo chown -R $USER ./cache-data if you need host-side access). The LLM runs separately — configure the connection via OLLAMA_BASE_URL (for Ollama) or OPENAI_BASE_URL + OPENAI_API_KEY (for OpenAI-compatible APIs) in .env.
To use a custom port:
PORT=9000 docker compose up --buildyourdomain.cz {
reverse_proxy localhost:8000
}
server {
server_name yourdomain.cz;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}# Install with dev tools (pytest, ruff, pyright, pre-commit)
uv sync --extra dev
# Run unit + API tests
uv run pytest -m "not integration" --cov
# Run integration tests (downloads from real psp.cz)
uv run pytest -m integration -v
# Lint and format
uv run ruff check . && uv run ruff format .
# Install pre-commit hooks
uv run pre-commit installSee Testing & CI/CD for full details on the test suite, CI pipelines, and contributing guidelines.
To build a fully-enriched cache (tisk FULL + amendment FULL) for a set of periods and pack it into a single archive for deployment, run:
# Default: periods 10 9 8 7 6 5 → ./pspcz-cache-<timestamp>.tar.gz
uv run python scripts/build_and_backup_cache.py
# Subset / no archive / resume without re-downloading
uv run python scripts/build_and_backup_cache.py --periods 10 9
uv run python scripts/build_and_backup_cache.py --no-backup
uv run python scripts/build_and_backup_cache.py --skip-downloadThe archive stores paths relative to the cache dir, so on the production
host it extracts straight over PSPCZ_CACHE_DIR (stop the app first). To
ship it over a slow or flaky uplink, use the resumable, checksum-verifying
upload wrapper:
./scripts/upload_cache.sh # newest archive → SSH host "master"
./scripts/upload_cache.sh --dry-run # preflight checks only
./scripts/upload_cache.sh host --extract --remote-cache-dir /data/psp-cacheThe script resumes interrupted transfers, retries with backoff, verifies the
remote sha256, and — with --extract — preserves production's
runtime_config.json/pipeline_history.json by default. See the
deployment guide
(docs/deployment.md) for the full workflow, overnight-tmux recipe, state
file semantics, and rollback procedure.
| Layer | Technology |
|---|---|
| Web framework | FastAPI + Uvicorn |
| Templating | Jinja2 + i18n extension |
| Frontend interactivity | HTMX |
| CSS | Pico CSS (institutional light theme) |
| Localization | Dict-based i18n (Czech + English) |
| Data processing | Polars |
| Charts | Seaborn + Matplotlib |
| PDF extraction | PyMuPDF |
| HTML scraping | BeautifulSoup4 |
| LLM integration | Ollama / OpenAI-compatible API (optional, bilingual) |
| Documentation | GitHub + MkDocs |
| HTTP client | httpx |
| Configuration | python-dotenv |
| Testing | pytest + pytest-cov |
| Linting & formatting | Ruff |
| Type checking | Pyright |
| CI/CD | GitHub Actions |
| Containerization | Docker + docker-compose |
| Package manager | uv |
All data comes from the psp.cz open data portal. Files are pipe-delimited UNL format, Windows-1250 encoded. The app downloads and caches them automatically on first access.
Cached data is stored at ~/.cache/pspcz-analyzer/psp/ (raw ZIPs, extracted UNL files, Parquet caches, PDF texts, and topic classifications). Override with PSPCZ_CACHE_DIR.
On startup, the app launches a background pipeline that enriches parliamentary prints (tisky):
- Download PDFs from psp.cz for each print
- Extract plain text using PyMuPDF
- Classify topics using the configured LLM (falls back to keyword matching if the LLM is unavailable)
- Summarize each print in both Czech and English (bilingual AI summaries)
- Scrape legislative process histories from psp.cz HTML pages
- Discover related bills via zakon.cz cross-references
- Track law changes (affected existing laws) from legislative process pages
This data powers the vote detail pages (topic tags, AI summaries, legislative timelines, and tisk transcriptions).
The amendment pipeline enriches third-reading votes with detailed amendment data:
- Identify third-reading vote points from tisk legislative histories
- Download amendment PDFs from psp.cz and parse amendment text
- Scrape stenographic records for spoken-word context
- Merge PDF and steno data, resolve vote IDs and submitters
- Summarize each amendment with bilingual AI summaries
- Analyze coalition voting patterns (who voted together)
This data powers the /amendments pages with per-amendment vote breakdowns and coalition analysis.
A password-protected admin backend runs on a separate port (default 8001):
- Pipeline Management — start/stop/monitor tisk and amendment pipelines per period
- Runtime Config — edit LLM provider, model, and processing settings without restart
- Log Streaming — real-time SSE-based log viewer
- System Status — cache size, disk space, loaded periods, pipeline history
- Authentication — bcrypt password + IP whitelist + session cookies + rate-limited login (5 attempts/minute)
| Document | Contents |
|---|---|
| Routes | All HTTP endpoints — pages, API partials, chart images, laws/amendments, admin routes |
| Services | Data pipeline, analysis services, tisk pipeline, amendment pipeline, law service, LLM integration, admin |
| Templates | Frontend structure, HTMX patterns, i18n, vote detail, laws, amendments, admin templates |
| Data Model | Electoral periods, UNL format, table schemas, vote codes, tisk data, amendment data, configuration |
| Testing & CI/CD | Test suite structure, fixtures, linting config, GitHub Actions workflows, contributing |
This project is licensed under the MIT License.
Educational / OSINT project. Parliamentary source data is public domain per Czech law and is not covered by this license — it remains in the public domain.