Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
103 changes: 102 additions & 1 deletion backend/src/monarch_py/api/additional_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ def __str__(self):


class SemsimSearchGroup(Enum):
"""Legacy search groups. Superseded by the explicit category/taxon filters below.

Each group is a CURIE prefix standing in for "entities of some category in some species",
which works only while the two coincide one-to-one. They do not: mouse models are split
across the MGI and MMRRC prefixes, so no single group can name them, and MGI alone covers
mouse genes, genotypes and variants at once. Kept working via `expand_search_group`.
"""

HGNC = "Human Genes"
MGI = "Mouse Genes"
RGD = "Rat Genes"
Expand All @@ -40,6 +48,65 @@ class SemsimSearchGroup(Enum):
MONDO = "Human Diseases"


# Explicit (category, taxa, prefixes) each legacy group expands to. This is what preserves the
# old meaning now that the association pool spans every phenotype-bearing entity: before, a
# gene-and-disease-only pool made prefix="MGI" mean "mouse genes" by accident. It no longer does,
# so the category has to be stated.
_SEARCH_GROUP_FILTERS = {
"HGNC": (["biolink:Gene"], ["NCBITaxon:9606"], ["HGNC"]),
"MGI": (["biolink:Gene"], ["NCBITaxon:10090"], ["MGI"]),
"RGD": (["biolink:Gene"], ["NCBITaxon:10116"], ["RGD"]),
"ZFIN": (["biolink:Gene"], ["NCBITaxon:7955"], ["ZFIN"]),
"WB": (["biolink:Gene"], ["NCBITaxon:6239"], ["WB"]),
"MONDO": (["biolink:Disease"], None, ["MONDO"]),
}


def expand_search_group(group):
"""Legacy group -> (categories, taxa, prefixes). Accepts the enum, its name ("MGI") or its
value ("Mouse Genes"). Returns (None, None, None) for an unknown group so the caller can
decide whether that is an error."""
if group is None:
return None, None, None
name = group.name if isinstance(group, SemsimSearchGroup) else str(group)
if name not in _SEARCH_GROUP_FILTERS:
try:
name = SemsimSearchGroup(name).name
except ValueError:
return None, None, None
return _SEARCH_GROUP_FILTERS[name]


class SemsimSearchFilter(BaseModel):
"""Explicit replacement for `group`: say what kind of thing, in what species, from where.

All three fields are optional and AND together, so "every mouse genotype regardless of
source" is `category=[Genotype], taxon=["NCBITaxon:10090"]` — the query the prefix-based API
could not express. `taxon` takes an NCBITaxon CURIE or a species label ("Mus musculus").
"""

category: Optional[List[EntityCategory]] = Field(
default=None, title="Biolink categories to search within (e.g. biolink:Genotype)"
)
taxon: Optional[List[str]] = Field(
default=None, title="NCBITaxon CURIEs or species labels (e.g. NCBITaxon:10090)"
)
prefix: Optional[List[str]] = Field(
default=None, title="Restrict to these CURIE prefixes (e.g. MMRRC) — a source filter, not a category"
)

def as_kwargs(self) -> dict:
"""Engine kwargs for Ducksim.search / full_search / hybrid_search."""
# `.value`, not `str()`: EntityCategory is a plain Enum, so str() gives
# "EntityCategory.GENOTYPE" rather than the "biolink:Genotype" CURIE the KG stores. That
# would match no rows and return an empty result that looks like a real answer.
return {
"categories": [c.value for c in self.category] if self.category else None,
"taxa": self.taxon or None,
"prefixes": self.prefix or None,
}


class SemsimDirectionality(str, Enum):
BIDIRECTIONAL = "bidirectional"
SUBJECT_TO_OBJECT = "subject_to_object"
Expand All @@ -66,7 +133,41 @@ class SemsimMultiCompareRequest(BaseModel):

class SemsimSearchRequest(BaseModel):
termset: List[str] = Field(..., title="Termset to search")
group: SemsimSearchGroup = Field(..., title="Group of entities to search within (e.g. Human Genes)")
group: Optional[SemsimSearchGroup] = Field(
None, title="DEPRECATED legacy entity group; prefer the explicit `filter`"
)
filter: Optional[SemsimSearchFilter] = Field(
None, title="Explicit category / taxon / prefix filter (supersedes `group`)"
)
metric: SemsimMetric = Field(SemsimMetric.ANCESTOR_INFORMATION_CONTENT, title="Similarity metric to use")
directionality: SemsimDirectionality = Field(
SemsimDirectionality.BIDIRECTIONAL, title="Directionality of the search"
)
limit: Optional[int] = Field(10, title="Limit the number of results", ge=1, le=50)

def resolved_filter(self) -> dict:
"""Engine kwargs, preferring the explicit filter and falling back to the legacy group.

`group` stays valid so existing clients keep working, but an explicit `filter` wins when
both are sent rather than silently intersecting with the group's implied category — two
filters quietly ANDing is exactly the kind of surprise this API change is meant to end.
"""
if self.filter is not None:
return self.filter.as_kwargs()
categories, taxa, prefixes = expand_search_group(self.group)
return {"categories": categories, "taxa": taxa, "prefixes": prefixes}


class SemsimProfileSearchRequest(BaseModel):
"""Search driven by an entity's own phenotype profile instead of a hand-typed termset.

`entity` is any phenotype-annotated node — a phenopacket Case, a MONDO disease, an MGI/MMRRC
mouse model — and `filter` says what to rank against it, so patient->model, model->patient and
disease->model are the same request with different filters.
"""

