Initial commit

This commit is contained in:
Jonas Karneboge 2026-09-22 18:35:43 +02:00
commit 3cba772836
1341 changed files with 532924 additions and 0 deletions

711
AMDP/app.R Normal file
View file

@ -0,0 +1,711 @@
# Präambel ####
AKZENT_FARBE = "#8B2635"
app_css = "
.alert-fehler {
background-color: #f8d7da;
color: #58151c;
border: 1px solid #8B2635;
border-radius: 4px;
padding: 12px 16px;
margin-bottom: 12px;
}
.alert-warnung {
background-color: #fff3cd;
color: #664d03;
border: 1px solid #d9a441;
border-radius: 4px;
padding: 12px 16px;
margin-bottom: 12px;
}
.abschnitt-karte {
background-color: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 18px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.abschnitt-titel {
color: #8B2635;
font-size: 20px;
font-weight: 700;
margin-bottom: 14px;
}
.abschnitt-titel:not(:first-child) {
margin-top: 22px;
}
.app-titel {
color: #8B2635;
font-size: 26px;
font-weight: 700;
margin: 4px 0 20px 0;
}
.btn-laden {
background-color: #8B2635;
color: #ffffff;
border: none;
margin-top: 8px;
margin-bottom: 8px;
}
.btn-laden:hover {
background-color: #6e1e2a;
color: #ffffff;
}
.item-zeile {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 16px;
border-top: 1px solid #f0f0f0;
}
.item-zeile:hover {
background-color: #faf7f2;
}
.item-nr {
min-width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: #f2e4e6;
color: #8B2635;
font-weight: 700;
font-size: 13px;
flex-shrink: 0;
}
.item-text {
flex: 1 1 auto;
font-size: 14px;
}
.input-panel {
flex: 0 0 auto;
}
.input-panel .shiny-options-group {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin: 0;
}
.input-panel .radio-inline {
margin: 0;
padding: 4px 12px;
border: 1px solid #d8d8d8;
border-radius: 14px;
font-size: 12px;
font-weight: 400;
cursor: pointer;
background-color: #fafafa;
}
.input-panel .radio-inline:hover {
border-color: #8B2635;
}
.input-panel .radio-inline input[type=\"radio\"] {
margin-right: 4px;
}
.bereich-panel {
border: 1px solid #e5e5e5;
border-radius: 6px;
margin-bottom: 12px;
}
.bereich-titel {
cursor: pointer;
padding: 12px 16px;
background-color: #faf5f6;
border-radius: 6px 6px 0 0;
list-style: none;
}
.bereich-titel::-webkit-details-marker {
display: none;
}
.bereich-titel h2 {
display: inline-block;
color: #8B2635;
font-size: 17px;
font-weight: 700;
margin: 0;
}
.bereich-hinweis {
display: block;
font-size: 12px;
font-weight: 400;
color: #888888;
margin-top: 2px;
}
input[type=\"radio\"],
input[type=\"checkbox\"] {
accent-color: #8B2635;
}
.syndrom-liste .shiny-input-container {
width: 100%;
max-width: 100%;
}
.syndrom-liste .shiny-options-group {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 6px 24px;
}
.syndrom-liste .checkbox {
margin: 0;
}
.vorbemerkungen-text {
background-color: #fdf6f2;
border-left: 3px solid #8B2635;
padding: 10px 14px;
margin: 10px 16px;
font-size: 13px;
line-height: 1.5;
color: #444444;
white-space: pre-line;
}
.erhebungscode-badge {
font-size: 10px;
font-weight: 400;
color: #999999;
margin-left: 4px;
}
.tooltip-ziel {
position: relative;
cursor: help;
border-bottom: 1px dotted #999999;
}
.tooltip-inhalt {
display: none;
position: absolute;
z-index: 100;
left: 0;
top: 100%;
margin-top: 6px;
width: 360px;
max-width: 80vw;
background-color: #ffffff;
border: 1px solid #8B2635;
border-radius: 6px;
padding: 10px 14px;
font-size: 12px;
font-weight: 400;
line-height: 1.5;
color: #333333;
white-space: normal;
text-align: left;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
}
.tooltip-ziel:hover .tooltip-inhalt,
.tooltip-ziel:focus .tooltip-inhalt,
.tooltip-ziel:focus-within .tooltip-inhalt {
display: block;
}
.tooltip-titel {
display: block;
color: #8B2635;
font-weight: 700;
margin-top: 8px;
}
.tooltip-titel:first-child {
margin-top: 0;
}
.tooltip-inhalt p {
margin: 2px 0 0 0;
}
.tooltip-inhalt ul {
margin: 2px 0 0 0;
padding-left: 16px;
}
"
app_css = gsub("#8B2635", AKZENT_FARBE, app_css, fixed = TRUE)
# Infrastruktur ####
library(shiny)
library(jsonlite)
library(stringr)
library(officer)
APP_VERZEICHNIS = normalizePath(getwd())
STUFE_AUSWAHL = c(
"nicht vorhanden" = "0",
"leicht ausgeprägt" = "1",
"mittel ausgeprägt" = "2",
"schwer ausgeprägt" = "3",
"keine Aussage" = "keine_aussage"
)
STUFE_STANDARDWERT = "0"
GESCHLECHT_AUSWAHL = c(
"männlich" = "maennlich",
"weiblich" = "weiblich"
)
GESCHLECHT_STANDARDWERT = "maennlich"
# Helper ####
lade_amdp_datei = function(pfad) {
ergebnis = tryCatch({
daten = jsonlite::fromJSON(pfad, simplifyVector = FALSE)
list(erfolg = TRUE, daten = daten, fehler = NULL)
}, error = function(e) {
list(erfolg = FALSE, daten = NULL, fehler = conditionMessage(e))
})
return(ergebnis)
}
stufe_bezeichnung = function(wert) {
namen = names(STUFE_AUSWAHL)
treffer = namen[STUFE_AUSWAHL == wert]
if (length(treffer) == 0) {
return(wert)
}
return(treffer[1])
}
passe_text_an_geschlecht_an = function(text, geschlecht) {
if (!identical(geschlecht, "weiblich")) {
return(text)
}
text = gsub("\\bDer Patient\\b", "Die Patientin", text)
text = gsub("\\bder Patient\\b", "die Patientin", text)
text = gsub("\\bden Patienten\\b", "die Patientin", text)
text = gsub("\\bvom Patienten\\b", "von der Patientin", text)
text = gsub("\\bdem Patienten\\b", "der Patientin", text)
text = gsub("\\bdes Patienten\\b", "der Patientin", text)
text = gsub("\\bPatienten\\b", "Patientin", text)
text = gsub("\\bPatient\\b", "Patientin", text)
text = gsub("\\bseine\\b", "ihre", text)
text = gsub("\\bseiner\\b", "ihrer", text)
text = gsub("\\bseinem\\b", "ihrem", text)
text = gsub("\\bseines\\b", "ihres", text)
text = gsub("\\bseinen\\b", "ihren", text)
return(text)
}
sammle_item_werte = function(input, items_data) {
werte = sapply(items_data, function(item) {
wert = input[[paste0("item_", item$id)]]
if (is.null(wert)) STUFE_STANDARDWERT else wert
})
names(werte) = sapply(items_data, function(item) item$id)
return(werte)
}
sammle_fussnoten_werte = function(input, items_data) {
werte = list()
for (item in items_data) {
if (!is.null(item$fussnoten)) {
for (idx in seq_along(item$fussnoten)) {
eingabe_id = paste0("fussnote_", item$id, "_", idx)
wert = input[[eingabe_id]]
werte[[eingabe_id]] = if (is.null(wert)) FALSE else wert
}
}
}
return(werte)
}
ermittle_codierungsvorschlaege = function(text, items_data) {
ergebnis = data.frame(
item_id = character(),
item_name = character(),
begriff = character(),
vorschlag_stufe = character(),
stringsAsFactors = FALSE
)
if (is.null(text) || stringr::str_trim(text) == "") {
return(ergebnis)
}
for (item in items_data) {
for (synonym in item$synonyme) {
if (stringr::str_detect(text, stringr::fixed(synonym, ignore_case = TRUE))) {
ergebnis = rbind(ergebnis, data.frame(
item_id = item$id,
item_name = item$name,
begriff = synonym,
vorschlag_stufe = "1",
stringsAsFactors = FALSE
))
break
}
}
}
return(ergebnis)
}
baue_befundtext = function(item_werte, fussnoten_werte, unauffaellige_einblenden, items_data, merkmalsbereiche_data, geschlecht) {
absaetze = character(0)
unbeurteilte_namen = character(0)
for (bereich in merkmalsbereiche_data) {
items_im_bereich = Filter(function(x) identical(x$merkmalsbereich, bereich$id), items_data)
saetze = character(0)
for (item in items_im_bereich) {
wert = item_werte[[item$id]]
if (is.null(wert)) {
wert = STUFE_STANDARDWERT
}
ist_keine_aussage = identical(wert, "keine_aussage")
if (ist_keine_aussage) {
unbeurteilte_namen = c(unbeurteilte_namen, item$name)
}
zeige_stufensatz = !ist_keine_aussage && !(identical(wert, "0") && !isTRUE(unauffaellige_einblenden))
if (zeige_stufensatz) {
satz = item$stufen_text[[wert]]
if (!is.null(satz)) {
saetze = c(saetze, satz)
}
}
if (!is.null(item$fussnoten)) {
for (idx in seq_along(item$fussnoten)) {
eingabe_id = paste0("fussnote_", item$id, "_", idx)
if (isTRUE(fussnoten_werte[[eingabe_id]])) {
fussnote = item$fussnoten[[idx]]
saetze = c(saetze, paste0(fussnote$bezeichnung, " vorhanden."))
}
}
}
}
if (length(saetze) > 0) {
absatz = paste(saetze, collapse = " ")
absaetze = c(absaetze, absatz)
}
}
if (length(unbeurteilte_namen) > 0) {
hinweis = paste0("Nicht beurteilt: ", paste(unbeurteilte_namen, collapse = ", "), ".")
absaetze = c(absaetze, hinweis)
}
if (length(absaetze) == 0) {
return("Keine Befunde erfasst.")
}
text = paste(absaetze, collapse = "\n\n")
text = passe_text_an_geschlecht_an(text, geschlecht)
return(text)
}
# Datenaufbereitung ####
merkmalsbereiche_ergebnis = lade_amdp_datei(file.path(APP_VERZEICHNIS, "merkmalsbereiche.json"))
items_ergebnis = lade_amdp_datei(file.path(APP_VERZEICHNIS, "items.json"))
syndrome_ergebnis = lade_amdp_datei(file.path(APP_VERZEICHNIS, "syndrome.json"))
erlaeuterungen_ergebnis = lade_amdp_datei(file.path(APP_VERZEICHNIS, "erlaeuterungen.json"))
daten_geladen = merkmalsbereiche_ergebnis$erfolg && items_ergebnis$erfolg && syndrome_ergebnis$erfolg && erlaeuterungen_ergebnis$erfolg
ladefehler_meldung = paste(
Filter(Negate(is.null), list(merkmalsbereiche_ergebnis$fehler, items_ergebnis$fehler, syndrome_ergebnis$fehler, erlaeuterungen_ergebnis$fehler)),
collapse = " / "
)
if (daten_geladen) {
merkmalsbereiche_data = merkmalsbereiche_ergebnis$daten
merkmalsbereiche_data = merkmalsbereiche_data[order(sapply(merkmalsbereiche_data, function(b) b$reihenfolge))]
items_data = items_ergebnis$daten
item_ids = sapply(items_data, function(x) x$id)
item_praefixe = gsub("[0-9]+", "", item_ids)
item_nummern = as.integer(gsub("\\D+", "", item_ids))
items_data = items_data[order(item_praefixe, item_nummern)]
syndrome_data = syndrome_ergebnis$daten
syndrome_by_id = setNames(syndrome_data, sapply(syndrome_data, function(s) s$id))
items_by_bereich = split(items_data, sapply(items_data, function(x) x$merkmalsbereich))
erlaeuterungen_data = erlaeuterungen_ergebnis$daten
erlaeuterungen_by_id = setNames(erlaeuterungen_data, sapply(erlaeuterungen_data, function(x) x$item_id))
}
# UI ####
if (!daten_geladen) {
ui = fluidPage(
tags$head(tags$style(HTML(app_css))),
div(class = "alert-fehler", paste("Fehler beim Laden der AMDP-Daten:", ladefehler_meldung))
)
} else {
item_tooltip_inhalt_ui = function(item) {
erl = erlaeuterungen_by_id[[item$id]]
if (is.null(erl)) {
return(NULL)
}
definition_ui = if (nzchar(erl$definition)) {
tagList(tags$span(class = "tooltip-titel", "Definition"), tags$p(erl$definition))
} else {
NULL
}
erlaeuterung_ui = if (nzchar(erl$erlaeuterung)) {
tagList(tags$span(class = "tooltip-titel", "Erläuterung"), tags$p(erl$erlaeuterung))
} else {
NULL
}
beispiele_ui = if (length(erl$beispiele) > 0) {
tagList(
tags$span(class = "tooltip-titel", "Beispiele"),
tags$ul(lapply(erl$beispiele, function(b) tags$li(paste0("„", b, "“"))))
)
} else {
NULL
}
return(div(class = "tooltip-inhalt", definition_ui, erlaeuterung_ui, beispiele_ui))
}
item_zeile_ui = function(item) {
tooltip_inhalt = item_tooltip_inhalt_ui(item)
name_ui = if (!is.null(tooltip_inhalt)) {
span(class = "tooltip-ziel", tabindex = "0", item$name, tooltip_inhalt)
} else {
span(item$name)
}
zeile = div(class = "item-zeile",
span(class = "item-nr", item$id),
span(class = "item-text", name_ui, tags$span(class = "erhebungscode-badge", paste0("(", item$erhebungscode, ")"))),
div(class = "input-panel",
radioButtons(paste0("item_", item$id), label = NULL, choices = STUFE_AUSWAHL, selected = STUFE_STANDARDWERT, inline = TRUE)
)
)
if (is.null(item$fussnoten)) {
return(zeile)
}
fussnoten_ui = lapply(seq_along(item$fussnoten), function(idx) {
fussnote = item$fussnoten[[idx]]
checkboxInput(paste0("fussnote_", item$id, "_", idx), label = fussnote$bezeichnung, value = FALSE)
})
return(tagList(zeile, div(style = "margin-left: 48px;", fussnoten_ui)))
}
merkmalsbereich_panels = lapply(merkmalsbereiche_data, function(bereich) {
items_im_bereich = items_by_bereich[[bereich$id]]
vorbemerkungen_ui = if (!is.null(bereich$vorbemerkungen) && nzchar(bereich$vorbemerkungen)) {
div(class = "vorbemerkungen-text", bereich$vorbemerkungen)
} else {
NULL
}
tags$details(class = "bereich-panel",
tags$summary(class = "bereich-titel",
tags$h2(bereich$name),
tags$span(class = "bereich-hinweis", "Zum Aufklappen anklicken")
),
vorbemerkungen_ui,
lapply(items_im_bereich, item_zeile_ui)
)
})
syndrom_auswahl_choices = setNames(
sapply(syndrome_data, function(s) s$id),
sapply(syndrome_data, function(s) s$name)
)
ui = fluidPage(
tags$head(tags$style(HTML(app_css))),
div(class = "app-titel", "AMDP-Befundgenerator"),
div(class = "abschnitt-karte",
tags$h2(class = "abschnitt-titel", "Einstellungen"),
radioButtons("geschlecht", label = "Geschlecht", choices = GESCHLECHT_AUSWAHL, selected = GESCHLECHT_STANDARDWERT, inline = TRUE),
actionButton("zuruecksetzen", "Formular zurücksetzen", class = "btn-laden"),
tags$h2(class = "abschnitt-titel", "Syndrom-Schnellauswahl"),
div(class = "syndrom-liste",
checkboxGroupInput("syndrom_auswahl", label = NULL, choices = syndrom_auswahl_choices)
),
actionButton("syndrom_uebernehmen", "Übernehmen", class = "btn-laden")
),
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Item-Erfassung"),
merkmalsbereich_panels
),
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Freitext-Befund"),
textAreaInput("freitext_befund", label = NULL, rows = 6, width = "100%"),
actionButton("freitext_ermitteln", "Codierungsvorschlag ermitteln", class = "btn-laden"),
uiOutput("freitext_vorschlaege_ui")
),
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Befundtext"),
checkboxInput("unauffaellige_einblenden", "Unauffällige Befunde einblenden", value = FALSE),
actionButton("text_generieren", "Fließtext generieren", class = "btn-laden"),
textAreaInput("befundtext", label = NULL, rows = 15, width = "100%"),
downloadButton("download_docx", "Als Word-Dokument exportieren")
)
)
}
# Word-Export ####
erstelle_amdp_befund_docx = function(befundtext, datum) {
dokument = officer::read_docx()
titel_format = officer::fp_text(color = AKZENT_FARBE, bold = TRUE, font.size = 18)
titel_absatz = officer::fpar(officer::ftext(paste0("AMDP-Befund vom ", datum), prop = titel_format))
dokument = officer::body_add_fpar(dokument, titel_absatz)
dokument = officer::body_add_par(dokument, "", style = "Normal")
absaetze = strsplit(befundtext, "\n\n", fixed = TRUE)[[1]]
for (absatz in absaetze) {
dokument = officer::body_add_par(dokument, absatz, style = "Normal")
}
dokument = officer::body_add_par(dokument, "", style = "Normal")
disclaimer_text = "Dieser Text ist ein Hilfsmittel zur Befunddokumentation und ersetzt keine eigenständige klinische Beurteilung. Automatisch bzw. heuristisch ermittelte Codierungsvorschläge wurden vor Übernahme fachlich geprüft."
dokument = officer::body_add_par(dokument, disclaimer_text, style = "Normal")
return(dokument)
}
# Server ####
if (!daten_geladen) {
server = function(input, output, session) {}
} else {
server = function(input, output, session) {
observeEvent(input$syndrom_uebernehmen, {
for (syndrom_id in input$syndrom_auswahl) {
syndrom = syndrome_by_id[[syndrom_id]]
for (eintrag in syndrom$items) {
updateRadioButtons(session, paste0("item_", eintrag$item_id), selected = as.character(eintrag$stufe))
}
}
})
vorschlaege = reactiveVal(data.frame(
item_id = character(), item_name = character(), begriff = character(), vorschlag_stufe = character(),
stringsAsFactors = FALSE
))
observeEvent(input$zuruecksetzen, {
for (item in items_data) {
updateRadioButtons(session, paste0("item_", item$id), selected = STUFE_STANDARDWERT)
if (!is.null(item$fussnoten)) {
for (idx in seq_along(item$fussnoten)) {
updateCheckboxInput(session, paste0("fussnote_", item$id, "_", idx), value = FALSE)
}
}
}
updateCheckboxGroupInput(session, "syndrom_auswahl", selected = character(0))
updateRadioButtons(session, "geschlecht", selected = GESCHLECHT_STANDARDWERT)
updateCheckboxInput(session, "unauffaellige_einblenden", value = FALSE)
updateTextAreaInput(session, "freitext_befund", value = "")
updateTextAreaInput(session, "befundtext", value = "")
vorschlaege(vorschlaege()[0, ])
})
observeEvent(input$freitext_ermitteln, {
treffer = ermittle_codierungsvorschlaege(input$freitext_befund, items_data)
vorschlaege(treffer)
})
output$freitext_vorschlaege_ui = renderUI({
treffer = vorschlaege()
if (nrow(treffer) == 0) {
return(div(class = "alert-warnung", "Keine Treffer im Freitext gefunden."))
}
zeilen = lapply(seq_len(nrow(treffer)), function(i) {
zeile = treffer[i, ]
div(class = "item-zeile",
span(class = "item-nr", zeile$item_id),
span(class = "item-text", paste0(
zeile$item_name, " — Treffer: \"", zeile$begriff, "\", Vorschlag: ", stufe_bezeichnung(zeile$vorschlag_stufe)
)),
actionButton(paste0("apply_", zeile$item_id), "Übernehmen", class = "btn-laden")
)
})
tagList(
tagList(zeilen),
actionButton("freitext_alle_uebernehmen", "Alle Vorschläge übernehmen", class = "btn-laden")
)
})
observeEvent(input$freitext_alle_uebernehmen, {
treffer = vorschlaege()
for (i in seq_len(nrow(treffer))) {
updateRadioButtons(session, paste0("item_", treffer$item_id[i]), selected = treffer$vorschlag_stufe[i])
}
vorschlaege(treffer[0, ])
})
for (aktuelles_item in items_data) {
local({
item_fuer_beobachter = aktuelles_item
observeEvent(input[[paste0("apply_", item_fuer_beobachter$id)]], {
treffer = vorschlaege()
zeile = treffer[treffer$item_id == item_fuer_beobachter$id, ]
if (nrow(zeile) > 0) {
updateRadioButtons(session, paste0("item_", item_fuer_beobachter$id), selected = zeile$vorschlag_stufe[1])
vorschlaege(treffer[treffer$item_id != item_fuer_beobachter$id, ])
}
}, ignoreInit = TRUE, ignoreNULL = TRUE)
})
}
aktualisiere_befundtext = function() {
item_werte = sammle_item_werte(input, items_data)
fussnoten_werte = sammle_fussnoten_werte(input, items_data)
text = baue_befundtext(item_werte, fussnoten_werte, input$unauffaellige_einblenden, items_data, merkmalsbereiche_data, input$geschlecht)
updateTextAreaInput(session, "befundtext", value = text)
}
observeEvent(input$text_generieren, {
aktualisiere_befundtext()
})
observeEvent(input$unauffaellige_einblenden, {
aktualisiere_befundtext()
}, ignoreInit = TRUE)
observeEvent(input$geschlecht, {
aktualisiere_befundtext()
}, ignoreInit = TRUE)
output$download_docx = downloadHandler(
filename = function() {
paste0("AMDP-Befund_", format(Sys.time(), "%Y%m%d_%H%M"), ".docx")
},
content = function(datei) {
dokument = erstelle_amdp_befund_docx(input$befundtext, format(Sys.Date(), "%d.%m.%Y"))
print(dokument, target = datei)
}
)
}
}
# Start ####
shiny::shinyApp(ui = ui, server = server)