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.

Figure 1: The welcome card: walk the pathway, open a patient, match trials.

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:

Figure 2: A decision node open in the inspector: where it comes from, the options that follow, and “Ask Luna about this node”.

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

Figure 3: The roster: fourteen synthetic patients over FHIR R4, with each one’s guideline and plan progress.

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

Figure 4: A patient opened: Luna’s briefing on the left, the EHR summary on the right.

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:

Figure 5: Monitor: tumour markers against their limits, with half-life checks that raise alerts.
Figure 6: Safety: care actions with deadlines and reasons, alerts, and what to watch for on the regimen.
Figure 7: Teach-back: the companion app’s check-ins and adherence, for consented patients, never their own words.

The worklist

Figure 8: The worklist: reaction alerts become triage tasks with deadlines — table lookups, no model call.

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:

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) + 1

The 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: nil

The 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.