Luna Guide
Background
Luna Guide is live at guide.nxtcure.com as Luna · AI Physician Agent: an NCCN guideline copilot for oncology decision support, grown into a care-coordination command center around it. A clinician walks a guideline pathway as a flowchart, asks Luna questions that light up the cited path, and works a roster of patients whose records pre-fill the workup, the medication list, the monitoring schedule and the safety triage. It is the clinician-facing sibling of the Luna patient companion, and the two are now joined: a patient’s check-ins, teach-back and messages from the companion app appear on the clinician’s side.
The repository (~/void/www/git/medicalapps/guide_general) is a fork of Microsoft GraphRAG with a self-contained clinical guidelines layer bolted onto its stable API seams — injector, index driver, query API and UI — and nothing upstream patched. The central design inversion is stated in the repository’s own architecture deck:
The core inversion: the knowledge graph is not LLM-extracted from text — it is hand-authored as Graphviz flowcharts, one .dot per NCCN algorithm page, and injected downstream of extraction.
--Architecture Deck, docs/architecture/slides.typ
What is deployed is the NCCN oncology build, not the general medicine one: five cancers, each at the guideline version it was indexed from — Testicular v2.2026, Breast v5.2026, Prostate v2.2026, Colon v2.2026 and NSCLC v6.2026. The headache, diabetes and obesity set described in older notes on this page is not on the site. The platform says what it is on every screen, and so should anybody describing it: decision support from NCCN pathway data and a synthetic roster, not medical advice, to be verified against the guideline and the trial site.
A tour of guide.nxtcure.com
Everything in this section was observed on the live site on 28 September 2026, by driving it with Playwright at 1440 and 390 pixels wide. It was a read-only walk: no question was sent to Luna, and nothing that runs a model, searches ClinicalTrials.gov, books an appointment or writes to a record was pressed — so what those controls do is described from what the screen says about them, not from having run them.
The pathway workspace
The first screen is a welcome card offering three ways in — walk the pathway, open a patient, match trials — with the keyboard map (/ or ⌘K to ask Luna, ◂ ▸ to step, Esc to close) and the disclaimer. Behind it is a three-pane workspace:
- The flowchart, centre. A cancer picker and a page picker (NSEM-1 through TEST-1 for testicular: nineteen pages) choose the NCCN algorithm page, drawn with Cytoscape.js and dagre. It is revealed step by step (
STEP 1 / 5),ALLshows the full pathway, and a legend colours the nodes Workup, Decision, Treatment, Recurrence and Salvage. The page header carries the page code, the cancer, the guideline version and a Reference marker. - Luna, left. A chat copilot with four suggested questions (initial staging workup, primary treatment options, systemic therapy for advanced disease, recurrence and later-line options), a Specific / Thematic switch — GraphRAG’s
localandglobalsearch — a Speaks toggle that reads answers aloud, and a microphone. - The inspector, right. To-Do and Timeline tabs. To-Do is the clinical workup for the chosen cancer, grouped by the page it comes from (for testicular: diagnosis and primary treatment from TEST-1, postdiagnostic staging from SEM-1 / NSEM-1), each item tagged with who does it — Clinician, Radiology, Lab, Surgery. Timeline is the case history: every step taken appears there and can be jumped back to.
Clicking a node inspects it: its type, what it comes from, the options or next steps that follow it, and an Ask Luna about this node button. The top bar also holds the worklist and message counters, the patient roster, the language switch — English, Cantonese (粵, ?lang=zh-HK) and Mandarin (普, ?lang=zh-CN), which translate the interface while the guideline text stays in English — a light/dark toggle (dark is the default) and the two pane toggles. At 390 pixels the workspace stacks without sideways overflow.
The patient roster
The roster reads FHIR R4 from an InterSystems IRIS server (iris:52773, cached, with a refresh button and the time it was fetched). It holds fourteen synthetic patients — three each for testicular, breast, prostate and colon, two for NSCLC — with sex and age, the diagnosis with its ICD-10 code and stage, the guideline it maps to, plan progress (for example 9/12 done · 1 active), the last encounter, and a warning count for open alerts. Filters by cancer sit above the table. Clicking a patient switches Luna to their guideline and opens them.
A patient open
Opening a patient adds a chip to the top bar and a briefing to the chat — “Reviewing Ivo Stubfield — EHR summary loaded. Stage the cancer when ready.”, what the record is missing, the open critical alert, and how many pre-treatment topics are still to cover. Luna prepends the patient’s context to every question while they are active. The inspector grows to ten tabs:
- Patient — the EHR summary: record completeness (
10/11 fields · missing LDH post-orchiectomy), problem list with codes and onset, staging as documented (TNM, AJCC edition), tumour markers, other labs, biomarkers, imaging, pathology, procedures, medications, encounters, performance status, and the treatment plan from the record. Buttons to stage this cancer (compute stage group, TNM and risk group from the record), and to match chemotherapy or radiation to the guideline, match trials, or ask about next steps and surveillance. - Prep — what to settle with the patient before treatment, from the record, with no model call: thirteen topics in three stages (before the decision, before the first dose, during treatment) such as sperm banking, distress screening, goals of care, health-care proxy, financial toxicity, port placement, hepatitis and HIV screening, a baseline audiogram before cisplatin, and contraception. Each is a decision or an action, and the sensitive ones are marked.
- Meds — active, planned and past medications with dose, route, schedule, regimen and RxNorm code, plus patient-reported adherence by day from the companion app.
- Monitor — post-treatment surveillance: tracked markers against their upper limit, charted with nadir and anchor, marker half-life checks (β-hCG falling with an 11.5-day half-life against an expected ≤ 3 days raises an alert), overdue tests, the schedule, and a form to record a result — saved to the record as source Luna, after which trends and alerts are recomputed.
- To-Do and Timeline — as on the workspace, but pre-filled from the patient’s EHR plan: done, under way or outstanding, with the date and finding that closed each item.
- Trials — ClinicalTrials.gov matching, on demand only: choose a radius (25 miles to nationwide) and how many trials to score (10, 25 or 60 nearest). It shows the model (
claude-haiku-4-5) and a worst-case cost before anything runs — for 25 trials, 51 calls and about 102k tokens in and 67k out. Nothing is spent until Match is pressed. - Teach-back — the patient’s side, from the companion app, for consented patients only (a withdrawal removes the view immediately), and showing kinds, counts, levels and times, never the patient’s own words: the teach-back chunks and which are held, a seven-day adherence grid for medications, goals and symptoms, and recent activity. It also carries the patient app access code: the patient opens patient-guide.nxtcure.com on their phone and enters it. Luna sends nothing to the patient.
- Safety — adverse events in CTCAE v5-style grades. Alert levels are table lookups in the API, so the same report always raises the same alert. Care actions carry a risk score with its reasons (a live critical alert, myelosuppressive chemotherapy, the day-7–14 neutrophil nadir window), a deadline (ED now, call ≤ 4 h), escalation, and Take / Called / Close. Below: active alerts, what to watch for on the current regimen (each effect tied to the drug that causes it, the dose-limiting ones marked), the adverse event log, and a form to record one.
- Appts — appointments from Medplum, which computes free times; a booking made here is recorded as by the clinician and appears in the patient’s Luna Care app.
The worklist
The bell opens the Worklist — reaction alerts turned into triage tasks with deadlines, by table lookups with no model call, refreshed every 30 seconds, with an acting as role at the foot. Six tabs:
- Alerts — critical, warning and info counts, acknowledged and not, each with Acknowledge, Resolve and Open patient.
- Tasks — the queue across every patient: overdue, due within the hour, open, and handed back to Luna for a recheck. Each carries its disposition (ED now, call within 4 or 24 hours, review at next visit), the OP-35 condition it maps to where one applies, escalations, and who it is for.
- Messages — what patients write from Luna Care, screened for red flags (the same screen as teach-back; a hit raises a critical alert). Replies are signed with the clinician’s name and the patient sees when theirs has been read.
- Clinic — today’s visits, due tests not yet booked, and staff and room time off, which Medplum stops offering straight away.
- Risk — patients scored against illustrative weights, not a validated model, as the screen itself says.
- Impact — the programme’s own outcomes over 30 days: problems caught early, triage deadlines met, median time to contact, febrile-neutropenia antibiotics within 60 minutes, OP-35-aligned conditions by where they were managed, and an avoided cost estimate — explicitly illustrative, not billing data.
Architecture
What the site confirms: the frontend is Phoenix LiveView (every control is a phx-click; the hooks are Cyto, Theme, ChatScroll, VoiceRecorder and Resize), the flowchart is Cytoscape.js with the dagre layout, styling is Tailwind from its CDN, and the fonts are Geist and JetBrains Mono. Patients come from a FHIR R4 server on InterSystems IRIS, and schedules, free times and bookings from Medplum. The companion app is a separate site. Only the LiveView page and its two scripts are served at the domain — none of /health, /login or the API’s paths answer there — so the API is reached server-side.
From the repository: a Klein REST API (api/app.py) loads each guideline as a self-contained on-disk GraphRAG project — parquet tables, a LanceDB vector store, no database — and serves the registry, page graphs as Cytoscape.js elements, item detail with PubMed citations, a bookmarked PDF export, and GraphRAG global/local query. The LiveView UI was a single-file Mix.install application (nccn_ui/nccn_ui.exs); the patient, worklist, safety, monitoring, prep, trials and scheduling features on the live site are newer than the repository description on this page, and their internals are not documented here yet.
The Hand-Authored Knowledge Graph
Each guideline page is a Graphviz .dot file where the drawing is the knowledge graph. Entity types are derived from fill color — Workup, Treatment, Decision, Management, plus general medicine additions where #E8DAEF means Diagnosis and #FADBD8 means Emergency. The headache triage page reads as clinical semantics in source form:
redflag [label="Red flags\npresent?", shape=diamond, fillcolor="#F9E79F"];
lowrisk [label="Low-risk headache", fillcolor="#EAECEE"];
highrisk [label="High-risk headache\n(possible secondary headache)", fillcolor="#FADBD8"];
ha2 [label="Primary Headache\nEvaluation -> HA-2", fillcolor="#FDEBD0", style="rounded,filled,dashed"];
ha3 [label="High-Risk Secondary Headache\nWorkup -> HA-3", fillcolor="#FDEBD0", style="rounded,filled,dashed"];
present -> screen -> redflag;
redflag -> lowrisk [label="No"];
redflag -> highrisk [label="Yes"];
lowrisk -> ha2;
highrisk -> ha3;
highrisk -> lifethreat [label="consider / rule out"];nccn_to_graphrag.py converts the flowcharts into pre-finalize GraphRAG parquet, repurposing the text_unit_ids column as the page code list so evidence can later resolve back to pages. Its connectivity trick is a page-anchor entity per page:
# Pass 2: build entities + relationships
for code, nodes, edges, anchor_title in parsed:
# a page-anchor entity connects everything documented on this page and
# gives the otherwise-fragmented pages a connected spine via references
add_entity(f"__page__{code.lower()}", anchor_title, "Protocol Page",
f"{doc_name} protocol page {code}. {anchor_title}", code)
for gv, n in nodes.items():
add_entity(norm(n["title"]), n["title"], n["type"], n["description"], code)
# link every step on the page to the page anchor (intra-page connectivity)
add_rel(anchor_title, n["title"], f"step in {code}", code, weight=0.5)Indexing Half a Pipeline
index_external_graph.py runs only the back half of GraphRAG’s indexing — graph extraction is deliberately skipped since the graph was authored by hand, and just two steps ever call an LLM: community reports and text embeddings (OpenAI gpt-4.1 and text-embedding-3-large via the litellm-backed provider layer).
WORKFLOWS = [
"finalize_graph",
"create_communities",
"create_community_reports",
"generate_text_embeddings",
]
async def main() -> None:
config = load_config(ROOT, cli_overrides={
"workflows": WORKFLOWS,
# cluster ALL connected components (our graph has several), not just the
# largest; and allow slightly bigger communities than the default.
"cluster_graph": {"use_lcc": False, "max_cluster_size": 12},
})
results = await build_index(config, verbose=False)The use_lcc: False override is a safety decision rather than a tuning knob: the injected graph has several connected components, and clustering only the largest would silently drop whole protocol areas. The graphs are small — headache is 32 entities and 69 relationships — which is the point: a faithful derivative of a hand-checked source, not a statistical extraction.
Query, Evidence, and Highlighting
The load-bearing coupling in the whole system is GraphRAG’s citation contract — answers cite [Data: Relationships (ids)] — which the API regex-parses back out and resolves into graph elements. Page-anchor bookkeeping edges would pollute the highlighting, so cited relationships are split into structural and clinical, and only clinical edges glow and vote for the primary page:
if ds.startswith("relationship") and hid in g.rel_by_hid.index:
r = g.rel_by_hid.loc[hid]
src, tgt = str(r["source"]), str(r["target"])
kind = "structural" if (src in g.anchors or tgt in g.anchors) else "clinical"
pages = _pages(r["text_unit_ids"])
page = pages[0] if pages else None
edges.append(EvidenceEdge(id=str(hid), source=src, target=tgt, page=page, kind=kind))
if kind == "clinical" and page:
page_votes[page] = page_votes.get(page, 0) + 1The LiveView side turns one query into a highlighted decision path in a single function — post the question, filter the clinical edges, collect the touched node titles, and fetch the page graph with those highlights:
defp run_query(key, q, method) do
body = Req.post!("#{@api}/query", json: %{guideline: key, query: q, method: method}, receive_timeout: 240_000, connect_options: [timeout: 10_000]).body
ev = body["evidence"] || %{}
page = ev["primary_page"]
clinical = Enum.filter(ev["edges"] || [], &(&1["kind"] == "clinical"))
hln = Enum.uniq(Enum.flat_map(clinical, &[&1["source"], &1["target"]]) ++ Enum.map(ev["nodes"] || [], & &1["title"]))
hle = Enum.map(clinical, &[&1["source"], &1["target"]])
graph = if page, do: graph_for(key, page, hln, hle, true), else: nilThe Cytoscape hook then reveals the pathway rank by rank — a breadth-first ord computed from zero-indegree sources drives a progressive reveal with animated dashes on the highlighted edges. A guideline directory that has not been indexed yet still serves its flowcharts; only /query refuses, with a 409 that names the exact script to run.
SMART on FHIR into eClinicalWorks
Not what the live site does. The deployment reads its roster from the IRIS FHIR server, and /login — the eClinicalWorks launch this section describes — answers 404 there. What follows is the repository’s general medicine build, kept because the pathway-placement rule is still the pattern the roster’s guideline mapping follows.
There is no application login; the only auth is the outbound eClinicalWorks standalone provider launch — OAuth2 authorization code with PKCE S256, a confidential client on the token endpoint, thirteen read-only user/*.read scopes, and the token held in a single in-memory Agent (explicitly a single-user dev tool). The OAuth callback route sits outside Phoenix’s browser pipeline so CSRF protection does not reject the cross-site redirect.
Once connected, the patient’s FHIR problem list places them on a pathway. Matching is on codes only, never display strings, tried in priority order:
# Rules are code-based (ICD-10-CM prefix or SNOMED CT), never the free-text
# display, and are tried in priority order; the first match wins.
@pathway_rules [
%{
key: "diabetes",
icd: ~w(E08 E09 E10 E11 E13 R73),
sct: ~w(73211009 44054006 313435000 46635009 15777000),
resolve: :diabetes
},
%{
key: "headache",
icd: ~w(G43 G44 R51),
sct: ~w(25064002 37796009 398057008 230462002 193031009),
resolve: :headache
}
]Auto-completed To-Do items are traceable: each pre-ticked checkbox carries references back to the exact FHIR Condition, display and code, that justified it. The demo patients were found empirically — a Playwright sweep of all 3,665 eClinicalWorks sandbox patients found only 17 with any problem list, and the README honestly records that no sandbox patient exists for the obesity rule.
Citations Without Guessing
Supporting literature is resolved through NCBI E-utilities rather than trusted from a model: scripts/resolve_citations.py searches PubMed by published title, verifies matches with a difflib ratio of at least 0.75, rebuilds the citation string from NCBI’s own metadata, and omits anything that fails — 33 verified PMIDs across the three guidelines, joined to graph elements at request time. Footnote nodes are stripped from the interactive chart but kept in the PDF export, because an export is a reference document, not the stripped interactive chart.
Deployment
The repository’s Dockerfile is a two-target multi-stage build: the API image adds Graphviz to a uv Python base, and the UI image pre-warms the Mix.install dependencies at build time so container startup is fast and network-free (though Tailwind and Cytoscape still load from CDN — the live site does exactly that). compose.yml health-gates the UI on the API’s /health.
One caveat when reading the repository, now reversed: its prose, Makefile and the legacy api/ui.html describe the five-cancer NCCN build, while its shipped code targeted the three general medicine guidelines. The live site is the five-cancer NCCN build — so the prose is the better guide to what is deployed, and the general medicine code is the older branch. The Elixir modules are still named Nccn.