An rah handler that scores a REDCap alert through a scikit-learn random forest and writes the predictions back into the record.
An alert fires, its email body carries the model's inputs as TOML, this handler
runs the regressor, and the predicted values go back to REDCap through the API.
The model itself is a RandomForestRegressor you've trained and saved with
joblib; scripts/simulate_panas.py builds an example one from simulated PANAS
data if you want something to test against.
This handler runs under an rah route. Here's a route that handles alerts whose
subject is rf_predict:
[routes.rf_predict]
handler = "rah-random-forest:predict"
# A TOML file holding the REDCap API url and token (see below).
redcap_info_file = "/path/to/secrets/redcap.toml"
# The REDCap field that holds the record id. It's read from the alert body and
# written to the imported record.
redcap_id_field = "record_id"
# A joblib dump of {"model": rf, "columns": [...], "targets": [...]}, the shape
# scripts/simulate_panas.py writes. If it won't load, every message on the
# route fails permanently.
model_file = "/path/to/models/my_rf.joblib"
# How the model's input columns are filled from the alert body:
# model_column = "alert_field"
# Every column the model expects needs an entry here. An alert can name its
# fields whatever it likes; this is where the two get reconciled.
[routes.rf_predict.input_fields]
panas20_q01 = "q1"
panas20_q02 = "q2"
# ...one line per model column
# Where each prediction lands in REDCap:
# model_target = "redcap_field"
# You can write every target or just the ones you care about.
[routes.rf_predict.target_fields]
pa = "pa_zscore"
na = "na_zscore"
ar = "ar_zscore"Both maps are keyed by the model's own names -- its columns on the input side,
its targets on the output side. That's deliberate. The model is the fixed thing
here: its columns and targets are baked into the joblib, and the alert and the
REDCap project are free to name their fields however they want. Keying on the
model means the handler can check the config against the model up front and tell
you exactly which column has no mapping, instead of finding out halfway through
the first live message. A route whose input_fields misses a column, or whose
target_fields names a target the model doesn't produce, can't work for any
message, so the handler rejects it before it looks at the body.
You don't have to wait for a live message to find out whether the maps line up.
rah doctor asks this handler to check its own route, so a config that doesn't
match the model shows up under the checkups line, naming the column or target
that's wrong. rah process runs the same check at startup and reports it, then
starts anyway -- a broken route still takes its mail, and each message fails
where you can see it.
Set dry_run = true on the route (or in [global]) to run everything except
the REDCap write. The handler logs the exact JSON it would have imported and
leaves the message in place.
A separate TOML file, kept out of the main config because it holds a secret:
url = "https://redcap.example.edu/api/"
token = "..." # the project's API tokenThe email body is TOML: the record id under whatever key you set as
redcap_id_field, plus one entry for each alert field named in input_fields.
For the route above:
record_id = "1023"
q1 = 3
q2 = 5
# ...through q20Values are cast to numbers before they reach the model, so a stray non-numeric value (or a field the alert forgot to send) fails completely rather than feeding the model garbage.
uv sync
uv run pytest