feat(search): natural-language search — translate to a query, never run it (#255)#257
feat(search): natural-language search — translate to a query, never run it (#255)#257angela-helios wants to merge 2 commits into
Conversation
…un it (#255) Type "female patients over 65 with a diabetes diagnosis" and get back a FHIR search query you can read, correct, and run. POST /$nl-search returns a *query*, not results. The client then executes it through the ordinary search path, which keeps auth, tenancy, audit, and search semantics on the one code path we already trust, and makes the query reviewable before it runs. The translator has no privileges of its own and never touches storage. Nothing the model says is trusted: - Its output shape is a forced, strict tool call, so a jailbroken model has nowhere to put free text. - Every generated query is parsed and validated against the SearchParameter registry before it is returned. Unknown resource type, unknown parameter, smuggled payload: it fails closed with a friendly error and never executes. - The checked-in system prompt (nl_search_prompt.md) scopes the model to translating search intent, treats the input as data rather than instructions, refuses anything that is not a search over this server's data, and — when one input packs a search together with something else — translates only the search and says the rest was ignored. The prompt is grounded in the server's real searchable vocabulary from the registry, so it never advertises a parameter this server cannot execute. Protecting the operator's key: a sliding-window rate limit plus a daily ceiling and an input length cap, keyed per authenticated user within a tenant, falling back to the peer address when auth is off (new PeerIp extractor; the binary now serves with ConnectInfo). Over the limit is 429 + OperationOutcome. Three states, and off means gone: - HFS_NL_SEARCH_ENABLED=false — no endpoint, no page, no mention anywhere. - enabled, no API key — the Search page advertises the feature, names the env vars, and links the how-to; the visual builder still works. - enabled + HFS_NL_SEARCH_API_KEY — working. UI: the Search page from the Figma "Search V1.0" frame — a segmented Natural language / Visual builder switch over one editable QUERY strip, with the explanation and any caveats under it. Both modes write the same query and neither runs anything; Run is always a separate click. The builder, results and message partials are now shared with the Saved Queries page rather than duplicated. Docs: book/src/components/natural-language-search.md covers what is sent to the model (the user's text and the server's search-parameter names — never patient data), how to get and set a key, cost, the rate limiter, and how to switch it off for good.
… back
Findings from the first run against a real model, which the mock could not have
surfaced:
The model has no clock. Asked for "female patients over 65" it computed the
cutoff against a date it invented — 17 months in the past — and returned
birthdate=le1960-02-14 with a confident caveat saying so. Half of what a
clinician asks for is relative ("over 65", "in the last year"), so the prompt
now carries today's date and is told never to guess it. The same request now
produces birthdate=le1961-07-14.
A malformed answer was being echoed into the UI. One run leaked the model's own
tool-call markup into resource_type, and the error message interpolated it
straight into text that lands in a browser. Untrusted output never leaves the
server now: the caller gets a fixed sentence, the raw value goes to the log,
and the whole response is logged at debug — which is where diagnosing this
belongs.
That leak was transient and the validation layer caught it (the search never
ran), but a bad sample should not be the user's problem, so an unusable
translation is now retried once. Two provider calls, hard maximum.
Also drops "strict": true from the tool definition — an OpenAI field the
Anthropic Messages API does not have, and was ignoring.
Now verified against a real model — and it found two bugs the mock never couldAngela ran this against the live Anthropic API. Everything works, in English and Spanish, including the two behaviors the issue asks for. But the first real run surfaced a bug I would never have caught with a mock: The model has no clock. Asked for "female patients over 65", it computed the cutoff against a date it invented — 17 months in the past — and returned Fixed: the prompt now carries the date and is told never to guess it. Same request now: A malformed answer was being echoed into the browser. One run had the model leak its own tool-call markup into That leak was transient, so an unusable translation is now retried once (two provider calls, hard maximum) rather than surfacing as an error. Also dropped The three cases, against the real modelIt refuses in the language it was asked in, and packed requests get the search translated and the rest explicitly dropped in a caveat. One thing reviewers should know: the prose the model writes can still be sloppy where the query is right — in the first case its explanation said "born on or before 2026-07-14" while emitting the correct |
Visual builder — the conditions are fed by the real SearchParameter registryThe Visual builder tab (beside Natural language) in action, on Conditions, includes and result controls parse two-way from the editable QUERY strip. The condition parameter field autocompletes from a They are genuine, resource-specific FHIR search parameters — One honest gap, for the record: the condition datalist suggests (it is a non-enforcing |
Visual builder filtering + listing results, and the configured NL stateVisual builder — filter and list the matched patients. Building Six female patients, newest birth date first, rendered with Name / Gender / Birth date — the results come straight from the FHIR API (no LLM involved on the builder path). Configured NL state — no setup / how-to card. With The describe-box, example chips, and the editable QUERY strip are all present; the setup instructions only appear when no key is configured. |




Closes #255.
Type "female patients over 65 with a diabetes diagnosis" and get a FHIR search query you can read, correct, and run.
The design decision everything else follows from
POST /$nl-searchreturns a query, not results. The client then runs that query through the ordinary FHIR search path.That keeps auth, tenancy, audit and search semantics on the one code path we already trust — the translator has no privileges of its own and never touches storage — and it makes the query reviewable before it runs. Nothing is translated silently.
So the UI shows the generated query in an editable strip, and Run is always a separate click (open question 2 on the issue: show-first-then-click, at least for the first iteration).
Nothing the model says is trusted
Three layers, and the prompt is deliberately the weakest of them:
crates/rest/src/handlers/nl_search_prompt.md, its own reviewable file) scopes the model to translating search intent, treats the input as data rather than instructions, and answerssupported: falseto anything else.The prompt is built from the server's real searchable vocabulary out of the registry, so it can never advertise a parameter this server cannot execute.
The two behaviors you asked for
Ask for something out of scope and it refuses rather than guessing:
Pack a real search and something else into one paragraph, and it answers only the part that is in scope — and says so:
Protecting the operator's key
An exposed natural-language box is an exposed LLM endpoint someone else is paying for.
PeerIpextractor, and the binary now serves withConnectInfo. With neither, one bucket per tenant: the most restrictive reading, deliberately.429+OperationOutcome(throttled). The limiter runs before the provider call, so a throttled request costs nothing.Three states, and off means gone
HFS_NL_SEARCH_ENABLEDfalse/ui/searchis never routed, the sidebar entry stays the coming-soon placeholder. No mention anywhere.truetrueScreenshots
Translated — the query is shown, editable, and not yet run. Explanation and caveats underneath.
Run it, and the results come back through the normal search path.
Off-topic is refused — and the previous query in the strip is left untouched.
Enabled but unconfigured: the setup state. An operator who has never heard of the feature can turn it on without leaving the page.
Design is Brett's Figma "Search V1.0" frame (node 145:348): the segmented Natural language / Visual builder switch over one QUERY strip. One deliberate deviation — Figma runs the search straight from the natural-language box, but the issue requires the query be seen and correctable first, so the strip is editable and Run is explicit.
How I verified it
No provider key on hand, so I stood up a mock Anthropic Messages endpoint and pointed
HFS_NL_SEARCH_BASE_URLat it — which also exercises the self-hosted/proxy escape hatch. End to end against a running server with seeded patients:"female patients over 65 with a diabetes diagnosis"→Patient?gender=female&birthdate=le1961-07-14&_sort=-birthdate, which returns exactly the 5 women born on or before that date (the male patient correctly excluded), newest first.200s then429once the window filled.ENABLED=false→/ui/searchand/$nl-searchboth404, and zero links to the page in the rendered sidebar./ui/queriesand/ui/searchafter the shared-partial refactor: builder syncs, results render, no JS errors.Tests: 6 HTTP integration tests (
crates/rest/tests/nl_search.rs), 11 unit tests over validation / prompt / rate-limit keying, 3 new UI state tests, locale parity across en/es/de.cargo fmtandclippyclean.Notes for review
book/src/SUMMARY.mdis stale. It still lists only the originalch01…ch14scaffold;components/,configuration/andgetting-started/are all orphaned from it, so mdBook isn't building them today. I added my page to SUMMARY so it is reachable, but that tree needs a separate fix._elements. Figma shows NAME / GENDER / BIRTH DATE; that needs a column model and belongs in its own issue (it's the existing renderer, shared with Saved Queries).claude-opus-4-8, withHFS_NL_SEARCH_BASE_URLas the escape hatch (open question 1: one provider to start).