Automated reference capture

Feb 20, 2024 · 8 min
marketplace

A travel nurse needs two professional references to submit to most jobs. Two names, two phone numbers or email addresses, a relationship type, a few ratings, a would-rehire flag. On paper, this is a small problem. In practice it has been one of the most stubborn manual steps in the clinician submission pipeline, because nothing about it sits in the clinician’s direct control.

The clinician supplies the contact. The contact has to actually respond. The platform sits between them, with two channels (SMS and email), a clock that’s always running against the submission deadline, and a downstream packet that can’t go out without a properly-formatted PDF in the right slot. Three actors, one sequence, and until recently, a person doing most of the chasing.

The design decisions that matter most turned out to be operational rather than architectural.

Why references were the last to go

References resisted automation longer than licenses or certifications for a structural reason. A license is a thing you have. A reference is a thing someone else has to do for you. The platform can verify the first; for the second, the best it can do is make the request, send the form, and follow up at the right cadence without becoming annoying enough that the contact ignores it altogether.

That last part is the design problem. Every reminder you send is a step toward completion and a step toward fatigue. Push too hard and the contact archives the email; push too softly and the request expires while the clinician misses a submission window.

Reference lifecycle as a swimlane diagram across three actors. Clinician lane: Adds contact. Platform lane: Request sent (automated), Idle check at a 2-day threshold (automated), and on success Response and PDF attached (automated). Contact lane: Receives form; if idle, the platform fires a Chase reminder which loops back into the idle check until a response arrives.

What the model actually tracks

The data model for a reference is small. It has to remember who the contact is, how to reach them, what relationship they had to the clinician, and where the request currently sits in its lifecycle. The interesting constants live near the top of the model and double as the operational dials we tune.

class Reference < ApplicationRecord
  REFERENSE_RESPONSE_IDLE_DAYS = 2

  RELATIONSHIPS = {
    manager: "Manager",
    charge_nurse: "Charge Nurse"
  }.freeze

  PREFERRED_CONTACT_METHODS = {
    sms: "SMS",
    email: "Email"
  }.freeze
end

A few things are worth pulling out.

The constant name REFERENSE_RESPONSE_IDLE_DAYS carries an extra n that nobody has been brave enough to rename. The code is older than the current team and has been doing real work the whole time.

The two acceptable relationships are deliberately narrow. Hospitals want references from someone in the clinician’s reporting chain at a recent job; manager and charge_nurse encode that constraint at the model level. A peer is not a reference, and the form won’t let you submit one.

The two channels are also deliberate. We considered offering a third option for “both,” and rejected it. One channel per reminder means one place to land and one place to track engagement. Double-sending reads less like a helpful nudge and more like a robot leaning on a doorbell.

REFERENSE_RESPONSE_IDLE_DAYS = 2 is the most operationally sensitive number in the file. It’s the threshold the chase jobs use to decide whether a reference has gone quiet. Two days is short enough to recover a stalled request inside a typical submission window, and long enough that most responsive contacts answer before the chase fires at all. We’ve nudged it before. Every nudge changes the shape of the funnel further down---completion timing, fallback rates, advocate workload---enough that the constant lives in code and not in a config table. Changing it requires reading the code that depends on it, which is the point.

The chase

The actual chasing is four Sidekiq jobs. Listed by their role in the sequence:

  • ReferencePendingNotificationEnqueuerJob is the scheduler. Runs on a cadence, finds references that are still outstanding past the idle threshold, and enqueues the per-reference jobs below. It’s the only job that knows about the population of references; the others operate on one record at a time.
  • ReferencePendingNotificationJob is the first reminder. Fires once a reference has been idle for REFERENSE_RESPONSE_IDLE_DAYS. Sends a follow-up on the contact’s preferred channel.
  • ReferenceChaseNotificationJob is the secondary chase. Runs after extended silence past the first reminder, with copy that’s a notch firmer and a fallback path that can hand the case to a human if the contact still hasn’t responded.
  • ReferenceCompletedNotificationJob is the closer. Fires the moment the contact submits the form. It notifies the clinician (so they can see their packet move forward) and is the trigger that kicks off PDF generation.

