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 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.~/void/www/git/medicalapps/guide_general
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 (, 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 api/app.py/global query. The frontend is a Phoenix LiveView 1.0 application in a single 2152 line file, local, started with nccn_ui/nccn_ui.exs and no project scaffold (port 4400): Mix.install is the Luna workspace with chat sidebar, Cytoscape flowchart, and To-Do/Timeline/Detail tabs; HomeLive shows one pathway node with its supporting evidence; ItemLive is the eClinicalWorks patient dashboard over eight FHIR resource types.PatientLive
The Hand-Authored Knowledge Graph
Each guideline page is a Graphviz file where the drawing is the knowledge graph. Entity types are derived from fill color — .dot, Workup, Treatment, Decision, plus general medicine additions where Management means Diagnosis and #E8DAEF means Emergency. The headache triage page reads as clinical semantics in source form:#FADBD8
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"];
converts the flowcharts into pre-finalize GraphRAG parquet, repurposing the nccn_to_graphrag.py column as the page code list so evidence can later resolve back to pages. Its connectivity trick is a page-anchor entity per page:text_unit_ids
# 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
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 index_external_graph.py and gpt-4.1 via the litellm-backed provider layer).text-embedding-3-large
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 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.use_lcc: False
Query, Evidence, and Highlighting
The load-bearing coupling in the whole system is GraphRAG’s citation contract — answers cite — 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:[Data: Relationships (ids)]
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 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 ord refuses, with a 409 that names the exact script to run./query
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 scopes, and the token held in a single in-memory user/*.read (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.Agent
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: 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.scripts/resolve_citations.py
Deployment
The is a two-target multi-stage build: the API image adds Graphviz to a uv Python base, and the UI image pre-warms the Dockerfile dependencies at build time so container startup is fast and network-free (though Tailwind and Cytoscape still load from CDN — vendor them for production). Mix.install health-gates the UI on the API’s compose.yml, and the stack boots without eClinicalWorks credentials since the env file is optional. One caveat when reading the repo: prose, Makefile, and the legacy /health 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 api/ui.html.Nccn