Nxtcure
Search Files

GalenOps

Background

GalenOps is a rudimentary implementation of the NxtCure AI-CRO vision: automate the repeatable, document-heavy Contract Research Organization functions in phased horizons, and build the company around a cross-functional agent orchestration layer that coordinates every module end to end. The repository lives at ~/void/www/git/medicalapps/galenops and is not source controlled in this monorepo.

A rudimentary implementation of the NxtCure AI-CRO vision: automate the repeatable, document-heavy CRO functions in phased horizons, keep a human-in-the-loop as a feature, not a limitation, and build the company around the one durable differentiator — a cross-functional agent orchestration layer that coordinates every module end to end and escalates to a human only on exception.
--GalenOps README

What is an agent? UNIX + LLM. GalenOps takes the reductionist reading seriously: the “AI” in the product is deterministic Elixir with explicit seams marked for models, so trial management gets guarantees first and inference later. Every automated judgment that is medical, safety, regulatory, or financial in nature terminates in a human review queue rather than a side effect.

Applications

The repository is a four-app Phoenix 1.8 monorepo. There is no umbrella; four independent Mix projects are wired together over HTTP:

Only the backend has a Repo. The portal, mobile, and Luna apps hold no state of their own; each is a pure client of the backend JSON API through a thin Req wrapper. The portal version reads as:

defmodule GalenopsPortal.Backend do
  @moduledoc """
  Thin HTTP client for the Galenops backend JSON API.

  All portal LiveViews go through this module; the portal holds no state of
  its own. Functions return plain decoded maps; `{:error, reason}` on
  transport or non-2xx responses.
  """

  def dashboard, do: get("/dashboard")

  def studies, do: get_data("/studies")
  def study(id), do: get_data("/studies/#{id}")
  def create_study(attrs), do: post("/studies", %{study: attrs})

With the HTTP plumbing at the bottom of the module amounting to little more than:

  defp get(path, params \\ []) do
    handle(Req.get(base_url() <> path, params: params, retry: false))
  end

  defp handle({:ok, %Req.Response{status: status, body: body}}) when status in 200..299,
    do: {:ok, body}

  defp handle({:ok, %Req.Response{status: status, body: body}}),
    do: {:error, "backend returned #{status}: #{inspect(body)}"}

  defp handle({:error, exception}),
    do: {:error, "backend unreachable: #{Exception.message(exception)}"}

This is the entire integration surface between the four services. No shared database, no message bus, no service mesh. Well-ordered filesystem, efficient automation.

Domain Model

The backend organizes its Ecto contexts into the four pillars of CRO work, in galenops_backend/lib/galenops/:

Two more contexts carry the differentiator: Galenops.HumanLoop is the review queue, and Galenops.Orchestration is the agent-run audit trail plus a twelve-step study lifecycle checklist. Finally Galenops.Luna treats the participant companion as a retention and safety sensor: chat messages with a classified intent, check-ins rating feeling 1 through 5, tracked items, item logs, and appointments. All schemas use binary_id primary keys and UTC timestamps.

Automation Engine

Galenops.Automation is a single 1089 line module of pure functions over the domain: draft_protocol/1, assess_feasibility/2, forecast_supply/2, score_site_risk/1, triage_adverse_event/1, resolve_data_query/1, draft_csr/1, generate_data_queries/1, generate_tlfs/1, audit_tmf/1, check_site_activation/2, predict_retention/1, and watch_termination_risk/1. The heuristics are not hand waving: the module carries the SPIRIT 2013/2025 protocol completeness checklist as @spirit_checklist, a fourteen day query-aging SLA as @query_aging_sla_days, and a kit buffer of 1.15 for supply forecasting. Each red flag rule in watch_termination_risk/1 is traceable to a citation in docs/intent/lit_review_synthesis.md.

The escalation posture is visible directly in the code. Adverse event triage never lets software decide causality:

  def triage_adverse_event(%AdverseEvent{} = ae) do
    if ae.serious or ae.severity == "severe" do
      {:ok, ae} = DataSafety.update_adverse_event(ae, %{status: "under_review"})

      {:ok, task} =
        HumanLoop.open_task(
          "adverse_event",
          ae.id,
          "assess_serious_event",
          "Serious/severe adverse event requires safety physician assessment: #{String.slice(ae.description || "", 0, 140)}",
          "critical"
        )

      {:ok, %{adverse_event: ae, review_task: task}}
    else
      {:ok, ae} =
        DataSafety.update_adverse_event(ae, %{
          status: "assessed",
          assessment:
            "Auto-assessed as non-serious (#{ae.severity}). Included in aggregate safety reporting; no expedited report required."
        })

      {:ok, %{adverse_event: ae, review_task: nil}}
    end
  end

