Skip to content

Add natural-language query interface (/ask)#203

Merged
skearnes merged 15 commits into
mainfrom
nl-query-production
Jun 28, 2026
Merged

Add natural-language query interface (/ask)#203
skearnes merged 15 commits into
mainfrom
nl-query-production

Conversation

@skearnes

@skearnes skearnes commented Jun 27, 2026

Copy link
Copy Markdown
Member

Summary

Adds a natural-language search page (/ask) that translates a free-text question — e.g. "reactions using benzene as an input with yield over 70%" — into the existing QueryParams and runs it through the current index-accelerated search path. No ORM or schema changes; a thin translation layer over the existing engine. Full-stack (backend + frontend).

The model emits a structured query via a single forced tool call — never SQL, never invented SMILES. Specific compound names resolve to SMILES via ord_schema.resolvers (PubChem → NCI/CADD CIR → OPSIN); compound classes and functional groups (e.g. "an aryl boronic acid", "brominated products") become model-authored SMARTS/SUBSTRUCTURE patterns and are never sent to the resolver.

Backend (ord_interface/api/nl_query.py)

  • Forced build_query tool call → NLQuery; model via ORD_NL_QUERY_MODEL (default claude-haiku-4-5); prompt in nl_query_prompt.md.
  • Match modes: EXACT (named compound, resolved), SUBSTRUCTURE/SMARTS (model-authored fragment), SIMILAR. Model-authored SMARTS and reaction_smarts are RDKit-validated up front, so a bad pattern is a 422 (with the pattern echoed) in both normal and dry-run mode.
  • Error mapping: rate limit → 429, other Anthropic API errors → 503, no/malformed tool call → 502, unresolvable compound / invalid SMARTS / empty interpretation → 422, missing key → 503. No uncaught 500s.
  • Redis caching (best-effort, fast-fail): translation only (1h), name→SMILES separately (30d). Search results are never cached. Concurrent name resolution.
  • GET /api/nl_query?q=... (length-bounded, dry_run supported) → {query, interpretation, resolved_components, query_components, results, dry_run}.

Frontend (/ask)

  • Text box, example chips, and an interpretation panel that separates provenance: a "From the model" section (the tool call, raw JSON available) and a "Resolved to structures" section listing only identifiers a resolver actually handled.
  • Dry-run toggle (?dry_run=1); shareable ?q= URLs synced to the input; in-development banner; route unlisted in the nav.

Eval & tests

  • nl_query_eval.py + 15 YAML cases. Identifiers compared by structure (RDKit-canonicalized SMARTS/SMILES; string fallback for names). Translation accuracy 15/15 on Haiku 4.5; --search also runs each query with a per-phase timing breakdown.
  • 25 unit tests, fully stubbed (no network/DB/key). ruff/ty/tsc/eslint clean.

Deploy note

