Nxtcure
Search Files

Luna Guide

Background

Luna Guide is the clinician-facing sibling of the Luna patient companion: where the consumer app checks in with patients, this one walks a clinician down a clinical guideline pathway and, through a SMART on FHIR link into eClinicalWorks, pulls a real patient’s problem list and places them on that pathway automatically. The repository lives at ~/void/www/git/medicalapps/guide_general and is a fork of Microsoft GraphRAG with a thin, self-contained clinical guidelines layer bolted onto its stable API seams — nothing upstream is forked or patched; the whole implementation is four small additions: injector, index driver, query API, and UI.

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

Two guideline families run in the same machinery: the original NCCN oncology work (testicular, breast, prostate, colon, NSCLC) and the general medicine set this checkout ships and Dockerizes — headache, diabetes, and obesity. A caveat repeated across the repo is worth repeating here: answers come from a lossy derivative graph and are a navigational aid, not a substitute for the guideline and not medical advice.

Architecture

Two services compose the demo. A Klein REST API (api/app.py, port 8891) loads each guideline as a self-contained on-disk GraphRAG project — parquet tables, a LanceDB vector store, no database anywhere — 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 frontend is a Phoenix LiveView 1.0 application in a single 2152 line file, nccn_ui/nccn_ui.exs, started with Mix.install and no project scaffold (port 4400): HomeLive is the Luna workspace with chat sidebar, Cytoscape flowchart, and To-Do/Timeline/Detail tabs; ItemLive shows one pathway node with its supporting evidence; PatientLive is the eClinicalWorks patient dashboard over eight FHIR resource types.

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

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 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 — vendor them for production). compose.yml health-gates the UI on the API’s /health, and the stack boots without eClinicalWorks credentials since the env file is optional. One caveat when reading the repo: prose, Makefile, and the legacy api/ui.html still describe the five-cancer NCCN build on ports 8899/4000, while the shipped code targets the three general medicine guidelines on 8891/4400 — and the Elixir modules are still named Nccn.