entity: str = Field(..., title="Entity whose phenotype profile is the query (e.g. a Case id)")
filter: SemsimSearchFilter = Field(..., title="Explicit category / taxon / prefix filter for the targets")
metric: SemsimMetric = Field(SemsimMetric.ANCESTOR_INFORMATION_CONTENT, title="Similarity metric to use")
directionality: SemsimDirectionality = Field(
SemsimDirectionality.BIDIRECTIONAL, title="Directionality of the search"
Expand Down
99 changes: 90 additions & 9 deletions backend/src/monarch_py/api/semsim.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
from typing import Optional

from fastapi import APIRouter, Path, Query
from fastapi import APIRouter, HTTPException, Path, Query

from monarch_py.api.additional_models import (
SemsimCompareRequest,
SemsimMetric,
SemsimSearchRequest,
SemsimProfileSearchRequest,
SemsimSearchGroup,
SemsimMultiCompareRequest,
SemsimDirectionality,
expand_search_group,
)
from monarch_py.api.config import semsim_service, solr
from monarch_py.api.utils.similarity_utils import parse_similarity_prefix
Expand Down Expand Up @@ -137,7 +139,11 @@ def _search(
limit: int = Query(default=10, ge=1, le=50),
engine: Optional[str] = EngineParam,
):
"""Search for terms in a termset
"""Search for terms in a termset, within one of the legacy entity groups.

Superseded by `POST /search`, which takes explicit category/taxon filters and can express
targets no group can name — notably "all mouse models", which spans the MGI and MMRRC
prefixes. This route expands the group to those same explicit filters.

<b>Args:</b> <br>
termset (str): Comma separated list of term IDs to find matches for. <br>
Expand All @@ -149,31 +155,106 @@ def _search(
List[str]: List of matching terms
"""
terms = [term.strip() for term in termset.split(",")]
categories, taxa, prefixes = expand_search_group(group)
results = semsim_service(engine).search(
termset=terms, prefix=parse_similarity_prefix(group), metric=metric, directionality=directionality, limit=limit
termset=terms,
prefix=parse_similarity_prefix(group),
metric=metric,
directionality=directionality,
limit=limit,
categories=categories,
taxa=taxa,
prefixes=prefixes,
)
return results


@router.get("/filters")
def _filters(engine: Optional[str] = EngineParam):
"""The (category, taxon) combinations available to search, with entity counts.

Explicit filters are only usable if callers can find out what to pass, so this enumerates the
valid values from the KG itself rather than from a hand-maintained enum that drifts.
"""
service = semsim_service(engine)
if not hasattr(service, "engine"):
return {"detail": "filter discovery requires the ducksim backend"}
return [
{"category": cat, "taxon": tax, "taxon_label": lab, "count": n}
for cat, tax, lab, n in service.engine.categories()
]


@router.post("/search")
def _post_search(request: SemsimSearchRequest, engine: Optional[str] = EngineParam):
"""
Search for terms in a termset <br>
Search for entities whose phenotype profile matches a termset. <br>
<br>
Example: <br>
Every mouse model, across MGI *and* MMRRC — not expressible with the legacy `group`: <br>
<pre>
{
"termset": ["HP:0002104", "HP:0012378", "HP:0012378", "HP:0012378"],
"group": "Human Diseases",
"metric": "ancestor_information_content",
"termset": ["HP:0002104", "HP:0012378"],
"filter": {"category": ["biolink:Genotype"], "taxon": ["NCBITaxon:10090"]},
"metric": "phenodigm_score",
"limit": 5
}
</pre>
Only orderable MMRRC strains: <br>
<pre>
{"termset": ["HP:0002104"], "filter": {"category": ["biolink:Genotype"], "prefix": ["MMRRC"]}}
</pre>
Legacy form, still supported: <br>
<pre>
{"termset": ["HP:0002104"], "group": "Human Diseases", "limit": 5}
</pre>
Call <code>GET /semsim/filters</code> for the category/taxon values this KG supports.
"""
if request.filter is None and request.group is None:
raise HTTPException(status_code=422, detail="provide either `filter` (preferred) or `group`")
return semsim_service(engine).search(
termset=request.termset,
prefix=parse_similarity_prefix(request.group.value),
metric=request.metric,
directionality=request.directionality,
limit=request.limit,
**request.resolved_filter(),
)


@router.post("/search-by-profile")
def _post_search_by_profile(request: SemsimProfileSearchRequest, engine: Optional[str] = EngineParam):
"""
Search using an entity's OWN phenotype profile as the query. <br>
<br>
Mouse models for a patient (phenopacket Case -> mouse genotypes): <br>
<pre>
{
"entity": "phenopacket.store:PMID_38991538_Individual_1",
"filter": {"category": ["biolink:Genotype"], "taxon": ["NCBITaxon:10090"]},
"metric": "phenodigm_score"
}
</pre>
Patients matching a mouse model (mouse genotype -> Cases): <br>
<pre>
{"entity": "MMRRC:000415-UCD", "filter": {"category": ["biolink:Case"]}}
</pre>
The likely diagnosis for a patient (Case -> diseases): <br>
<pre>
{"entity": "phenopacket.store:PMID_38991538_Individual_1", "filter": {"category": ["biolink:Disease"]}}
</pre>
Needs the ducksim backend (the entity's profile is read from the KG).
"""
service = semsim_service(engine)
if not hasattr(service, "search_by_profile"):
raise HTTPException(
status_code=501, detail="search-by-profile requires the ducksim backend (?engine=ducksim)"
)
profile, results = service.search_by_profile(
entity_id=request.entity,
metric=request.metric,
directionality=request.directionality,
limit=request.limit,
**request.filter.as_kwargs(),
)
if not profile:
raise HTTPException(status_code=404, detail=f"no phenotype profile found for entity {request.entity!r}")
return {"entity": request.entity, "query_profile": profile, "results": results}
Loading
Loading