The split between the enqueuer and the per-reference jobs is the only piece of architecture worth dwelling on. The enqueuer is cheap and idempotent; it can run on whatever cadence we want without consequence. The per-reference jobs are where the actual work happens, and they retry cleanly because each one is scoped to a single record. When something goes wrong with a single reference---a phone number that bounces, an email server that refuses delivery---the failure stays contained to that one job and surfaces in a way an on-call engineer can read without context-switching.

Everything else about the chase is operational. The cadence is tuned, not designed. The copy has been rewritten more times than the code. A chase that respects the contact’s time lands better than one optimized for the platform’s time.

The PDF is the artifact

When a reference completes, the system needs to produce a PDF. The submission packet is, in the end, a stack of documents. The reference contributes one of them, and what goes into the packet is not the database record but the rendered file.

We handle this with a small concern, GeneratesReferencePdfs, attached to the Reference model. The pattern is straightforward: an after_commit hook detects the transition to a completed state, and an exported_pdf_attachment method is responsible for producing and caching the file. The render itself is done by Core::HtmlToPdfConverter, our headless-browser wrapper that renders the same HTML view a human would see if they opened the reference in our admin UI.

The cache matters more than it looks like it does:

def exported_pdf_attachment
  exported_pdf || generate_exported_pdf!
end

Submission packets are assembled and reassembled on a new application, on a regenerated packet, on a packet preview, on a download from the admin UI. Each of those was, at one point, a cause of redundant PDF rendering. Headless-browser PDF generation is not free; doing it once and caching the attachment removes a non-trivial amount of platform overhead from every step downstream. The pattern is simple enough that it’s easy to overlook how much it’s saving us.

The cache is invalidated when the rendered output would actually change---a profile edit that affects what the reference page shows, for instance---by detaching the old PDF and re-rendering on next read. It’s the same pattern as a memoized derived field, applied to a binary artifact.

AI verification

Last year we shipped AI auto-verification for references. Before that, every completed reference went through a human auditor who confirmed it met the qualitative bar---the relationship was appropriate, the would-rehire flag was set, the employment dates were recent enough---before the reference could count toward the clinician’s submission-ready state.

The auto-verifier uses the same Form pattern from agents that fill out forms, repurposed for a different surface. The same idea applies: don’t let the model write directly to the system of record, give it a form, share the validation contract with the human UI. The agent looks at the completed reference, fills out the verification form, and submits it through the same validation layer a human auditor uses. When the form passes, the reference is verified. When it doesn’t, the case escalates to a human reviewer with the agent’s reasoning attached.

The architectural lift was small because the pattern was already in production. The actual work was in the prompt, the eval suite, and the confidence threshold for auto-escalation. Once a pattern is the right shape, the second application of it costs a fraction of the first.

What’s still manual

Three parts of the reference flow stay hands-on. We didn’t miss them; we left them there on purpose.

Contact resolution is manual. When a clinician supplies a phone number that bounces or an email address that doesn’t exist, an advocate has to ask the clinician for a different one. The conversation is a thirty-second exchange in chat. Automating it would mean building inbound-error parsing, retry policies, clinician-facing prompts, and a fallback for when the second number also bounces---all to replace a thirty-second message. The math doesn’t work, and we’ve checked.

Initial contact selection is manual. We can suggest references from a clinician’s work history, and we do, but the final pick belongs to the clinician. There’s a difference between “the system thinks this person can vouch for you” and “you’re comfortable putting this person’s name in front of a hospital.” That call is theirs, and putting a model in the middle of it would be a worse product, not a better one.

Ambiguous relationship classification is manual. The manager-versus-charge-nurse line is clear most of the time. When the contact themselves disputes the categorization on the form, a human handles it. A reference rejected by the VMS for the wrong relationship type can blow a submission window; the rate at which this happens is low, the cost of getting it wrong is high, and a human asking a clarifying question takes a minute. We’ll automate this when we can show the model is at least as good as the advocate, and not before.

--- Engineering

← back to posts