Add natural-language query interface (/ask)#203
Merged
Conversation
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>
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>
This was referenced Jun 27, 2026
- 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>
Member
Author
|
Good catch on the SMARTS Note: we're also planning to replace the 🤖 Generated with Claude Code |
# 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>
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 existingQueryParamsand 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)build_querytool call →NLQuery; model viaORD_NL_QUERY_MODEL(defaultclaude-haiku-4-5); prompt innl_query_prompt.md.EXACT(named compound, resolved),SUBSTRUCTURE/SMARTS(model-authored fragment),SIMILAR. Model-authored SMARTS andreaction_smartsare RDKit-validated up front, so a bad pattern is a 422 (with the pattern echoed) in both normal and dry-run mode.GET /api/nl_query?q=...(length-bounded,dry_runsupported) →{query, interpretation, resolved_components, query_components, results, dry_run}.Frontend (
/ask)?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;--searchalso runs each query with a per-phase timing breakdown.ruff/ty/tsc/eslintclean.Deploy note
Requires
ANTHROPIC_API_KEYin 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 existingQueryParamsvia 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 viaasyncio.gather, and thorough error mapping across all Anthropic/resolution/query failure modes.nl_query.py): Translation, SMARTS/reaction-SMARTS validation, name→SMILES resolution viaord_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,ValidationError500 on bad tool payload, sequential resolution latency) have been fixed.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.nl_query_eval.py): Reports 15/15 accuracy with Haiku 4.5, butCaseExpectationandcheck_interpretationdo not score thereaction_smartsoruse_stereochemistryfields, so accuracy for those twoNLQueryfields 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
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%%{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 + SearchResultsReviews (9): Last reviewed commit: "Tighten verbose comments in nl_query" | Re-trigger Greptile