- Python 73.5%
- JavaScript 24.8%
- Makefile 0.9%
- Dockerfile 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .agents/skills | ||
| .github/workflows | ||
| data | ||
| deploy | ||
| docker | ||
| evals/cases | ||
| src | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .python-version | ||
| CLAUDE.md | ||
| CONTEXT.md | ||
| docker-compose.dev.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| LICENSE | ||
| Makefile | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
Datenkatalog Attribute Extractor
Deploy on your server (PDF extraction)
CI publishes the image ghcr.io/dcc-bs/datenkatalog-attribute-extractor to GHCR whenever main
passes its checks. This deployment covers PDF forms only: it runs the API and the Streamlit
UI and talks to an LLM you already operate. It starts no model server and no browser, so the
Web-Formular option in the UI does not work here.
You need Docker with the compose plugin and an OpenAI-compatible endpoint that serves a vision-capable model (the project is built for Gemma 4 on vLLM).
mkdir datenkatalog-extractor && cd datenkatalog-extractor
curl -O https://raw.githubusercontent.com/DCC-BS/datenkatalog-attribute-extractor/main/deploy/docker-compose.yml
curl -o .env https://raw.githubusercontent.com/DCC-BS/datenkatalog-attribute-extractor/main/deploy/.env.example
# edit .env: set the three LLM_* values
docker compose up -d
The UI is then on port 8501. If the package is private, run docker login ghcr.io first.
| Variable | Meaning |
|---|---|
LLM_URL |
Base URL of the OpenAI-compatible API, including /v1, no trailing slash |
LLM_MODEL |
Model name as listed under GET {LLM_URL}/models |
LLM_API_KEY |
Bearer token for the API (default not-needed, which a local vLLM ignores) |
Optional: PORT (host port of the UI) and IMAGE_TAG (default latest; the image is also tagged
with the version from pyproject.toml). Everything else uses the defaults of the image. The
container does not start the API unless LLM_URL and LLM_MODEL are set, and an extraction fails
with a 503 up front if the LLM is unreachable or does not serve that model.
Put a reverse proxy with TLS and authentication in front of port 8501; the app has none of its own.
Update with docker compose pull && docker compose up -d.
Extracts the input fields of questionnaires and forms as a list of uniquely named attributes for the Datenkatalog. A PDF is rendered page by page to an image and read by a locally hosted Gemma 4 vision model (vLLM, OpenAI-compatible); an online form is rendered in a browser, walked through its steps, and read from pictures of it, a few consecutive screens per call. A reviewer then checks and corrects the proposed names in a Streamlit UI.
Only the field label is extracted — no data types, no answer options, no mandatory flags. A checkbox or radio group is therefore a single field, named after its group heading.
How it works
PDF ──► pypdfium2 ──► one PNG per page ──────────────┐
├─► Gemma 4 (vLLM) ──► labels + context
URL ──► browser ──► step 1..n ──► screens, 4 per call ┘ ▲ │
(fill required, press Weiter) labels of those screens │
as a spelling reference ▼
ensure_unique_names() ──► FormField[]
Both sources converge on the same two stages, deliberately separated:
- Per-page extraction. The model reports each label verbatim plus its enclosing
headings (
context_path), for example["Gesetzliche Vertreter", "Vater"]. It never invents names. - Deterministic naming.
services/naming.pyslugifies labels and, only where they collide, prefixes them with the innermost distinguishing heading.
Uniqueness is a document-wide property that a page-scoped model call cannot see, and letting
the model choose prefixes would make the core feature untestable. So the model supplies
structure and the code guarantees uniqueness. A form asking twice for Familienname: yields
vater_familienname and mutter_familienname.
Every field carries two names, built from the same label and the same disambiguation:
name |
display_name |
|
|---|---|---|
| Familienname under Vater | vater_familienname |
Vater Familienname |
| AHV-Nummer | ahv_nummer |
AHV-Nummer |
| Grösse | groesse |
Grösse |
display_name is derived from the label, never by un-slugging name — otherwise
ahv_nummer would come back as "Ahv Nummer" and groesse as "Groesse".
Reviewing in the UI
The sidebar chooses the source — a PDF upload or a form URL — and shows the run summary; the main area is the work. Two views share one editable table, so an edit in either is visible in the other:
- Vergleich mit Quelle — for a PDF, the rendered page next to the fields found on that page, one selector driving both. For a web form the pane gives a link to the live form and the structure as it was read from the rendered page.
- Alle Felder — the whole document in one table, plus CSV herunterladen.
Both name columns are editable. Duplicate detection always runs across the whole document,
not just the visible page. The CSV is comma-separated with a UTF-8 BOM, so umlauts survive
being opened in Excel; switch CSV_SEPARATOR in ui/field_table.py to ; if you would
rather have Excel split the columns automatically.
Quick start
cp .env.example .env # IS_PROD is required; init_logger() fails without it
make install
make docker-up # vLLM + API + UI
make docker-up-web # the same, plus the browser and the form fixture for web forms
The UI is on http://localhost:8501, the API on http://localhost:8000/docs.
The browser sits behind a compose profile, so make docker-up starts nothing extra and PDF
extraction needs none of it. In production nothing local runs either: BROWSER_API_URL points
at the cluster deployment.
Without Docker, against an already-running vLLM:
make dev # API on :8000
make dev-ui # UI on :8501
Web forms
An online form is rendered in a browser this repository owns (docker/browser, Playwright
plus a few hundred lines), walked through its steps, and read from screenshots of it.
Rendering is not optional: a modern form has drawn nothing when its HTML arrives, and a form's
structure is visual — a caption is the text beside the box you type into, a section heading is
the larger text above a run of fields, and a column header applies to the fields under it.
Read from the picture, spelled from the page
The picture is the reading. An earlier version read the DOM instead, as one line per control with the heading chain measured off the layout, and fell back to screenshots only where that said too little. On real cantonal forms the listing lost exactly what matters: the fourteen tick boxes of Veranstaltungsart read as fourteen fields, and every heading on the page collapsed into one. Both are plain to see in a screenshot.
What the DOM is still good for is spelling. Beside each screen the model gets the labels the browser measured off that same render, marked as a spelling reference:
Wörtliche Beschriftungen der Bedienelemente auf den obigen Bildschirmen …
- [text] Bezeichnung der Verantstaltung
- [checkbox] Anwohnerstrassenfest
- [radio] kommerzieller Anlass
It says nothing about what a field is or how fields group — the image decides that. It stops
AHV-Nummer coming back as AHV Nummer and Grösse as Groesse, which is what an 11px
label rendered into a screenshot otherwise invites.
Screens, several per call
The page is photographed in tiles the size of the browser window, following whichever element
actually scrolls — an application shell scrolls an inner pane and leaves the document one
screen tall. Each tile is one browser window (BROWSER_VIEWPORT_WIDTH ×
BROWSER_VIEWPORT_HEIGHT, 1280×1024 by default), tiles overlap by 80 px, and at most
MAX_WEB_UNITS of them are read across all steps.
WEB_SCREENS_PER_CALL (4) consecutive screens go into one call, because a form does not
break where a screen does. Shown a single tile, the model reports the fragments at its edges as
fields: a caption whose box was cut off below, a box whose caption was cut off above. Shown the
run, it sees one field — and the prompt tells it that the screens overlap and that a field on
two of them is reported once. Screens are never grouped across a step boundary; two steps are
two different pictures of the form.
The ceiling is the served model's: vLLM refuses a request carrying more images than
--limit-mm-per-prompt allows, which is why LLM_IMAGES_PER_PROMPT (4) and
WEB_SCREENS_PER_CALL are separate settings and the second must not exceed the first. Each
image also costs LLM_IMAGE_SOFT_TOKENS of context.
One call is one "page" in the result and carries all of its screens back (page_images, which
may hold several entries per unit); the UI stacks them beside the fields read off them.
Reopening the live form instead would show a new render, of the first step at that; what a
reviewer has to check against is what the model was actually given.
Multi-step forms
A cantonal form is usually a wizard. WSU_KESB_103_Gefaehrdungsmeldung has four steps and
shows five controls on the first; the eGov Veranstaltung auf öffentlichem Grund has eight.
Reading the entry page alone returns a fraction of the inventory that looks like the whole
thing, which is the worst answer available.
So the browser walks the form. Per step it photographs what is there before answering anything — the picture the model reads is the form as an applicant first meets it, never one carrying placeholder values or the red banners a refusal leaves behind — and then:
- answers every control that is waiting for one, with placeholder values, repeating up to
three times because requiredness moves as answers are given. Every control, not only the
ones marked
required: the KESB form marks nothing at all and the eGov wizard's dropdowns are described nowhere, so a walk that answers only what it is told to answer stops on the first step of both. Tick boxes are the exception — that is where consent lives, so one is ticked only after the step has refused and only where the markup calls it required; - presses the page's own next button;
- if the step did not change, fills and presses again, up to three times in all — a server-validated form says which fields it wanted only after refusing, so the second round knows strictly more than the first;
- observes, and stops when the step still does not change.
Whether the step changed is decided by the controls' names, never by their labels. A step
that refuses re-renders with Feld darf nicht leer sein appended to every caption; read by its
labels that is a page of controls nobody has ever seen, so the walk used to conclude it had
advanced, record the same step a second time, and photograph it covered in error text — which
the model then read the form off. A step stays the same step while its address, its own name
for itself and a subset of its controls hold, so a section unfolding as answers are given is
not mistaken for a new one either.
Never submitting
Advancing a wizard and submitting it are both a button press, and only one of them is allowed: this reads a form's field list, it does not file applications. Four things enforce that, because the first one alone is a promise made by a regular expression over prose:
- a button whose wording reads like a submit is never pressed, checked before the test for a
next button, so
Weiter zur zahlungspflichtigen Bestellungis left alone;Enteris never pressed in a text field either, since inside a<form>that submits it; - every request is held up before it leaves the browser.
WEB_BLOCK_WRITESsets how strictly:alllets nothing but GET out,navigationholds page navigations and form submissions to the walk's own presses of next while widget lookups go through,offrelies on wording alone; - a request bound for a submit-shaped path is refused whatever caused it — the one check that does not trust the walk's own classification, and the only thing that catches a page script posting an application with nothing clicked;
- what was allowed and what was refused comes back with the observation and is logged, so a run can be checked afterwards rather than trusted.
A server-validated wizard cannot be walked without sending each step's answers, and that is
accepted; what must never happen is the act that opens the case. CONTEXT.md holds the
distinction. docker/wizard is a form fixture that files an application if anything slips
through, and tests/integration/test_never_submit.py walks it and asserts its record of
submissions is empty.
Filling a real government form is where the time went. Seven things, nearly all silent — the field ends up empty and the step blames it a round later:
-
element.value = …does not fill a React form. The framework owns the value and never learns of the assignment, so the eGov wizard's Weiter staysdisabledwhile the field looks filled. Values go in through Playwright. -
Requiredness is not always stated. The KESB form marks nothing at all, not even after refusing to advance, which is why every radio group is answered and not only marked controls.
-
A masked field drops what does not fit.
PLZtakes digits, soTestreads back as empty. Candidates are tried in turn and the value is read back; no mask is understood, only covered. -
An autocomplete refuses to be filled and commits only what it recognises.
Ort,PLZandStrasseare jQuery UI autocompletes:fillis treated as a programmatic change and wiped after it returned, and a typed value that is not a real place is cleared when focus leaves. They are typed key by key and the suggestion the widget offers is clicked. Clicked, neverEnter— Enter in a text field inside a<form>triggers implicit submission. -
A masked field is never empty.
Beginnholds__:__, so it looks answered and is skipped. A value made only of mask characters counts as none. -
aria-invaliddescribes the last submission, not the value just typed. A server-validated form marks the field in the HTML it renders and never unmarks it, because nothing revalidates until the next submission. Treating that as "the value did not stick" meant every candidate read back as a failure however well it went in —4051in a postcode and10:00in a time field were both typed successfully, both judged refused, and the field was then given up on for the rest of the walk. Whether a value stuck is read from the value. -
The first keystroke into a mask is eaten moving the caret: typing
1000into__:__gives_0:00, which is rejected while looking filled in.Homefirst, and the same keystrokes give10:00. -
A custom dropdown is not clicked where its input is. React-Select parks a one-pixel
dummyInputoff screen and draws the control as divs around it, so clicking the input fails with "Element is outside of the viewport". The nearest ancestor with a rendered box is clicked instead — the same ruleobserve.jsuses to find where a control sits — and the first option offered is taken. Where the dropdown searches instead of just opening, short prefixes (ba,st, …) are typed to make it offer something.
A field that accepts none of the candidates is marked and left alone for the rest of the step, so three stubborn address fields do not cost minutes of retyping on every round.
When a step still will not advance, the run says which fields it was waiting for
(blocked_by → a warning): "The form would not accept: Strasse, Beginn". A next button that is
present but greyed out is reported as blocked, never as "no further step button was
found" — the second describes a form that has ended, and that is the difference between a
complete inventory and a quarter of one.
A submit is never pressed. Anything reading absenden, abschicken, einreichen,
senden, bestellen, kostenpflichtig, zahlungspflichtig and the like is skipped, checked
before the next test so that Weiter zur zahlungspflichtigen Bestellung is left alone. A
form service takes a filled-in form at its word, and a dummy report to the child protection
authority is not an acceptable cost of reading its field list.
The walk is capped at WEB_MAX_STEPS (10), never leaves the site it started on, and closes any
tab the form opens. Where it could not get past a step, the result says so and the inventory is
knowingly partial. follow_steps: false on the URL endpoints — Mehrstufige Formulare
durchklicken in the sidebar — reads the first step only.
What is not attempted
Nothing beyond the form's own steps. Links into the wider site are not followed, and a second form linked from the first is a second run.
Conditional fields behind an optional answer are not sought out: only what a step demands is filled, so a section that appears after ticking an optional box is not seen.
Why not Firecrawl
The web path ran on self-hosted Firecrawl first. It returns HTML and, self-hosted, nothing
else: screenshots and actions live in Fire Engine, which is cloud-only (SELF_HOST.md,
issues #1028 and #2059, both closed as not planned), and its playwright
adapter neither requests a screenshot nor accepts one back. Every visual question therefore had
to be answered by inferring from markup — sibling order, nesting depth, inline styles — and
those inferences fit the page they were written against and broke on the next toolkit.
Owning the browser answers those questions directly, and replaced five containers (api, redis,
rabbitmq, postgres, playwright) with one. Two Firecrawl findings still hold for anything that
fetches a page: formats: ["markdown"] is useless here because markdown has no syntax for a
form control, and an HTTP 200 from a scraping service says nothing about what the page
answered.
API
| Method | Path | Purpose |
|---|---|---|
POST |
/extraction/form-fields |
Upload a PDF, get the full inventory. Blocks until done. |
POST |
/extraction/form-fields/stream |
Same, as SSE: a progress event per page, then one result. |
POST |
/extraction/form-fields/url |
Read an online form. Body: {"url": "..."}. |
POST |
/extraction/form-fields/url/stream |
Same, as SSE, with the same event contract. |
GET |
/health/{liveness,readiness,startup} |
Kubernetes probes; readiness checks the LLM. |
curl -F file=@data/example.pdf http://localhost:8000/extraction/form-fields
curl -X POST http://localhost:8000/extraction/form-fields/url \
-H 'Content-Type: application/json' \
-d '{"url":"https://www.example.ch/anmeldung"}'
A URL the backend will not fetch is rejected with 400 before anything is scraped: only
http and https are accepted, and every address the host resolves to is checked against the
loopback, private, link-local and reserved ranges. A public name whose A record points at
127.0.0.1 is caught, since the resolved addresses are checked rather than the string.
Pages are processed one at a time, so a 7-page form takes minutes. That is why the UI uses
the streaming endpoint. LLM_MAX_CONCURRENCY and the vLLM --max-num-seqs setting must be
raised together to speed this up.
Failure behaviour
Extraction is expensive, so failures are split into two kinds.
Fatal — abort immediately. Before a single page is rasterised, the service checks that
the LLM answers its health endpoint, serves the configured model, and serves it with a
context large enough for prompt + page image + answer (LLM_MIN_CONTEXT_TOKENS, default
8192). The check uses its own short timeout (LLM_HEALTH_TIMEOUT, default 5s) rather than
LLM_TIMEOUT, so a hanging server is caught in seconds. If anything fails — unreachable,
hanging, 5xx, bad credentials, unknown model, context too small — the run stops at once with
503 and a message naming the endpoint and the cause. Nothing is rendered and no page is
sent. The same applies if the LLM dies mid-run: the next page raises instead of continuing.
$ curl -F file=@data/example.pdf localhost:8000/extraction/form-fields # LLM down
{"errorId":"service_unavailable","status":503,
"debugMessage":"The LLM API at http://llm:8000/health is unavailable: ConnectTimeout"}
Without this, an unavailable model was the worst case: all pages rendered, every page failing identically, and an empty inventory returned with one warning per page — a wrong answer dressed up as a successful run.
Page-local — carry on. A page the model cannot read, or a response that will not parse,
yields an empty result plus a warning so the remaining pages still produce output. The
classification lives in services/llm_health.py::is_fatal_llm_error.
The app deliberately does not refuse to start when the LLM is down; the model may come up
later, and that is what /health/readiness is for.
Development
make check # uv lock --locked, ruff format, ruff check, ty
make test # unit tests
make help # all targets
make test-integration runs against a live LLM and is excluded from CI.
Evaluating extraction quality
Prompt changes silently break form types you are not looking at, so quality is tracked with
labelled cases in evals/cases/:
make eval
It reports label precision/recall, the total field count against the expected count, and fails outright if the naming pass ever produces a duplicate.
Current result on data/example.pdf with gemma-4-31B-it-NVFP4 at 200 DPI and 560 image
tokens: precision 98.3%, recall 96.6%, 92 of ~93 fields, all names unique. The two
remaining discrepancies are one very long question the model shortens and one document
checklist on page 5 that it does not report as a field.
To add a case:
make bootstrap-case PDF=data/your-form.pdf
This seeds a skeleton from the PDF's AcroForm widgets, but the labels must be curated by
hand. In real questionnaires AcroForm metadata is mostly useless as ground truth: in
data/example.pdf, 77 of the 81 text widgets are named Text1…Text31 with no tooltip, and
every checkbox option is its own widget even though our specification counts the group as
one field. Treat the widget count as a sanity check, not as an answer key.
Adding a new source kind
Excel is planned. Everything after extraction is source-agnostic, so:
- Add a source type to the
ExtractionSourceunion inmodels/extraction.py, unless an existing one fits. - Implement the
FieldExtractorprotocol inservices/extractors/—supports(), which receives the source itself, plus anextract()that yields onePageResultper unit of work. - Add it to
Container.extractor_registry.
Naming, uniqueness, the API contract and the UI need no changes. Only extractors know about modality, which matters because Excel and HTML are not naturally images and should not be forced through the rendering path.
Dispatch is on the source object rather than a media type string, which is what lets a URL —
having no bytes and no media type — be a first-class source instead of a special case
smuggled through as text/uri-list.
A PageResult.page is a unit of work, not necessarily a page: a PDF page, or one screen of
one call over a run of a web form's screens. The UI calls these "Seite" and "Teil".
Structured output: why NativeOutput
services/agents/form_field_agent.py wraps the output type:
output_type=NativeOutput(PageExtraction)
This is load-bearing. A bare output_type=PageExtraction makes pydantic-ai request the
result as a tool call (tools: [final_result], tool_choice: "required"), which vLLM
refuses unless it was started with --tool-call-parser and a tool chat template:
400 tool_choice="required" requires --tool-call-parser to be set
NativeOutput instead sends response_format: json_schema, which uses vLLM's guided
decoding and needs no extra server flags. That is why the compose command carries no
tool-calling options.