REST API¶
Base URL: http://127.0.0.1:8000 (local)
GET /health¶
Returns service status and config flags (no PHI).
Includes: status, classifier_gemini (bool, the effective value), inbox_max_pages (effective), vertex_gemini_model, vertex_gemini_fallback_model (effective), vertex_cooldowns ({model: seconds left} after a persistent 429/5xx), runtime_settings ({values, source: {key: "file"|"env"}, updated_at, updated_by, override_count}), loinc_mapper_ready, mapping_registry_version (the published registry), pending_approvals (approvals that wait for a publish), pending_applied (how many of them the running mapper applies), pending_view_version and mapper_package ({name, version, build} of the mapper package the service loaded from the bucket) (BUG-88). With a mapper package that lacks the review interface, loinc_mapper_ready is false and loinc_mapper_error names the missing piece.
Settings¶
The Settings page's API. Values are counts, ratios, booleans and a model id — never a secret. A saved override applies to the next fax on both paths and beats env until cleared.
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/settings |
{schema_version, updated_at, updated_by, override_count, fields:[{key, label, help, kind (int, ratio, bool, text), min, max, max_length, value, source, env_value, override}]} — one entry per overridable key: inbox_max_pages, abstain_warn_ratio, abstain_warn_count, hold_unfiled_ratio, hold_min_lab_rows, loinc_judge_allow_medium, elation_new_chart_on_no_match, classifier_gemini, vertex_gemini_fallback_model |
| PUT | /api/v1/settings |
Body = the override set ({"inbox_max_pages": 10, "classifier_gemini": true, "hold_min_lab_rows": null}); a null or absent key means the env value applies again. Returns the GET view plus changed (["inbox_max_pages:50->10"]). 422 on an unknown key, a value out of bounds or hold_ratio_below_warn_ratio; 503 runtime_settings_write_failed. Audited as runtime_settings_saved by=<session user> changed= |
GET /api/v1/parsers¶
List OCR and extractor plugins with enabled status.
POST /api/v1/parse¶
Multipart form upload (file, ocr_parsers, extract_parsers, optional classifier_gemini=true|false). Missing classifier_gemini uses the CLASSIFIER_GEMINI env (fail closed). The extract-page checkbox is this parse only; inbox poll does not send the field. Streaming variant: POST /api/v1/parse/stream (NDJSON). One parse runs at a time in the service (BUG-89): while the inbox or another upload is being parsed, both routes answer 409 with a sentence and Retry-After: 60; a stream that loses the race ends with {"stage": "error", "status": "busy", "message": …}. GET /health → parse_slot {busy, owner, held_s}.
POST /internal/inbox/poll (Cloud Scheduler, X-Inbox-Poll-Token) answers 202 {ok, accepted, submit, chain} at once; the fax is processed by the background chain (chain: running, rounds, items, errors, deferred, held, last_reason). ?dry_run=true is a synchronous listing (dry_run: true, counts, items with their skip reason). Outside clinic hours or while a chain runs it answers 200 skipped with the reason.
Completed jobs include image_fidelity (page counts, pixel min/max, wrapper img2pdf|pillow|original_pdf, recompressed, low_resolution). Numbers only.
An upload with more pages than the effective inbox_max_pages is refused before any parse: 413 too_many_pages: N pages, cap M (Settings → Max pages); 0 disables the cap.
The result's filing_plan.verdict is the filing verdict: {status: ok|warn|hold, reason, lab_rows, filed_rows, unfiled_rows, unfiled_ratio, needs_communication, thresholds}. Unfiled = lab rows not filed to the grid. warn files and flags the fax for further communication; hold files nothing until a person overrides (below). Each segment carries its own verdict for the note.
GET /api/v1/elation/report-types¶
Elation document-type catalog for the harness dropdown (report_type, display_value).
POST /api/v1/jobs/{job_id}/file-to-elation¶
File a stored parse job to Elation.
JSON body (all optional):
| Field | Description |
|---|---|
segment_id |
Filing segment; default first |
report_type |
Elation catalog label (fail closed if unknown). Source returned as report_type_source: classifier or manual |
override_hold |
true files a job whose verdict says hold — a person's decision, logged as filing_hold_overridden job_id= by=<session user> and returned as hold_overridden_by. Without it a held job answers 409 filing_held and nothing is posted |
The response also carries note_grid (an Imaging Result posted its verbatim sections on a results-less grid), note_posted (the non-visit-note channel: true, false on failure — the report stays filed — or null when there was nothing to post) and review_reason (imaging_result when a person should review the copied sections).
LOINC review¶
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/loinc-review/cases |
open_only, status, dispute_only, needs_attention, auto_filed_only, q (test name, printed/repaired name, code, status, job or case id), job_id, limit; the response carries limit and truncated. A case file written before questions existed is never listed |
| GET | /api/v1/loinc-review/case-counts |
{open, attention, disputed, auto_filed, ops, all, legacy} for the filter chips; legacy = case files kept on disk and shown nowhere |
| GET | /api/v1/loinc-review/cases/{case_id} |
Mapping facts + opaque IDs; name (the question's name), question ({id, key_version, facts:[{key, label, words}]}), laboratories_seen, decisions (where each decision stands: state approved, published, replaced, returned, withdrawn or recorded; code, reviewer_name, registry_version, replaced_by, reason_code, message, not_applied), kept, choice (what separates the codes of a tie), policy_defaults, primary_outcome, diagnostics, panel (heading, source, member count, panel_key = the index record a required_panel approval may name), blocked_approved_code (the clinician-approved code the mapper blocked (rejected, or held for its unit), from the mapper's primary outcome only, else ""), refused_approvals ([{code, axis, reason_code, state}]: the approvals of the name the latest row did not take because it prints another fact) and refused_line (the card's sentence for it, else ""), repair_provenance.printed_name (never the upload filename) |
| GET | /api/v1/loinc-review/cases/{case_id}/decision |
The saved decision to prefill the form: the active one, else the newest withdrawn one, else null |
| POST | /api/v1/loinc-review/cases/{case_id}/preview |
{selected_loinc} and nothing else. What approving the code would mean, before anything is written: {state: new, confirms, conflict or refused; message; selected{code, display}; earlier{decision_id, code, display, reviewer_name, reviewed_at, state, registry_version}; refusal{axis, reason_code}; method_hint; scope}. Takes no mapper lock. 503 mapper_unavailable |
| GET | /api/v1/loinc-review/quick |
kind=question (default) or inference → {kind, counts{question, inference}, cards}; a card carries the question's facts, laboratories, tie, preselected, and per code separates |
| POST | /api/v1/loinc-review/cases/{case_id}/quick-decision |
{case_revision, selected_loinc, reviewer_id, reviewer_name, choice, earlier_decision_id}; nothing else is accepted. choice is empty (approve), keep (no decision is written, the card closes as kept:<decision>) or replace (the approval supersedes earlier_decision_id, copied from the preview). 400 conflict or refused with the outcome; 409 conflict_changed when the earlier answer is no longer the one the reviewer saw |
| POST | /api/v1/loinc-review/quick/confirm-all |
{reviewer_id, reviewer_name} → {confirmed, left:[{case_id, reason}], counts}; each filing by inference goes through the preview, a conflict is never replaced in a batch |
| GET | /api/v1/loinc-review/ops-counts |
{total, counts:[{reason, name, count, last_seen_at}]}: what was filed or set aside without a doctor's case (flag:<review flag>, policy:<axis>, hold:<reason> for an approved code that waits for its unit, no_question:<status>) |
| POST | /api/v1/loinc-review/disputes |
Open a case for a mapped-but-wrong code. {job_id, row_id} (the harness job store, else the job's fax record) or {raw_name, ...}. Extra patient-like keys rejected. |
| POST | /api/v1/loinc-review/cases/{case_id}/decision |
Clinician decision; an unknown key is refused (422). choice and earlier_decision_id as on the quick decision. required_panel (approve_universal only) names the panel index record the case resolves to — validated against the case's heading with the mapper's panel index. An approval answers state: approved and applies from the next fax |
| POST | /api/v1/loinc-review/cases/{case_id}/reopen |
Withdraw every decision that answers the case and reopen |
| POST | /api/v1/loinc-review/publish |
Compile → replay → active registry, per item (dry_run=true first, it changes nothing; policy=strict_replay for operators) → {published, already_published, returned:[{decision_id, case_id, reason_code, message, other}], deferred_source_specific, active_version, worker_loaded, …}. 400 no_decisions when no approval waits |
| GET | /api/v1/loinc-review/search |
Active LOINC search, on the catalog's own read-only connection |
| GET | /api/v1/loinc-review/export.csv |
Review CSV (503 when the mapper package is not importable) |
Faxes¶
The fax explorer reads the fax record (FAX_DATA_DIR/fax_records/, one file per job with every attempt), the journeys and the review cases. Ids, dates, counts and rows with their values — never patient names; the only patient field is the Elation patient_id a report was filed to.
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/faxes |
q (a numeric value matches the inbox id, an Elation report id or a patient id; anything else is a job-id prefix), from/to (YYYY-MM-DD, else 400 invalid_date), report_type, decision (auto_file, staging_file, new_chart_file, triage), outcome (filed, partial, parsed, failed, skipped, held, legacy), needs_communication=true (warn/hold verdicts and held items), limit ≤ 500 → {count, items}; each item carries verdict (ok, warn, hold or empty) and needs_communication; latest attempt per job, newest first |
| GET | /api/v1/faxes/{job_id} |
{job_id, record, journey, journeys_by_attempt, review_cases, links, legacy, notices}; each attempt carries verdict (the numbers, hold_overridden/hold_overridden_by after an override), held_reason and, per segment, review_reason; 404 fax_not_found; 500 record_contains_forbidden_fields when a stored record carries a patient key |
| GET | /api/v1/faxes/{job_id}/elation-report/{report_id} |
Read-back from Elation: {report_id, report_type, result_count, grids:[{note, note_chars, result_count}], read_at}; on an Imaging report note is empty and note_chars says how long the verbatim note is (the text stays in Elation); 404 unless the report belongs to the fax, 503 without Elation credentials, 502 with the HTTP status only |
Communication¶
Who is told about a fax, where, and in which mode (faxautomation/api/routes/communication.py). Staff, settings and
the state of notices: ids, staff names, counts and codes. The credential is never returned, the words of a clinical
note are never returned, and a vendor's message is never passed through: a failure is a short code.
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/communication/status |
{mode, saved_mode, ceiling, blockers, file_problem, connection{state, organization_name, members}, credential_suffix, organization{id, name, pin}, limits{hour_left, day_left, breaker_open, breaker_code, failures}, steps{off, dry_run, pilot, live}, open_notices, test_note_verified_at, revision}. connection.state is not_configured, refused, unreachable, wrong_organization, connected or ready. steps lists per mode what keeps it from being switched on |
| GET | /api/v1/communication/directory/members |
?refresh=true. {members:[{member_id, name, kind, suspended}], count}; never an email. 503 directory_unavailable:<code> |
| GET | /api/v1/communication/directory/conversations |
notes and team conversations without a contact: {conversations:[{conversation_id, kind, title, internal, archived, endpoint_id}], count} |
| GET | /api/v1/communication/directory/physicians |
the practice's doctors from Elation: {physicians:[{physician_id, name}], count} |
| POST | /api/v1/communication/directory/conversations/lookup |
{link}: the conversation a person pasted. 422 not_a_conversation, not_found, or not_internal for a patient's conversation, whose title is never returned |
| GET | /api/v1/communication/settings |
the settings file as it is, with file_problem (unreadable, invalid or empty) |
| PUT | /api/v1/communication/settings |
the editable part and revision. Names are stamped from the directories, whatever the body says. mode, organization_id, test_note or an unknown key: 422. 409 settings_revision_mismatch; 422 {error: unknown_member, member_suspended, unknown_physician or thread_not_internal, detail}, duplicate_physician, daily_limit_below_hourly, invalid_patient_id |
| POST | /api/v1/communication/organization |
the settings adopt the organization the credential answers for, when it is the pinned one. 409 connection_<state> |
| POST | /api/v1/communication/mode |
{mode}. 409 {error: mode_blocked, blockers} with every blocker; off always works. Lowering the mode cancels what was not sent; off resets the breaker |
| POST | /api/v1/communication/test-note |
takes nothing (any key is 422): fixed synthetic words to where notices about faxes go (the picked team conversation, or the owner's own thread, which the first note makes), paging the owner. At most 5 an hour. 409 with what is missing |
| POST | /api/v1/communication/test-note/{key}/check |
one look at the conversation: {notice, verified}. verified = found, internal, nobody paged but the owner; the settings then record it |
| GET | /api/v1/communication/notices |
state, kind, limit ≤ 200 → {notices, count}, newest first |
| GET | /api/v1/communication/notices/{key} |
the route taken, the checks, the history. words is the exact text of a note that carries no text of a report; for a clinical note words_withheld: true and sections holds section names with character counts |
| POST | /api/v1/communication/notices/{key}/cancel |
{state} as the page saw it. 409 notice_changed, not_cancellable |
| POST | /api/v1/communication/notices/{key}/retry |
{state}. A blocked notice is tried as it is; a failed one as a new generation without the text of the report. 409 notice_changed, not_retryable |
| GET | /api/v1/communication/routes/gaps |
the doctors whose notices went to the fallback coordinator: {gaps:[{physician_id, physician_name, notices}], count} |
GET /api/v1/faxes/{job_id} carries notices: one entry per notice about the fax, as the outbox has it now.
GET /health carries communication{mode, saved_mode, ceiling, blockers} for a signed-in caller.
Pages¶
/, /loinc-review, /quick-review, /faxes, /faxes/{job_id}, /communication and /settings are composed server-side (web/pages.py) from one shell and a fragment under web/static/pages/. Cookie login when APP_AUTH_ENABLED; an API call without a session gets 401 {"detail":"unauthorized"} and the page scripts send the person to /login?next=….
Errors¶
| Code | Cause |
|---|---|
| 400 | Disabled parser, unknown report_type, invalid dispute row |
| 401 | App auth required |
| 404 | Unknown parser, expired job, missing case |
| 409 | filing_held — the job's verdict holds it; send override_hold: true to file on a person's decision. conflict_changed — the earlier answer of a review question changed since the preview |
| 413 | too_many_pages — the upload is over the page cap (Settings → Max pages) |
| 422 | Schema / extra forbidden fields; a settings value out of bounds or hold_ratio_below_warn_ratio |
| 503 | runtime_settings_write_failed — the settings file could not be written |