Alongside the automation engine, Galenops.Integrations marks the build-versus-partner boundary with three simulated vendor surfaces: MatchingEngine for EHR/RWD candidate matching, SupplyVendor for IRT/RTSM forecasting, and PaymentsVendor for milestone site payments. The matching engine never auto-enrolls; the recruitment agent always opens an import_matched_candidates review task.

Agent Orchestration

Galenops.Orchestration.Pipeline in lib/galenops/orchestration/pipeline.ex is the coordinator. An ordered list of thirteen agents walks the study lifecycle: feasibility_agent, protocol_agent, site_activation_agent, recruitment_agent, monitoring_agent, cdm_agent, safety_agent, retention_agent, termination_watch_agent, supply_agent, biostats_agent, medical_writing_agent, and tmf_agent. Each agent is a defp run_agent(:name, study) clause returning {:done, reasoning, task_or_nil} or {:skip, reasoning}, calling into Galenops.Automation and Galenops.Integrations.

The main loop wraps every agent so that a crash is itself an escalation, never a silent failure:

  def run_study_pipeline(%Study{} = study) do
    runs = for {agent, horizon} <- @steps, do: execute(agent, horizon, study)
    {:ok, runs}
  end

  defp execute(agent, horizon, study) do
    {outcome, reasoning, task} =
      try do
        case run_agent(agent, study) do
          {:done, reasoning, nil} -> {"completed", reasoning, nil}
          {:done, reasoning, task} -> {"escalated", reasoning, task}
          {:skip, reasoning} -> {"skipped", reasoning, nil}
        end
      rescue
        exception ->
          {:ok, task} =
            HumanLoop.open_task(
              "study",
              study.id,
              "resolve_agent_exception",
              "Agent #{agent} crashed on \"#{study.title}\": #{Exception.message(exception)}. Orchestration continued; this step needs human follow-up.",
              "high"
            )

          {"failed", "Unhandled exception: #{Exception.message(exception)}", task}
      end

Every execution is persisted as an AgentRun with a plain-language reasoning string, an outcome of completed, escalated, skipped, or failed, and a pointer to the review task when one was opened.

The safety agent illustrates the do/skip/escalate shape and closes with a sentence worth keeping:

      _ ->
        {:done,
         "Triaged #{length(reported)} adverse event(s); #{length(escalated)} serious/severe escalated to the safety physician. Causality is never decided by software.",
         task}

Galenops.Orchestration.checklist/1 then derives the portal-facing twelve step checklist by joining the latest AgentRun per agent against its ReviewTask, yielding not_started, attention, awaiting_review, or complete per step.

Human In The Loop

Galenops.HumanLoop in lib/galenops/human_loop.ex is the other half of the contract. Agents open tasks through one function, deduplicated so pipeline re-runs do not stack duplicate escalations:

  def pending_task_exists?(subject_type, subject_id, action) do
    Repo.exists?(
      from t in ReviewTask,
        where:
          t.subject_type == ^subject_type and t.subject_id == ^subject_id and
            t.action == ^action and t.status == "pending"
    )
  end

  def open_task(subject_type, subject_id, action, summary, risk_level) do
    create_review_task(%{
      subject_type: subject_type,
      subject_id: subject_id,
      action: action,
      summary: summary,
      risk_level: risk_level,
      status: "pending"
    })
  end

Approval and rejection side effects are keyed on the ReviewTask.action string: an approved approve_protocol_draft marks the protocol approved, a rejection returns it to draft, and a serious adverse event is never auto-closed on rejection. Known actions include approve_protocol_draft, approve_csr_draft, assess_serious_event, resolve_data_query, submit_regulatory, release_site_payment, validate_tlf_outputs, confirm_onsite_visit, review_low_feasibility, import_matched_candidates, assess_termination_risk, resolve_agent_exception, and the three Luna escalations luna_crisis_escalation, luna_severe_symptom, and luna_withdrawal_signal. A companion module HumanLoop.TaskContext polymorphically assembles details, consequences, agent reasoning, and links per subject type so the reviewer can decide well from one screen.

Luna

Luna is the participant side, ported from the NxtCure app. Galenops.Luna.Brain is where an LLM would be: the NxtCure chat system prompt has been ported into deterministic rules — ordered keyword precedence over crisis, severe symptom, withdrawal, medication, visit, and status phrase lists — so the role contract (never a doctor, crisis resources first, one-sentence deflection out of scope) is enforced in code rather than in a prompt. An LLM can be slotted behind the same intents later.

The one live external AI dependency is Deepgram, voice only and optional. Galenops.Luna.Deepgram makes two plain Req calls: speech to text against /v1/listen with nova-3, and text to speech against /v1/speak with aura-2-aurora-en. Without a DEEPGRAM_API_KEY the voice endpoint reports disabled and text chat is unaffected. A spoken turn is one round trip: audio in, transcribe, route through the same Luna.chat/2 path as typed messages, speak the reply, return base64 audio.