Requires ANTHROPIC_API_KEY in the API environment (provisioned in ord-infrastructure#33). Follow-up: offline name→SMILES table and per-IP rate limiting.

🤖 Generated with Claude Code

Greptile Summary

This PR adds a natural-language search page (/ask) that translates a chemist's free-text question into the existing QueryParams via a forced Anthropic tool call, then runs it through the current search backend. The implementation is full-stack (FastAPI endpoint + React UI) with Redis caching for translations, concurrent name resolution via asyncio.gather, and thorough error mapping across all Anthropic/resolution/query failure modes.

  • Backend (nl_query.py): Translation, SMARTS/reaction-SMARTS validation, name→SMILES resolution via ord_schema.resolvers, and a Redis cache for translations (1 h TTL) and resolutions (30 d TTL) are all cleanly separated; previously-flagged issues (empty-interpretation 500, missing query-length guard, ValidationError 500 on bad tool payload, sequential resolution latency) have been fixed.
  • Frontend (MainNLSearch.tsx, useNLQuery.ts): URL-keyed query, dry-run toggle, and a two-layer interpretation panel (model output vs. resolver output) are implemented correctly; the route is reachable but intentionally unlisted while the feature is in development.
  • Eval harness (nl_query_eval.py): Reports 15/15 accuracy with Haiku 4.5, but CaseExpectation and check_interpretation do not score the reaction_smarts or use_stereochemistry fields, so accuracy for those two NLQuery fields is unverified by the harness.

Confidence Score: 5/5

Safe to merge; the new endpoint is well-guarded and the UI is unlisted from the nav while in development.

The core backend is solid: every realistic failure mode (Anthropic errors, bad SMARTS, unresolvable names, empty interpretation) is mapped to an appropriate HTTP status code, and 25 unit tests cover all those paths without needing a live API key or database. The only gap is in the offline eval harness, which does not score reaction_smarts or use_stereochemistry — a test-coverage shortcoming that does not affect production correctness.

ord_interface/api/nl_query_eval.py — CaseExpectation and check_interpretation are missing coverage for reaction_smarts and use_stereochemistry.

Important Files Changed

Filename Overview
ord_interface/api/nl_query.py Core NL→query translation layer. Error handling is thorough (429/502/503 from Anthropic, 422 for bad SMARTS/unresolvable names, 422 for empty constraints), caching is correctly scoped to translations only, and concurrent resolution via asyncio.gather is in place. No issues found.
ord_interface/api/nl_query_eval.py Eval harness for translation accuracy. CaseExpectation and check_interpretation cover components and numeric filters but omit reaction_smarts and use_stereochemistry, so accuracy for those two NLQuery fields is unverified.
app/src/views/nl-search/MainNLSearch.tsx React UI for /ask page. Text input synced to URL params, dry-run toggle, example chips, two-layer interpretation panel (model output + resolver output). Logic and state management look correct.
app/src/hooks/useNLQuery.ts TanStack Query hook for /api/nl_query. Query key includes both query string and dryRun flag, retry disabled, staleTime Infinity for URL-keyed results. Proto deserialization matches existing search hook pattern.
ord_interface/api/nl_query_test.py 25 unit tests covering translation, error mapping (429/502/503), cache hit/miss, invalid SMARTS 422, dry-run, and empty-interpretation 422. All paths from the PR description are covered; tests are fully stubbed.
ord_interface/api/main.py Adds nl_query router to FastAPI app; one-line change, no issues.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as React /ask
    participant Hook as useNLQuery
    participant API as FastAPI /api/nl_query
    participant Redis as Redis Cache
    participant LLM as Anthropic (Haiku)
    participant Resolver as ord_schema.resolvers
    participant DB as PostgreSQL

    UI->>Hook: "submit query (q=...)"
    Hook->>API: "GET /api/nl_query?q=..."
    API->>Redis: get translation cache
    alt cache hit
        Redis-->>API: NLQuery (cached)
    else cache miss
        API->>LLM: messages.create (forced build_query tool call)
        LLM-->>API: "ToolUseBlock { NLQuery }"
        API->>Redis: set translation cache (1h TTL)
    end
    API->>Resolver: asyncio.gather(_resolve_component x N)
    Note over Resolver: SMARTS validate with RDKit, SMILES canonicalize, name PubChem/CIR/OPSIN (cached 30d)
    Resolver-->>API: ResolvedComponent[]
    alt not dry_run
        API->>DB: run_query(QueryParams)
        DB-->>API: QueryResult[]
    end
    API-->>Hook: NLQueryResponse
    Hook-->>UI: NLQueryData (protos deserialized)
    UI->>UI: render Interpretation panel + SearchResults
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as React /ask
    participant Hook as useNLQuery
    participant API as FastAPI /api/nl_query
    participant Redis as Redis Cache
    participant LLM as Anthropic (Haiku)
    participant Resolver as ord_schema.resolvers
    participant DB as PostgreSQL

    UI->>Hook: "submit query (q=...)"
    Hook->>API: "GET /api/nl_query?q=..."
    API->>Redis: get translation cache
    alt cache hit
        Redis-->>API: NLQuery (cached)
    else cache miss
        API->>LLM: messages.create (forced build_query tool call)
        LLM-->>API: "ToolUseBlock { NLQuery }"
        API->>Redis: set translation cache (1h TTL)
    end
    API->>Resolver: asyncio.gather(_resolve_component x N)
    Note over Resolver: SMARTS validate with RDKit, SMILES canonicalize, name PubChem/CIR/OPSIN (cached 30d)
    Resolver-->>API: ResolvedComponent[]
    alt not dry_run
        API->>DB: run_query(QueryParams)
        DB-->>API: QueryResult[]
    end
    API-->>Hook: NLQueryResponse
    Hook-->>UI: NLQueryData (protos deserialized)
    UI->>UI: render Interpretation panel + SearchResults
Loading

Reviews (9): Last reviewed commit: "Tighten verbose comments in nl_query" | Re-trigger Greptile

Translate a chemist's free-text question (e.g. "reactions using benzene as an
input with yield greater than 70%") into the existing structured QueryParams via
a single forced Claude tool call, resolve compound names to SMILES, and dispatch
through the existing index-accelerated search path. The model emits a structured
query, never SQL or invented SMILES; name resolution is grounded in PubChem/OPSIN.

Backend (ord_interface/api/nl_query.py):
- translate(): forced build_query tool call -> NLQuery; RateLimitError -> 429,
  other anthropic.APIError -> 503, no tool call -> 502.
- Async, cached name resolution (SMARTS passthrough, verbatim SMILES, else
  resolve_name); blocking lookups run in a thread, failures are not cached.
- Redis caches (best-effort): identical questions (1h) and name->SMILES (30d),
  so repeated questions skip the model call and shared compounds skip PubChem.
- System prompt lives in nl_query_prompt.md, shipped via package-data.
- GET /api/nl_query returns the interpretation and resolved structures alongside
  results for transparency.

Eval: nl_query_eval.py + nl_query_eval_cases.json (10 cases); --search executes
against the DB to flag zero-result translations. Translation accuracy 10/10.

Frontend: a separate /ask page (MainNLSearch) with an "Interpreted as:" panel,
useNLQuery hook, types, route, and nav link.

Tests: nl_query_test.py and nl_query_eval_test.py (18 tests, fully stubbed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread ord_interface/api/nl_query.py Outdated
Comment thread ord_interface/api/nl_query.py
Comment thread ord_interface/api/nl_query.py Outdated
Comment thread ord_interface/api/nl_query.py Outdated
skearnes and others added 2 commits June 26, 2026 22:40
Cache only the model's translation (NLQuery), not the search results: repeated
identical questions still skip the model call, but the DB query is always re-run
so results stay fresh and Redis entries stay small (Greptile P2).

Other review fixes and hardening:
- Guard the empty-interpretation case: run_query's "no parameters" ValueError now
  maps to 422 instead of an unhandled 500 (Greptile P1).
- Bound the q parameter with Query(min_length=1, max_length=2000) (Greptile P2).
- Resolve components concurrently with asyncio.gather (Greptile P2).
- Best-effort Redis ops fast-fail via asyncio.timeout (REDIS_OP_TIMEOUT_SECONDS):
  a missing/slow Redis degrades to a miss in ~1s instead of stalling each request
  ~10-20s. Measured against the live DB without Redis: per-compound resolve
  dropped from ~21s to ~2s.
- Bump ord-schema to >=0.7.1 for the new NCI/CADD CIR resolver; resolve_name now
  cascades PubChem -> CIR -> OPSIN, so a PubChem 503 falls through to CIR (which
  resolves benzene/aspirin/ibuprofen/palladium acetate that OPSIN cannot).

Eval: add a per-phase time breakdown (translate/resolve/search) and a per-search
asyncio timeout so a pathologically slow query is reported, not hung.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The natural-language web UI (the /ask page, useNLQuery hook, types, route, and
nav link) is split out to the nl-query-frontend branch so this PR is backend-only
(the /api/nl_query endpoint and its translation/resolution/caching/eval). The
frontend follow-up depends on this endpoint and lands separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
skearnes and others added 5 commits June 27, 2026 18:01
- Match component identifiers exactly (case- and whitespace-insensitive) instead
  of by substring, so the model must reproduce the specific compound -- e.g.
  "4-aminophenol", not "aminophenol". This surfaced a real prompt bug: the model
  emitted "pyridine ring" for "a pyridine ring"; the prompt now instructs it to
  strip descriptive words ("ring"/"group"/"moiety"/"scaffold") and to pass a
  user-supplied SMILES/SMARTS through verbatim.
- Move eval cases from JSON to YAML (nl_query_eval_cases.yaml); add pyyaml as a
  dependency and ship *.yaml via package-data.
- Add SMILES-identifier cases (CCO, an aspirin SMILES, a C(=O)O substructure).
- Reformat the system prompt into sections (Components / Match mode / Filters /
  Output).

Translation accuracy: 13/13 with Haiku.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop "substructure" from the C(=O)O case so it reads "products contain C(=O)O";
verifies the model maps "contain" to SUBSTRUCTURE mode on its own. Still 13/13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Recombine the natural-language web UI (the /ask page, useNLQuery hook, types,
route, and nav link) with the backend so the feature can be reviewed and iterated
on as a single full-stack change. Reverses the earlier split to the
nl-query-frontend branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These changes belong with the earlier eval commit but were dropped when its
git add aborted on the already-removed JSON pathspec, leaving the branch in a
broken state (JSON deleted while nl_query_eval still loaded it):

- nl_query_eval.py: load cases from YAML; exact (case/whitespace-insensitive)
  identifier matching via the renamed `identifier` field.
- nl_query_eval_test.py: assert exact matching ("aminophenol" != "4-aminophenol").
- nl_query_prompt.md: sectioned format; strip descriptive words from identifiers
  and pass user SMILES/SMARTS through verbatim.
- pyproject.toml / uv.lock: add pyyaml; ship *.yaml via package-data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The component spec is "pattern;target;mode", but SMARTS uses ";" as a
low-precedence AND, so a model- or user-supplied SMARTS like "[#6;R]" broke the
str.split(";") unpacking and surfaced as an unhandled 500. Split from the right
(rsplit(";", 2)) since target and mode are the trailing fields and never contain
";". Adds a regression test.

(Flagged by Greptile on #203; their suggested split(";", 2) would mis-parse a
leading-";" SMARTS, so rsplit is the correct form.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@skearnes

Copy link
Copy Markdown
Member Author

Good catch on the SMARTS ; collision — fixed in 3dff42f. One correction: the suggested split(";", 2) would mis-parse a SMARTS whose ; is near the start (e.g. [#6;R]["[#6", "R]", "input;smarts"]), since it splits left-to-right. Used rsplit(";", 2) instead — target and mode are the trailing fields and never contain ;. Added a regression test (test_query_smarts_with_semicolon).

Note: we're also planning to replace the pattern;target;mode string encoding with JSON entirely (separate PR), which removes this class of delimiter bug.

🤖 Generated with Claude Code

Comment thread ord_interface/api/nl_query.py Outdated
skearnes and others added 2 commits June 27, 2026 20:48
# Conflicts:
#	ord_interface/api/search.py
#	ord_interface/api/search_test.py
- nl_query: build_query_params now emits JSON ComponentSpec strings (matching the
  new /query encoding from #206) instead of "smiles;target;mode".
- Dev mode: GET /api/nl_query?dry_run=true translates and resolves but skips the
  search, returning the would-be-executed query_components for inspection. The
  /ask page gets a URL-persisted "Dry run" toggle that shows the structured query
  instead of results.
- Add an "in development" banner to the /ask page.
- Remove the "Ask" link from the nav bar; the /ask route still resolves directly.
- Apply ruff format to the eval test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread ord_interface/api/nl_query.py
skearnes and others added 3 commits June 27, 2026 21:24
Functional-group and element classes ("brominated products", "an aryl
boronic acid") were translated as exact name lookups and failed in the
resolver, which only handles specific named compounds. Teach the prompt to
express such classes as a model-authored SMARTS or SUBSTRUCTURE pattern
instead, and never send them for resolution. SUBSTRUCTURE fragments are now
SMILES the model writes directly (e.g. a pyridine ring -> c1ccncc1).

Validate model-authored SMARTS with RDKit in _resolve_component so a bad
pattern is a clean 422 with the pattern echoed, rather than a 400 deep in
query execution that a dry run would skip entirely.

Eval matching is now structure-aware: SMARTS and SMILES identifiers are
canonicalized with RDKit so equivalent patterns compare equal, while names
RDKit cannot parse keep the case- and whitespace-insensitive string compare.

The /ask interpretation box now separates provenance: a "From the model"
section (the build_query tool call, with the raw JSON available) and a
"Resolved to structures" section that lists only identifiers a network
resolver actually handled, headed by the resolvers used. The redundant
dry-run query dump is dropped since the box already shows it.

Bump the translation cache version to v3 for the prompt change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NLQuery.model_validate in translate() and model_validate_json in
_translation_cache_get can raise pydantic.ValidationError. Catch it
explicitly: a tool payload that fails schema validation becomes a 502
(consistent with the no-tool-call case), and a schema-mismatched cache entry
degrades to a miss. ValidationError subclasses ValueError in our Pydantic, so
the cache path was already covered, but the translate() path was unwrapped
entirely and the explicit catch is robust to Pydantic's inheritance quirk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validate reaction_smarts with RDKit in build_query_params so a bad pattern
is a clear 422 in both normal and dry-run mode, instead of a misleading "no
constraints" 422 from run_query (normal) or an unvalidated pass-through (dry
run, which skips run_query). ReactionFromSmarts both returns None and raises
ValueError for different malformed inputs, so handle both.

On the /ask page, sync the text input to the ?q= URL parameter via an effect
so browser back/forward navigation updates the visible query rather than
leaving a stale value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
skearnes and others added 2 commits June 27, 2026 21:58
The harness docstring referenced nl_query_eval_cases.json; the cases are YAML.
Add similarity_threshold and limit to the over-extraction check (and to
CaseExpectation) so the model is flagged for extracting either without the
question asking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@skearnes skearnes merged commit afeae73 into main Jun 28, 2026
16 checks passed
@skearnes skearnes deleted the nl-query-production branch June 28, 2026 02:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant