Skip to content

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:

Authorization: Bearer YOUR_API_KEY

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:

  1. Instant — Semantic + Keyword-Search, deutlich unter einer Sekunde. Liefert nur sichere Treffer (high/medium, max. 10).
  2. Smart — LLM zieht Keywords aus dem Freitext und wählt Codes, wenige Sekunden.
  3. 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": "Patient mit Riss des vorderen Kreuzbandes rechts"
}

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:

{ "type": "status", "status": "searching", "message": "Code-Lookup..." }

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. Mit rerank: true nutzt Stufe 1 stattdessen "score" (Legacy).
  • score: Legacy-Feld — Ähnlichkeit 0–100 nur wenn confidence "score" ist; sonst 0 (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.
  • method ist "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": "application/pdf",
  "data": "JVBERi0xLjQK...",
  "filename": "zuweisung.pdf"
}

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": "zuweisung",
  "text": "Zuweisung vom 12.08.2026 ..."
}

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:

"removed_by_grounding": ["patient.versichertennummer='756.1234.5678.90'", "diagnosen[1 Eintraege]"]

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/extract und /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. query leer oder zu lang, unbekannter document_type).
  • 429 — Rate-Limit oder Tageskontingent des Keys erreicht; der Retry-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.