In the LiveView, escalation surfaces to the participant as a flash while the review task lands in the CRO queue in real time:

        case Backend.chat(user["id"], text) do
          {:ok, %{"reply" => reply, "escalated" => escalated}} ->
            sent = %{"role" => "user", "content" => text, "escalated" => escalated}

            socket =
              socket
              |> assign(transcript: socket.assigns.transcript ++ [sent, reply], error: nil)
              |> then(fn s ->
                if escalated,
                  do: put_flash(s, :error, "Escalated to the study team — a human will follow up."),
                  else: s
              end)

            {:noreply, socket}

API Surface

The router at galenops_backend/lib/galenops_web/router.ex exposes RESTful resources per pillar, plus the orchestration, review queue, Luna, and automation routes:

    # Horizon 3 — cross-functional agent orchestration + audit trail
    post "/studies/:id/orchestrate", OrchestrationController, :run
    get "/studies/:id/checklist", OrchestrationController, :checklist
    resources "/agent_runs", AgentRunController, except: [:new, :edit]

    # Human-in-the-loop review queue (grouped route must precede :id routes)
    get "/review_tasks/grouped", ReviewTaskController, :grouped
    resources "/review_tasks", ReviewTaskController, except: [:new, :edit]
    get "/review_tasks/:id/context", ReviewTaskController, :context
    post "/review_tasks/:id/approve", ReviewTaskController, :approve
    post "/review_tasks/:id/reject", ReviewTaskController, :reject

    # Automation endpoints — each proposes work and routes risk to a human
    post "/studies/:id/automation/draft_protocol", AutomationController, :draft_protocol
    post "/studies/:id/automation/assess_feasibility", AutomationController, :assess_feasibility
    post "/studies/:id/automation/forecast_supply", AutomationController, :forecast_supply
    post "/studies/:id/automation/draft_csr", AutomationController, :draft_csr

Two smaller modules round out the backend: Galenops.PdfWriter is a dependency-free PDF renderer (Courier, US Letter) for protocol, CSR, and TLF previews served at GET /api/documents/:id/pdf, and Galenops.Literature provides pharmacovigilance mechanism evidence through NCBI E-utilities, honestly separated into always-available reproducible PubMed search links and best-effort live article lookups that degrade to empty on network failure. Never fabricated citations.

Demo Data and Seeding

The seed pipeline has three layers. Galenops.Release.maybe_seed/0 is the boot-time guard, wired into compose.yml as the backend command so first boot migrates, seeds if the database is empty, then serves:

  def maybe_seed do
    cond do
      System.get_env("SEED_ON_BOOT", "false") not in ~w(true 1 yes) ->
        IO.puts("SEED_ON_BOOT disabled — skipping seed.")

      not empty?() ->
        IO.puts("SEED_ON_BOOT: database already has studies — skipping seed.")

      true ->
        IO.puts("SEED_ON_BOOT: empty database — seeding demo data...")
        seed()
    end
  end

priv/repo/seeds.exs creates the demo study GAL-001: Adaptive Phase II in Refractory Melanoma, deliberately behind enrollment pace so the termination watch agent raises an accrual flag, walks it through every automation function pillar by pillar, seeds three Luna participants with distinct narratives (a perfect-day streak, missed medications with withdrawal talk, symptom ratings), and finishes by running the full orchestration pipeline so the reasoning trail and review queue are populated on first page load.

Galenops.DemoImport then ingests real-scale CDISC SDTM extracts from priv/demo_data/*.json, produced by scripts/extract_sdtm_study.py (pandas plus pyarrow, run under uv): a synthetic Phase 3 NSCLC study with 450 subjects and 2,840 adverse events, and the CDISC pilot Alzheimer’s study with 306 subjects. The import mapping keeps the safety agent honest — the five most recent serious adverse events stay reported so a live triage queue exists, and major protocol deviations become open QA findings feeding the site KRIs.

Deployment

Bring-up is one command from the repository root, with secrets in a gitignored .env generated via mix phx.gen.secret:

docker compose up -d --build

The backend healthcheck curls /api/dashboard with a 600 second start period because first boot seeds before listening; the portal, mobile, and Luna services gate on condition: service_healthy. SQLite persists in a backend_data volume and migrations run on every boot. Local development is the standard mix setup && mix phx.server per app.

The docs/architecture/ directory carries six Graphviz control-flow diagrams (system context, request flow, orchestration, human loop, module map, boot), a Touying typst slide deck in slides.typ compiled to a 16:9 PDF, and a build.sh that renders the dot sources and compiles the deck. The literature review in docs/intent/lit_review_synthesis.md maps each implemented heuristic back to its citation.