Politrack: track Austrian politicians from public sources

Initial import of Politrack, a self-hosted service that tracks Austrian
politicians from federal to municipal level - roles, votes, money flows,
statements, meetings, social media and legal proceedings - with every claim
linked to its sources.

- backend: FastAPI + Postgres/pgvector; adapter framework (fetch -> parse ->
  load -> enrich) with robots.txt/TDM-aware fetching, a raw-response archive
  for reparsing, and a claim/evidence model with corroboration
- sources: Parliament, Wikidata, EU Parliament/Commission, Council of
  Ministers, Rechnungshof party reports and donations, procurement, RTR media
  transparency, political ad libraries, WKStA, news feeds, Wikipedia edits,
  Bluesky, Mastodon, public Telegram channels, YouTube and X (official API)
- web: Next.js frontend in German and English with typed API client
- mcp: MCP server for AI agents and RAG
- docs: architecture, data model, sources, legal notes, Unraid deployment and
  contributor guides; Docker Compose setup for Unraid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
simonandClaude Opus 5.5 committed 2026-10-28 20:02:01 +01:00
commit 583ccba31c
187 files changed
+39890

No files matched your search

+291
View File
@@ -0,0 +1,291 @@
# Writing a source adapter
An **adapter** turns one publisher's data into typed records. It never touches HTTP or the database
directly: the framework fetches (politely, legally, archived) and the Loader writes. Your job is two
functions, `start()` and `parse()`, and a description of the publisher (`SourceSpec`).
```
start(ctx) -> Request ... entry points (list pages, feeds, API queries)
│ Fetcher: robots.txt, throttle, TDM check, conditional GET, retries, raw archive
▼
parse(ctx, resp) -> Record | Request ... PURE: no DB, no network, no clock-dependent output
│ Loader: entity resolution, claim identity, evidence, facets, corroboration status
▼
PostgreSQL
```
Read [architecture/01-design-from-scratch.md](../architecture/01-design-from-scratch.md) §4 for the
reasoning; this page is the how-to.
## 0. Before writing code: the legal checklist
Answer these in the pull request (they end up in `SourceSpec.tos_notes`):
1. **Is there an official API, bulk download or feed?** Use it instead of scraping HTML.
2. **robots.txt** – the Fetcher enforces it. Never work around it. If a documented public API is
listed in robots.txt only because of generic crawler rules (Wikidata SPARQL, the MediaWiki API), add
the API prefix to `robots_exempt` and cite the operator's API policy in `tos_notes`.
3. **Terms of service / licence** – may automated access happen? May we keep full text? If not,
`store_fulltext=False` (only title, short excerpt and link are kept) and usually `keep_raw=False`.
4. **TDM reservation** (§42h UrhG, W3C TDMRep) – detected automatically per origin. If set, no text and no
raw archive are kept, whatever the spec says.
5. **Personal data** – only data about people in their public role. There is no address field in any
record type; don't smuggle one into `data`.
6. **No circumvention** – no paywall tricks, no mirrors/proxies of platforms whose ToS forbid scraping
(e.g. Nitter for X), no logged-in sessions, no private groups.
If any answer is "no" or "unclear", document the source in [docs/sources.md](../sources.md) under
"Approaches we deliberately do not use" instead.
## 1. Where things go
```
backend/politrack/sources/
my_source.py one module per publisher (or a package for large ones, see parlament/)
backend/tests/fixtures/<adapter name>/
<case>.request.json captured request (url, kind, meta)
<case>.html|json|xml|bin captured response body
<case>.expected.json golden output of parse()
```
Modules under `politrack/sources` and `politrack/enrich` are imported automatically; `@register` is all
it takes. External packages can add adapters through the `politrack.adapters` entry-point group (see
`backend/pyproject.toml`).
## 2. A complete example
A (fictitious) state parliament publishes its members as JSON and a page per member:
```python
"""Landtag Musterland: members (JSON API) and their declared side jobs (HTML)."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
from politrack.core import Adapter, Request, Response, RunContext, SourceSpec, register
from politrack.core.records import Claim, Evidence, OrgRef, PersonRecord, PersonRef, Role
from politrack.text import excerpt
BASE = "https://www.landtag-musterland.at"
SPEC = SourceSpec(
slug="landtag-musterland", # stable; referenced by Evidence.source and user filters
name="Landtag Musterland",
kind="official", # official | ngo | media | social | aggregator | user | derived
homepage=BASE,
license="Amtliches Werk (§7 UrhG)",
access="api", # api | bulk | rss | scrape | manual | derived
trust=90, # default trust 0-100 (users can override)
min_interval=2.0, # seconds between requests to this host
tos_notes="Open JSON API, no terms restricting reuse; robots.txt allows /api/.",
)
@register
class MusterlandMembers(Adapter):
spec = SPEC
name = "musterland.members" # <source>.<dataset>; also the CLI/worker task name
description = "Members of the Landtag Musterland with mandates and side jobs"
schedule = "15 4 * * *" # cron (UTC); None = manual only
queue = "scrape" # scrape | social | bulk
creates_persons = True # authoritative member list: may create persons
parser_version = 1 # bump when parse() output changes, then `politrack reparse`
def start(self, ctx: RunContext) -> Iterator[Request]:
yield Request(url=f"{BASE}/api/members", params={"period": ctx.params.get("period", "current")},
kind="list")
def parse(self, ctx: RunContext, response: Response) -> Iterator[Any]:
if response.request.kind == "list":
for m in response.json()["members"]:
ref = PersonRef(ids={"landtag-musterland": str(m["id"])}, name=m["name"])
yield PersonRecord(ref=ref, full_name=m["name"], photo_url=m.get("photo"))
yield Claim(
claim_type="role", person=ref,
summary_de=f"{m['name']} ist Abgeordnete:r zum Landtag Musterland ({m['party']})",
summary_en=f"{m['name']} is a member of the Musterland state parliament ({m['party']})",
valid_from=m.get("since"),
evidence=[Evidence(url=f"{BASE}/abgeordnete/{m['id']}", doc_type="api_record",
title=m["name"])],
facet=Role(role="mp", level="state", region="Musterland",
body=OrgRef(slug="landtag-musterland", name="Landtag Musterland", kind="parliament"),
party=OrgRef(name=m["party"], kind="party", create=False),
start=m.get("since")),
)
# follow-up request; meta travels with it to parse()
yield Request(url=f"{BASE}/abgeordnete/{m['id']}", kind="profile",
meta={"member_id": str(m["id"]), "name": m["name"]})
return
# kind == "profile": HTML page with a side-job table
for row in response.soup.select("table.nebentaetigkeiten tr")[1:]:
employer, function = (td.get_text(" ", strip=True) for td in row.find_all("td")[:2])
yield Claim(
claim_type="side_job",
person=PersonRef(ids={"landtag-musterland": response.meta["member_id"]}),
counterpart_org=OrgRef(name=employer, kind="company"),
key=("musterland", response.meta["member_id"], employer, function), # no facet -> explicit key
summary_de=f"{response.meta['name']}: {function} bei {employer}",
evidence=[Evidence(url=response.url, doc_type="register_entry", quote=excerpt(row.get_text(" "), 300))],
)
```
Run it:
```bash
docker compose exec api politrack sources sync # registers SPEC in the DB
docker compose exec api politrack run musterland.members --max-requests 20 -p period=current
docker compose exec api politrack status # run stats, errors
```
The periodic task appears automatically for the workers (`schedule`, `queue`).
## 3. The building blocks
### Request
| field | purpose |
|---|---|
| `url`, `method`, `params`, `json`, `headers` | the HTTP request. Secrets go in `params` named `key`/`token`/`api_key`/`access_token`, or in `headers` – both are redacted from the archive and logs. |
| `kind` | dispatch key for `parse()` |
| `meta` | context for `parse()` (ids, names, depth); not part of the request identity |
| `conditional` | send `If-None-Match`/`If-Modified-Since` from the archive (default on) |
| `skip_unchanged` | when body hash and `parser_version` are unchanged, don't reload records – only follow-up requests are collected, so an interrupted run resumes (default on) |
A request's identity (`fingerprint`) is method + url + params + json. The runner de-duplicates within a run.
### Response
`response.text`, `response.json()`, `response.soup` (BeautifulSoup, `html.parser`), `response.meta`
(= `request.meta`), `response.url` (final URL, secrets redacted), `response.tdm_reserved`.
### RunContext
`ctx.params` (CLI `-p key=value`, worker parameters), `ctx.state` (persistent cursors, see below),
`ctx.stats` (a Counter shown in `politrack status` – count what you skip and why), `ctx.log`.
`ctx.conn` is available in `start()` only (e.g. to read which social accounts to poll); **`parse()` must
not query the database** – that is what keeps it replayable and testable.
### Records
All in `politrack/core/records.py`:
| record | use for |
|---|---|
| `PersonRecord`, `OrgRecord` | entity data from authoritative sources (names, photo, dates) |
| `SocialAccount` | an account of a person/organisation (`verified=True` only if an official page links it) |
| `VoteEvent`, `Event` | things with a key that claims point at (votes, sessions, trips) |
| `Claim` (+ `facet`) | every fact. Facets: `Role`, `VotePosition`, `Money`, `Participation`, `Statement`, `Legal` |
| `Document` | a document without a claim yet (headlines, press releases) – enrichers derive claims later |
Claim types: `role vote donation money_flow statement appearance meeting travel side_job legal_proceeding
lobbying media_mention career parliamentary_question government_decision wiki_edit declaration ownership
affiliation party_finance other` (see [data-model.md](../data-model.md)).
### Referring to people and organisations
Never look up ids yourself. Describe what you know and let the Loader resolve it:
```python
PersonRef(ids={"parlament": "2339"}) # strong identifier (best)
PersonRef(ids={"wikidata": "Q78869"}, name="Alexander Van der Bellen")
PersonRef(name="Leonore Gewessler") # exact alias match (titles stripped)
PersonRef(hints={"surname_in_body": "nationalrat", "on": date(2024, 3, 1)}, name="Kickl") # roll calls
PersonRef(hints={"office": "Bundesministerin für Justiz", "on": date(2025, 6, 17)}) # office holder
OrgRef(slug="oevp") # seeded organisations
OrgRef(ids={"firmenbuch": "123456a"}, name="Muster GmbH", kind="company") # created if unknown
OrgRef(name="ÖVP", kind="party", create=False) # match only, never create
```
Identifier schemes: `parlament`, `wikidata`, `ep`, `uid`, `firmenbuch`, `rtr`, `eu_transparency`,
`google_advertiser`, `facebook_page` – add a new scheme by simply using it (document it in data-model.md).
Persons are only created by adapters with `creates_persons = True` (authoritative member lists); everyone
else's claims about unknown people are skipped and counted as `unresolved_person`.
Organisation names are matched case-, accent- and legal-form-insensitively (`normalize_org_name`), so
"HYPO TIROL BANK AG" and "Hypo Tirol Bank Aktiengesellschaft" become one organisation.
### Claim identity – facet or `key`
The Loader computes a claim's identity from *resolved* entities and the facet: a donation is
(kind, payer, payee, amount, date, period, reference), a vote position is (vote, person/party), a role is
(person, body, role, start), a statement is (person/org, url), a participation is (event, person/org), a
media mention is (url, person/org). So the same fact reported by two sources becomes **one claim with two
pieces of evidence** – that is how corroboration works.
Claims **without a facet** (side jobs, careers, declarations, holdings, questions …) need an explicit
`key` – the Loader refuses them otherwise. Build it from what identifies the fact in the real world, not
from your source's internals, so that another source could produce the same key:
`key=("ministerrat", url, file_number, office)`, `key=("side_job", member_id, employer, function)`.
A key always overrides the facet-based identity.
### Summaries
`summary_de` is required and shown to users; `summary_en` is optional but appreciated. Write neutral,
factual one-liners ("X erhielt 2024 eine Spende von Y über 5.000 €"). Use `politrack.text.format_eur_de`
for German amounts and `excerpt()` for quotes.
### Sensitive claims
Anything about criminal or legal matters: `claim_type="legal_proceeding"`, `sensitive=True`,
`review=True` unless the source is an official court/parliament record. The UI then shows the
presumption-of-innocence notice and the claim waits for human review.
## 4. Incremental adapters and cursors
For large or append-only sources, keep a cursor:
```python
def start(self, ctx):
since = ctx.params.get("since") or ctx.state.get("last_date") or "2024-01-01"
yield Request(url=..., params={"from": since}, kind="page")
def parse(self, ctx, response):
...
ctx.state.set("last_date", newest_date_seen) # the only "write" parse() may do
```
`ctx.state` is persisted per adapter in the `state` table (and kept in memory during tests).
## 5. Multi-publisher adapters
Feeds of many outlets, or an aggregator that points at original publishers: define one `SourceSpec` per
publisher (register extra specs with `register_spec`) and put `meta={"source": "<slug>"}` on each
request – the Fetcher then applies *that* publisher's policy (store_fulltext, throttle) and evidence is
attributed to it. See `sources/news.py`.
## 6. Tests (required)
1. Capture a fixture from the live source (one request, honouring robots.txt):
```bash
politrack fixture musterland.members "https://www.landtag-musterland.at/api/members?period=current" list_current --kind list
```
2. Generate and **review** the golden output:
```bash
UPDATE_GOLDEN=1 pytest tests/test_golden.py -k musterland
git diff tests/fixtures/musterland.members/
```
3. Unit-test tricky helpers (regexes, date formats) in `tests/test_parsers.py`.
Keep fixtures small (cut lists to a few entries) and free of personal data beyond public roles.
## 7. Changing an existing parser
1. Change `parse()`, bump `parser_version`.
2. `UPDATE_GOLDEN=1 pytest` and review the diff.
3. After deployment: `politrack reparse <adapter>` replays the raw archive – no refetching. At the end of a
complete, error-free reparse, the adapter's claims that the new parser no longer produces are deleted
(unless another source backs them), so a fix that changes a claim's identity leaves no stale duplicate.
Two adapters sharing one `SourceSpec` should not emit the same claims, or a reparse of one removes the
other's until its next run.
## 8. Checklist for the pull request
- [ ] `SourceSpec` with honest `kind`, `trust`, `license`, `tos_notes` (legal checklist answered)
- [ ] `parse()` is pure; counts skipped rows in `ctx.stats`
- [ ] strong identifiers wherever the source has them
- [ ] golden fixture + expected output, helper unit tests
- [ ] entry in [docs/sources.md](../sources.md)
- [ ] new claim types / identifier schemes documented in [data-model.md](../data-model.md) and the
frontend (`ClaimDetails.tsx`, messages)
+45
View File
@@ -0,0 +1,45 @@
# Extending the API (and the MCP server)
Data flows out through three layers that are kept deliberately thin:
```
politrack/queries/<domain>.py SQL, returns dicts (shared by REST API and MCP server)
politrack/api/schemas.py Pydantic response models (the contract → OpenAPI → TypeScript)
politrack/api/main.py FastAPI endpoints (parameters, 404s, response_model)
politrack/mcp_server.py MCP tools for AI agents (same query functions)
```
## Adding an endpoint, step by step
1. **Query** – add a function to the matching module in `politrack/queries/` (people, organizations,
claims, votes, money, search, admin) and export it from `queries/__init__.py`.
* Only show visible claims: use `VISIBLE` (published, not merged) from `queries/common.py`.
* Respect the user's source filter: take a `SourceFilter` and use `evidence_json(source_sql)` to
aggregate evidence – claims whose evidence is entirely filtered out must disappear.
* Always parameterise (`%(name)s`), never format user input into SQL. `LIMIT/OFFSET` on lists.
2. **Response model** – add a class to `api/schemas.py` (inherit from `Model`). Field names = column names.
3. **Endpoint** – in `api/main.py`, with `response_model=...`. Reuse the dependencies `DB`, `Sources`,
`Limit`, `Offset`; comma-separated list parameters go through `csv_list()`.
4. **Types for the web app**:
```bash
cd backend && politrack openapi --ts ../web/src/lib/api-types.ts
```
Then add a typed function to `web/src/lib/api.ts`. Commit the regenerated `api-types.ts`.
5. **MCP tool** (if agents should use it) – a `@mcp.tool()` in `mcp_server.py` with a docstring that tells
the agent what the data means and how to cite it.
6. **Test** – extend the API smoke test (see [testing.md](testing.md)).
## Conventions
* URLs: `/api/v1/<plural noun>[/<slug>[/<sub-resource>]]`; slugs, never internal ids, for people,
parties and organisations (vote events use ids – they have no natural slug).
* Every claim-like object carries `evidence: [{url, title, source, published_at, quote, …}]` and
`source_count`.
* Legal matters always include `legal_stage`; the client decides about the presumption-of-innocence
notice (`needsInnocenceNotice` in the web app, the `instructions` in the MCP server).
* Admin endpoints require `Authorization: Bearer $POLITRACK_ADMIN_TOKEN` and are excluded from the schema.
## Read-only database access for agents and BI tools
The role `politrack_reader` (password `POLITRACK_READER_PASSWORD`) can `SELECT` everything. Useful views:
`claim_with_evidence`, `person_status`, `person_identifier`. See [../data-model.md](../data-model.md).
+74
View File
@@ -0,0 +1,74 @@
# Writing an enricher
An **enricher** derives new facts from data that is already in the database: linking headlines to
people, detecting trips and meetings, classifying legal matters, tagging topics, corroborating claims,
computing embeddings. Adapters *collect*; enrichers *connect*.
```python
from collections.abc import Iterator
from politrack.core import Enricher, RunContext, register
from politrack.core.records import Claim, Evidence, PersonRef
from politrack.core.spec import DERIVED
@register
class AmbassadorAppointments(Enricher):
spec = DERIVED # derived claims are attributed to "politrack" + the original evidence
name = "enrich.ambassadors"
description = "Ambassador appointments mentioned in Council of Ministers minutes"
schedule = "20 6 * * *" # cron (UTC); runs on the `enrich` queue
def run(self, ctx: RunContext) -> Iterator[Claim]:
rows = ctx.conn.execute(
"""SELECT d.id, d.url, d.title, d.published_at, d.full_text FROM document d
JOIN source s ON s.id = d.source_id
WHERE s.slug = 'bundesregierung' AND d.full_text ILIKE '%%Botschafter%%'
AND NOT (d.meta ? 'ambassadors_at')"""
).fetchall()
for doc in rows:
for name in find_appointees(doc["full_text"]): # a pure, unit-tested helper
yield Claim(
claim_type="career", person=PersonRef(name=name),
summary_de=f"{name} als Botschafter:in vorgeschlagen",
occurred_at=doc["published_at"],
evidence=[Evidence(url=doc["url"], title=doc["title"], source="bundesregierung",
quote=..., doc_type="protocol")],
)
ctx.conn.execute("UPDATE document SET meta = meta || jsonb_build_object('ambassadors_at', now()) "
"WHERE id = %s", (doc["id"],))
ctx.stats["documents"] += 1
```
## Rules
* **Yield records, don't write facts yourself.** Records go through the same Loader as adapter output:
entity resolution, claim identity, evidence, corroboration status. Direct SQL is fine for *housekeeping*
(marking documents as processed, maintenance like closing roles at death – see `enrich/maintenance.py`).
* **Cite the original.** Evidence of a derived claim points at the original document and names its
`source` slug, so the user's source filter and the corroboration logic work on the real publisher.
* **Be incremental.** Remember what you processed (a marker in `document.meta`, or `ctx.state`), so a run
costs proportional to new data.
* **Be conservative with people.** Enrichers never create persons (`creates_persons = False`); unknown
names are skipped and counted (`unresolved_person`). Prefer precision over recall – a missed link is
better than a wrong one.
* **Sensitive output needs review.** Anything about legal matters: `sensitive=True, review=True`.
* **Put logic in pure helpers** (regexes, classifiers) and unit-test them in `tests/test_extraction.py`.
The runner commits every 200 records, so a crash loses little work.
## Existing enrichers
| name | what it does |
|---|---|
| `enrich.linker` | finds politicians (aliases, "Bundeskanzler Stocker" office titles) in headlines/teasers → `media_mention` claims |
| `enrich.events` | trips, summits, bilateral meetings in official news → `Event` + participation, `travel`/`meeting` claims |
| `enrich.legal` | investigation/indictment/verdict vocabulary in headlines → `legal_proceeding` claims (review queue) |
| `enrich.topics` | keyword topics (mirrors Parliament's subject list) → `topic_assignment` |
| `enrich.roles` | gives open-ended offices the holder's party; closes roles at death |
| `enrich.rollcall` | faction majority positions derived from individual roll-call votes |
| `enrich.corroborate` | merges near-duplicate claims, recomputes reported/corroborated/disputed |
| `enrich.embed` | chunks + embeddings for semantic search/RAG (needs Ollama) |
| `config.social_accounts` | imports `config/social_accounts.yaml` (curated accounts, party channels) |
Run them all with `politrack enrich`, or one with `politrack run enrich.linker`.
+81
View File
@@ -0,0 +1,81 @@
# Extending the web app
Next.js 15 (App Router, React Server Components) with next-intl (German default, English). Pages fetch
from the API on the server; there is almost no client-side JavaScript.
```
web/
messages/de.json, en.json all UI text (keys must exist in both)
src/app/[locale]/<route>/page.tsx pages (German route names: /personen, /parteien, /geld …)
src/components/ shared server components
src/lib/api.ts typed API client (server-side only; reads the filter cookie)
src/lib/api-types.ts GENERATED from the backend's response models – do not edit
src/lib/format.ts dates, money, localized names
src/lib/prefs.ts the user's source/topic filter (cookie)
```
## Types come from the backend
After any change to `backend/politrack/api/schemas.py`:
```bash
cd backend && politrack openapi --ts ../web/src/lib/api-types.ts
cd ../web && npm run typecheck
```
`api.ts` re-exports the generated types (`Claim`, `MoneyFlow`, …) and adds one function per endpoint.
`api.claims()`, `api.money()`, `api.social()` … automatically apply the visitor's source filter
(`withPrefs`), so every list respects "which sources do I trust".
## Adding a page
1. Create `src/app/[locale]/<route>/page.tsx` – an async server component:
```tsx
export default async function Page({params, searchParams}: Props) {
const {locale} = await params;
setRequestLocale(locale);
const t = await getTranslations('MyPage');
const rows = await api.something({...});
return <>…</>;
}
```
Filters are plain `<form method="get">` – shareable URLs, no client state.
2. Add the texts to **both** `messages/de.json` and `messages/en.json`.
3. Link it from the navigation (`src/app/[locale]/layout.tsx`) or the home page's "Entdecken" cards.
4. Page modules may only export Next.js page fields (`default`, `generateMetadata`, …); keep helpers and
constants module-local or in `src/components`.
## Showing a new claim type
Claims render through `ClaimList`; type-specific details come from the registry in
`src/components/ClaimDetails.tsx`:
```tsx
const ambassador: Renderer = (claim, {t}) => (
<p className="claim-detail small">{t('country')}: {String(claim.data.country ?? '')}</p>
);
const DETAILS: Partial<Record<ClaimType, Renderer[]>> = {
…,
career: [counterpart, ambassador],
};
```
Also add the label under `Claims.type.<claim_type>` in both message files, and (if it should appear) the
type to the person page's `TABS`.
## Non-negotiables in the UI
* **Every fact shows its sources** (the "Quellen" disclosure under each claim, `[source]` links in
tables). Don't render a claim without its evidence.
* **Presumption of innocence:** `needsInnocenceNotice()` in `ClaimList.tsx` decides; never remove the
notice for anything but an undisputed final conviction.
* **Connections are not accusations:** pages that show networks or money of connected organisations
carry the note (`Network.note`, `Organizations.connectionNote`).
* Unverified social accounts are marked (Wikidata-only handles).
## Styling
One global stylesheet (`src/app/globals.css`) with CSS variables for light/dark mode. Reuse the existing
classes (`claims`, `claim-detail`, `chip`, `tag`, `notice`, `grid-2`, `compact`, `num`, `muted`, `small`)
before adding new ones.
+50
View File
@@ -0,0 +1,50 @@
# Testing
```bash
cd backend
pytest # everything below except the DB smoke test
pytest tests/test_golden.py -k parlament # one adapter's golden tests
UPDATE_GOLDEN=1 pytest tests/test_golden.py # regenerate expected outputs (review the diff!)
```
| suite | what it covers | needs |
|---|---|---|
| `tests/test_core.py` | framework invariants: request identity, secret redaction, registry consistency, record serialisation, OpenAPI→TS generator, shared helpers | nothing |
| `tests/test_parsers.py` | parsing helpers (vote texts, CSV layouts of the Rechnungshof, dates, normalisation) | nothing |
| `tests/test_extraction.py` | roll-call lists, immunity reports, event/legal/office-title detection | nothing |
| `tests/test_golden.py` | every captured fixture replayed through `adapter.parse()` and compared with `<case>.expected.json` | nothing |
Parsers are pure, so none of these needs a database or network.
## Golden fixtures
```
tests/fixtures/<adapter name>/<case>.request.json {url, kind, meta, content_type, body_file}
tests/fixtures/<adapter name>/<case>.<ext> the captured body
tests/fixtures/<adapter name>/<case>.expected.json parse() output (records as JSON)
```
Capture with `politrack fixture <adapter> <url> <case> --kind <kind> --meta '{"…": …}'`. A fixture
without `.expected.json` creates it on the first run and is skipped until you have reviewed it.
When a source changes its format: capture a new case next to the old one (keep the old one if the
publisher still serves old documents in that format), fix the parser, bump `parser_version`.
## Against a real database
The quickest end-to-end check uses the compose stack:
```bash
docker compose up -d db && docker compose run --rm migrate
docker compose run --rm api politrack run parlament.persons --max-requests 20 -p periods='["XXVIII"]'
docker compose run --rm api politrack enrich
docker compose run --rm api politrack status
curl -s localhost:8000/api/v1/stats
```
`--max-requests` keeps test runs polite. Each run is recorded in the `run` table (stats, errors) and
shown by `politrack status` and `/api/v1/admin/runs`.
## Frontend
`npm run typecheck` (strict TypeScript against the generated API types) and `npm run build`.