Medical API (Deutsch)¶
Kompakte deutsche Fassung — die englische Dokumentation beginnt bei der Übersicht.
Die Medical API macht aus klinischem Freitext strukturierte Daten. Sie hat als ICD-10-Kodierung angefangen und kann heute vier Dinge:
| Endpoint | Zweck |
|---|---|
POST /api/deep-research |
Freitext → ICD-10-GM-Codes, in drei Stufen gestreamt |
POST /api/documents/text |
PDF oder Bild → Text (Textschicht oder Vision-Transkription) |
POST /api/documents/extract |
Zuweisung oder Arztbrief → strukturiertes, belegtes JSON |
POST /api/llm/generate |
Freie Anweisung auf einem Dokumenttext → Freitext-Antwort |
Katalog für die Kodierung ist ICD-10-GM 2026 mit ergänzten Synonymen — "Blinddarm" oder "Kreuzbandriss" funktionieren also, nicht nur die offizielle Katalogsprache.
Basis-URL und Auth¶
| Umgebung | Basis-URL |
|---|---|
| Sandbox | https://medical-api.dev.unomed.ch |
| Produktion | https://medical-api.unomed.ch |
Die bisherigen Hostnamen icd-10.unomed.ch und icd-10.dev.unomed.ch laufen unverändert weiter und bedienen dieselbe API — bestehende Integrationen müssen nichts ändern. Neue Integrationen nehmen medical-api.
Jeder Call ausser den Health-Endpoints braucht den API-Key als Bearer-Token:
Ohne oder mit falschem Key: 401.
Der Key ist ein langlebiger Integrations-Key und gehört nur auf den Server. Er darf nie in Browser- oder App-Code eingebaut werden — das Frontend spricht mit dem eigenen Backend, das Backend mit dieser API.
Patientendaten¶
/api/documents/text, /api/documents/extract und /api/llm/generate sind für Dokumente mit Patientendaten gebaut. Deren Inhalte werden weder gecacht noch geloggt — gezählt wird nur ein Request-Zähler pro Key fürs Kontingent. Dasselbe gilt für den Freitext an /api/deep-research.
POST /api/deep-research¶
Der Endpoint für die ICD-Kodierung. Der Server hält die Verbindung offen und streamt drei Ergebnis-Stufen nacheinander via Server-Sent Events:
- Instant — Semantic + Keyword-Search, deutlich unter einer Sekunde. Liefert nur sichere Treffer (
high/medium, max. 10). - Smart — LLM zieht Keywords aus dem Freitext und wählt Codes, wenige Sekunden.
- Deep — LLM-Recherche im Katalog mit konsolidierter Empfehlung, 10–30 s.
Stufe 1 kann sofort angezeigt und durch Stufe 2 und 3 ersetzt bzw. ergänzt werden, sobald sie ankommen. Wenn der User weiternavigiert, wird die Verbindung einfach geschlossen.
Request¶
query ist Pflicht (1–2000 Zeichen). Ein reiner ICD-Code als query (z.B. "M17.9") wird als direkter Lookup behandelt und kommt über den gleichen Stream zurück: Stufe 1 enthält dann den exakten Katalog-Treffer (high) plus benachbarte Katalog-Codes (medium); Stufe 2 und 3 listen die Nachbarn als low mit Begründung.
rerank (optional, Default aus) aktiviert ein Legacy-Reranking für Stufe 1. In dem Modus kommen Stufe-1-Codes mit confidence: "score" und numerischem score (0–100) statt der kategorialen Confidence. Nur setzen, wenn ihr vom alten Format abhängt.
Response — Event-Stream¶
Jede Zeile ist ein SSE-Event der Form data: {...}\n\n. Es gibt drei type-Werte:
status — Fortschritt, für Spinner/Statuszeile:
Wenn während des Streams etwas schiefgeht, kommt {"type":"status","status":"error","message":"..."} — kein HTTP-Fehler, sondern ein Status-Event. Danach ist der Request beendet.
partial — Ergebnisse von Stufe 1 und 2 (layer: 1 oder 2):
{
"type": "partial",
"layer": 1,
"summary": "",
"codes": [
{
"code": "S83.53",
"description": "Riss des vorderen Kreuzbandes",
"confidence": "high",
"reasoning": "",
"score": 0
}
]
}
result — Ergebnis von Stufe 3, danach ist der Stream zu Ende:
{
"type": "result",
"layer": 3,
"summary": "Kurze Begründung der Empfehlung",
"codes": [
{
"code": "S83.53",
"description": "Riss des vorderen Kreuzbandes",
"confidence": "high",
"reasoning": "Warum dieser Code passt",
"score": null
}
]
}
Code-Felder¶
code,description: ICD-10-GM-Code + offizieller Katalogtext.confidence:"high"/"medium"bei Stufe 1 (schwächere Treffer werden nicht zurückgegeben),"high"/"medium"/"low"bei Stufe 2 und 3. Mitrerank: truenutzt Stufe 1 stattdessen"score"(Legacy).score: Legacy-Feld — Ähnlichkeit 0–100 nur wennconfidence"score"ist; sonst0(Stufe 1) bzw.null.reasoning: kurze Begründung, nur Stufe 2 und 3 (Stufe 1 lässt es leer).
Beispiel¶
Läuft server-seitig (Node 18+); von dort werden die Stufen ans eigene Frontend weitergereicht, z.B. über einen eigenen SSE-Endpoint:
const response = await fetch('https://medical-api.dev.unomed.ch/api/deep-research', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({ query: 'Kreuzbandriss rechts' }),
});
if (!response.ok) {
// 401 / 422 / 503 kommen als normale HTTP-Fehler, nicht als Stream
throw new Error(`ICD API ${response.status}: ${await response.text()}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const event = JSON.parse(line.slice(6));
if (event.type === 'partial') forwardTier(event.layer, event.codes);
if (event.type === 'result') forwardTier(3, event.codes);
if (event.type === 'status' && event.status === 'error') forwardError(event.message);
}
}
Der Client-Timeout sollte mindestens 120 Sekunden betragen — Stufe 3 kann lang sein.
POST /api/documents/text¶
Nimmt eine Datei — PDF, JPEG oder PNG — und gibt ihren Text zurück. Der Vorschalt-Endpoint für /api/documents/extract, wenn ihr nur den Anhang habt und nicht den Text. Synchron, kein Streaming.
Entschieden wird je Seite, die Antwort sagt, was gelaufen ist:
- Eine Seite mit echter Textschicht (≥ 40 sichtbare Zeichen oder ausgefüllte Formularfelder) kommt unverändert zurück — Millisekunden, kein Modell.
- Eine Seite ohne (Scan, Foto, Bild) wird mit 130 dpi gerendert und vom Vision-Modell wortgetreu abgeschrieben (Zeilenstruktur bleibt, Kästchen als
[x]/[ ], nichts zusammengefasst, korrigiert oder übersetzt). Scan-Seiten gehen in Dreier-Paketen ans Modell, höchstens 10 je Dokument. methodist"text"(keine Scan-Seite — zählt nicht aufs Tageskontingent),"vision"(keine Textseite) oder"mixed". Sobald eine Scan-Seite dabei ist, zählt das Dokument einen Request aufs Kontingent.
Ausgefüllte Formularfelder (AcroForm) stehen nach den Seiten als Block --- Formularfelder ---, eine Zeile je gefülltem Feld (Name: Wert; Kästchen nur angekreuzt, als [x] <Wert>). Der Name ist der kurze Feldname (lastName, nicht der ganze XFA-Pfad); der volle Pfad erscheint nur, wenn zwei gefüllte Felder denselben kurzen Namen haben. Bilder über 4000 px oder 3.5 MB werden vor der Abschrift verkleinert und als JPEG neu kodiert; Bilder über 40 Megapixel werden vor dem Dekodieren mit 422 (Bild zu gross …) abgewiesen. PDF-Seiten mit übergrosser Seitenbox werden mit reduzierter Auflösung gerendert; reicht selbst 40 dpi nicht, wird die Seite mit 422 (Seite zu gross …) abgewiesen.
Request¶
content_type ist application/pdf, image/jpeg oder image/png; image/tiff wird noch nicht unterstützt (die Vision-Modelle lesen nur JPEG und PNG) — auf eurer Seite konvertieren oder als PDF schicken. data ist die Datei Base64-kodiert, höchstens 25 MB dekodiert. filename ist optional und nur informativ.
Response¶
{
"text": "--- Seite 1 ---\nZuweisung zur stationären Behandlung\n...\n\n--- Seite 2 ---\n[x] stationär [ ] ambulant\nPatient: Hans Muster, geb. 03.05.1960\n...\n\n--- Formularfelder ---\nNachname: Muster\nstationaer: [x] Ja",
"pages": 2,
"pages_transcribed": 1,
"method": "mixed",
"model": "eu.anthropic.claude-haiku-4-5-...",
"processing_time_ms": 6210.4
}
text enthält die Seiten in Dokumentreihenfolge, bei mehreren Seiten mit --- Seite n ----Marken; hat ein Dokument mehr als 10 Scan-Seiten, werden die ersten 10 abgeschrieben und der Text endet mit [weitere Seiten nicht abgeschrieben]. pages ist die Seitenzahl des Dokuments, pages_transcribed die Zahl der Seiten, die durchs Vision-Modell gingen. model ist leer, wenn kein Modell gelaufen ist.
Die Vision-Ausgabe ist eine Abschrift, keine OCR-Wahrheit: das Modell liest, was es sieht, und markiert Unleserliches als [unleserlich]. Gebt das Ergebnis an /api/documents/extract weiter — dessen Beleg-Prüfung sorgt dafür, dass nur Werte in die Struktur kommen, die in genau diesem Text stehen.
Fehler: 413 Datei grösser als 25 MB · 415 content_type nicht unterstützt (auch image/tiff) · 422 Datei nicht als PDF/Bild lesbar, ohne jeden Text, oder die Abschrift wurde vom Ausgabelimit des Modells abgeschnitten (Abschrift abgeschnitten — Dokument zu lang) · 429 nur bei Scan-Seiten · 503 kein Modell konfiguriert oder PDF-Renderer nicht verfügbar (PDFs mit Textschicht laufen trotzdem).
POST /api/documents/extract¶
Nimmt den Klartext eines medizinischen Dokuments und gibt ein strukturiertes JSON-Objekt zurück — Patient, Versicherung, Absender, Empfänger, Diagnosen, Medikation. Synchron, kein Streaming; ein typisches Dokument braucht 2–5 Sekunden.
Request¶
document_type ist "zuweisung" (Anmeldung/Überweisung an eine Institution) oder "arztbrief" (Entlass-/Verlaufsbrief, Befund) und bestimmt das Schema. text ist das Dokument als Klartext, 1–20 000 Zeichen; PDFs und Scans wandelt /api/documents/text in Text um (eigener OCR-Text funktioniert genauso).
Response¶
{
"document_type": "zuweisung",
"data": {
"dokument": { "typ": "zuweisung", "datum": "2026-08-12" },
"patient": { "nachname": "Muster", "vorname": "Max", "geburtsdatum": "1971-02-03", "geschlecht": null, "strasse": "Seestrasse 12", "plz": "8802", "ort": "Kilchberg", "versichertennummer": null, "fallnummer": null },
"versicherung": { "name": "Helsana", "klasse": "allgemein" },
"fragestellung": "Indikation zur Knie-TP rechts?",
"diagnosen": ["Gonarthrose rechts", "Arterielle Hypertonie"],
"medikation": ["Lisinopril 10mg 1-0-0"],
"kostengutsprache": null,
"dringlichkeit": "elektiv"
},
"removed_by_grounding": [],
"model": "eu.anthropic.claude-haiku-4-5-...",
"usage": { "tokens_in": 2149, "tokens_out": 452 },
"processing_time_ms": 2815.98
}
data enthält immer alle Top-Level-Keys. Was nicht ausdrücklich im Dokument steht, ist null bzw. eine leere Liste. Datumsangaben sind ISO-normalisiert (YYYY-MM-DD).
Beide Typen teilen dokument, patient, versicherung, absender, empfaenger, aufenthalt, fragestellung, diagnosen, medikation. Dazu kommt bei zuweisung kostengutsprache und dringlichkeit, bei arztbrief eingriffe und procedere.
Enum-Werte: patient.geschlecht m/w · versicherung.klasse allgemein/halbprivat/privat · aufenthalt.art stationaer/ambulant · dringlichkeit notfall/dringlich/elektiv · kostengutsprache liegt_vor/beantragt/wird_eingeholt. Sie bleiben null, solange das Dokument sie nicht ausdrücklich nennt — eine "Privatklinik" macht die Versicherungsklasse nicht privat, ein Wunschtermin macht eine Zuweisung nicht dringlich.
Grounding — warum nichts erfunden werden kann¶
Nach der Modell-Antwort prüft ein deterministischer Validator jeden Wert gegen das Quelldokument. Was er dort nicht belegen kann, wird auf null gesetzt und in removed_by_grounding aufgeführt:
Wörtliche Felder (Namen, Adressen, Nummern, Daten, Diagnosen, Medikamente) brauchen einen Beleg im Dokument — Daten in allen gängigen Schreibweisen, Nummern über die Ziffernfolge, Freitext über Substring oder ≥ 60 % Token-Überdeckung. Enum-Felder brauchen ein explizites Schlüsselwort.
Garantiert ist damit: ein erfundener Wert erreicht euch nicht. Nicht abgedeckt ist die Fehlzuordnung — ein Wert, der im Dokument steht, aber im falschen Feld landet (z.B. die Praxisadresse als Patientenadresse), besteht den Beleg-Check. Dafür braucht es einen menschlichen Review-Schritt auf den relevanten Feldern und eine Anzeige des Quelldokuments daneben.
Ein nicht-leeres removed_by_grounding ist ein brauchbares Review-Signal: das Modell hat etwas behauptet, was das Dokument nicht hergibt.
POST /api/llm/generate¶
Allzweck-Endpoint: Dokumenttext plus Anweisung rein, Antwort des Modells als Text zurück. Für Aufgaben, für die es noch keinen eigenen Endpoint gibt — Brief zusammenfassen, patientenverständlich umschreiben, eine bestimmte Passage herausziehen.
Wenn ihr ein strukturiertes Ergebnis braucht, nehmt POST /api/documents/extract — der ist schemagebunden und belegt-geprüft. Dieser Endpoint liefert Freitext ohne solche Garantien.
Request¶
{
"text": "Zuweisung vom 12.08.2026 ...",
"prompt": "Fasse die Fragestellung dieser Zuweisung in einem Satz zusammen."
}
text ist das Dokument (1–20 000 Zeichen), prompt die Anweisung (1–5 000 Zeichen). prompt geht als System-Prompt rein, text als User-Message.
Response¶
{
"output": "Der Hausarzt möchte klären, ob eine Kniegelenksprothese rechts indiziert ist.",
"model": "eu.anthropic.claude-haiku-4-5-...",
"usage": { "tokens_in": 207, "tokens_out": 56 },
"processing_time_ms": 1397.92
}
output ist Klartext; Modelle setzen gern Markdown-Überschriften davor — wenn ihr einen nackten Satz wollt, schreibt das in den prompt. Die Ausgabe wird nicht validiert: es prüft niemand, ob die Antwort durch text gedeckt ist. Also als Entwurf für einen Menschen behandeln, nicht als Fakt.
Health¶
Ohne Auth erreichbar:
GET /api/health— Basis-Verfügbarkeit + geladene Katalog-Grösse.GET /api/llm/health— Verfügbarkeit des LLM-Backends. Betrifft Stufe 2 und 3 der Deep Research sowie/api/documents/extractund/api/llm/generate. Wenn die als down zurückkommt, läuft Stufe 1 der Deep Research trotzdem — die anderen beiden Endpoints nicht.
Fehlercodes¶
401— API-Key fehlt oder ist ungültig.422— Request-Validierung fehlgeschlagen (z.B.queryleer oder zu lang, unbekannterdocument_type).429— Rate-Limit oder Tageskontingent des Keys erreicht; derRetry-After-Header sagt, wann es weitergeht.502— LLM-Call fehlgeschlagen oder ohne brauchbares Ergebnis. Nur auf den LLM-Endpoints; einmal wiederholen, danach als Störung behandeln.503— Service noch nicht bereit oder LLM-Backend nicht konfiguriert.
Auf den dokumentverarbeitenden Endpoints sind die Fehlermeldungen bewusst generisch: Provider-Fehlertexte können Teile des Requests zitieren, und der kann Patientendaten enthalten.