Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/src/views/search/MainSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ const MainSearch: React.FC = () => {
options.reagent.reagents.forEach(reagent => {
searchParams.append(
'component',
`${reagent.smileSmart};${reagent.source};${reagent.matchMode.toLowerCase()}`,
JSON.stringify({
pattern: reagent.smileSmart,
target: reagent.source,
mode: reagent.matchMode.toLowerCase(),
}),
);
});
searchParams.set(
Expand Down
34 changes: 29 additions & 5 deletions app/src/views/search/SearchOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,30 @@ interface ReagentComponent {
matchMode?: string;
}

interface ParsedComponent {
pattern: string;
target: string;
mode?: string;
}

/**
* Parses a `component` query-parameter value.
*
* New values are JSON objects; the legacy `pattern;target;mode` form is still accepted
* so previously shared search URLs keep working. The legacy parse splits from the right
* because a SMARTS pattern may itself contain `;`.
*/
const parseComponent = (comp: string): ParsedComponent => {
try {
return JSON.parse(comp) as ParsedComponent;
} catch {
const parts = comp.split(';');
const mode = parts.pop();
const target = parts.pop() ?? 'input';
return { pattern: parts.join(';'), target, mode };
}
};
Comment thread
greptile-apps[bot] marked this conversation as resolved.

interface ReagentOptions {
reactants: ReagentComponent[];
products: ReagentComponent[];
Expand Down Expand Up @@ -177,12 +201,12 @@ const SearchOptions: React.FC<SearchOptionsProps> = ({ onSearchOptions }) => {
const components = Array.isArray(q.component) ? q.component : [q.component];

components.forEach((comp: string) => {
const compArray = comp.split(';');
const compType = compArray[1] === 'input' ? 'reactants' : 'products';
const matchMode = compArray[2] || 'exact';
const parsed = parseComponent(comp);
const compType = parsed.target === 'input' ? 'reactants' : 'products';
const matchMode = parsed.mode || 'exact';
const component: ReagentComponent = {
smileSmart: compArray[0].replaceAll('%3D', '='),
source: compArray[1],
smileSmart: parsed.pattern,
source: parsed.target,
matchMode,
};

Expand Down
50 changes: 46 additions & 4 deletions ord_interface/api/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,49 @@ class QueryParams:
min_yield: float | None = None
max_yield: float | None = None
doi: list[str] | None = Query(None)
# Each value is a JSON-encoded ComponentSpec; see ComponentSpec.
component: list[str] | None = Query(None)
use_stereochemistry: bool | None = None
similarity: float | None = None
limit: int | None = None


class ComponentSpec(BaseModel):
"""A single component predicate, JSON-encoded in each ``component`` query value.

Replaces a legacy ``"pattern;target;mode"`` string whose ``;`` delimiter collided
with SMARTS patterns (which use ``;`` as a low-precedence AND). ``target`` and
``mode`` are matched case-insensitively against the ReactionComponentQuery enums.
"""

pattern: str
target: str
mode: str

@classmethod
def parse(cls, spec: str) -> ComponentSpec:
"""Parses one ``component`` value, accepting JSON or the legacy format.

New values are JSON objects; the legacy ``"pattern;target;mode"`` form is still
accepted so previously shared search URLs keep working. The legacy parse splits
from the right because a SMARTS ``pattern`` may itself contain ``;``.

Args:
spec: A single ``component`` query-parameter value.

Returns:
The parsed ComponentSpec.

Raises:
ValueError: If ``spec`` is neither valid JSON nor a 3-field legacy string.
"""
spec = spec.strip()
if spec.startswith("{"):
return cls.model_validate_json(spec)
pattern, target, mode = spec.rsplit(";", 2)
return cls(pattern=pattern, target=target, mode=mode)


async def run_query(
params: QueryParams, return_ids: bool
) -> list[QueryResult] | list[str]:
Expand All @@ -154,12 +191,17 @@ async def run_query(
if params.similarity is not None:
kwargs["similarity_threshold"] = params.similarity
for spec in params.component:
pattern, target_name, mode_name = spec.split(";")
try:
component = ComponentSpec.parse(spec)
except ValueError as error:
raise HTTPException(
status_code=400, detail=f"Invalid component spec: {spec!r}"
) from error
queries.append(
ReactionComponentQuery(
pattern,
ReactionComponentQuery.Target[target_name.upper()],
ReactionComponentQuery.MatchMode[mode_name.upper()],
component.pattern,
ReactionComponentQuery.Target[component.target.upper()],
ReactionComponentQuery.MatchMode[component.mode.upper()],
**kwargs,
)
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
Expand Down
51 changes: 43 additions & 8 deletions ord_interface/api/search_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"""Tests for ord_interface.api.search."""

import gzip
import json

import pytest
from ord_schema.proto import dataset_pb2
Expand All @@ -23,6 +24,12 @@

from ord_interface.api.queries import QueryResult


def _spec(pattern: str, target: str, mode: str) -> str:
"""Returns a JSON-encoded component spec for the `component` query parameter."""
return json.dumps({"pattern": pattern, "target": target, "mode": mode})


QUERY_PARAMS = [
# Single factor queries.
({"dataset_id": ["ord_dataset-89b083710e2d441aa0040c361d63359f"]}, 24),
Expand All @@ -31,26 +38,26 @@
({"min_conversion": 50, "max_conversion": 90}, 7),
({"min_yield": 50, "max_yield": 90}, 51),
({"doi": ["10.1126/science.1255525"]}, 24),
({"component": ["[Br]C1=CC=C(C(C)=O)C=C1;input;exact"]}, 10),
({"component": ["C;input;substructure"]}, 144),
({"component": [_spec("[Br]C1=CC=C(C(C)=O)C=C1", "input", "exact")]}, 10),
({"component": [_spec("C", "input", "substructure")]}, 144),
(
{
"component": ["O[C@@H]1C[C@H](O)C1;input;substructure"],
"component": [_spec("O[C@@H]1C[C@H](O)C1", "input", "substructure")],
"use_stereochemistry": True,
},
20,
),
({"component": ["[#6];input;smarts"]}, 144),
({"component": ["CC=O;input;similar"], "similarity": 0.5}, 0),
({"component": ["CC=O;input;similar"], "similarity": 0.05}, 120),
({"component": [_spec("[#6]", "input", "smarts")]}, 144),
({"component": [_spec("CC=O", "input", "similar")], "similarity": 0.5}, 0),
({"component": [_spec("CC=O", "input", "similar")], "similarity": 0.05}, 120),
# Multi-factor queries.
(
{
"min_yield": 50,
"max_yield": 90,
"component": [
"[Br]C1=CC=C(C(C)=O)C=C1;input;exact",
"CC(C)(C)OC(=O)NC;input;substructure",
_spec("[Br]C1=CC=C(C(C)=O)C=C1", "input", "exact"),
_spec("CC(C)(C)OC(=O)NC", "input", "substructure"),
],
},
7,
Expand All @@ -65,6 +72,34 @@ def test_query(test_client, params, num_expected):
assert len(response.json()) == num_expected


def test_query_smarts_with_semicolon(test_client):
# SMARTS uses ";" as a low-precedence AND; JSON-encoding the component spec means
# such patterns parse cleanly (the legacy ";"-delimited format split incorrectly).
response = test_client.get(
"/api/query", params={"component": [_spec("[#6;R]", "input", "smarts")]}
)
response.raise_for_status()
assert isinstance(response.json(), list)


def test_query_accepts_legacy_component_format(test_client):
# Previously shared "pattern;target;mode" URLs still work (backward compatibility).
response = test_client.get(
"/api/query",
params={"component": ["[Br]C1=CC=C(C(C)=O)C=C1;input;exact"]},
)
response.raise_for_status()
assert len(response.json()) == 10


def test_query_invalid_component_returns_400(test_client):
# A spec that is neither JSON nor a 3-field legacy string is a client error.
response = test_client.get(
"/api/query", params={"component": ["not-a-valid-spec"]}
)
assert response.status_code == 400


def test_get_reaction(test_client):
response = test_client.get(
"/api/reaction", params={"reaction_id": "ord-3f67aa5592fd434d97a577988d3fd241"}
Expand Down
Loading