From 6e0f1fc01513b9aeb3b8d71e34aa3e5a29ca75c6 Mon Sep 17 00:00:00 2001 From: Bilal El Houdaigui Date: Sun, 16 Aug 2026 21:32:53 +0100 Subject: [PATCH] :sparkles: handle transcipt stable IDs (there are broken protein links as well for now) --- app/api/models/resolver.py | 10 +++--- app/api/resources/resolver_view.py | 2 +- .../templates/shared/id_content.html | 2 +- app/api/utils/commons.py | 23 ++++++++---- app/api/utils/metadata.py | 5 +++ app/api/utils/search.py | 11 ++++-- app/static/APISpecification.yaml | 6 ++-- stable_ids_sample.csv.redb | Bin 0 -> 1056768 bytes tests/test_fast_match_search.py | 30 ++++++++++++++++ tests/test_resolver.py | 33 ++++++++++++++++++ 10 files changed, 104 insertions(+), 18 deletions(-) create mode 100644 stable_ids_sample.csv.redb diff --git a/app/api/models/resolver.py b/app/api/models/resolver.py index 90fa309..f72c288 100644 --- a/app/api/models/resolver.py +++ b/app/api/models/resolver.py @@ -4,9 +4,9 @@ class SearchPayload(BaseModel): - stable_id: str = Field(default=None, title="Stable ID of a gene") - type: Literal["gene"] | None = Field( - default=None, title="Type of stable id, e.g. gene" + stable_id: str = Field(default=None, title="Stable ID") + type: Literal["gene", "transcript", "protein"] | None = Field( + default=None, title="Optional stable-ID type filter" ) per_page: int = 1 app: Literal["genome-browser", "feature-explorer"] = Field( @@ -15,8 +15,9 @@ class SearchPayload(BaseModel): class SearchMatch(BaseModel): - genome: str + genome_id: str unversioned_stable_id: str + type: Literal["gene", "transcript", "protein"] | None = None class SearchResult(BaseModel): @@ -89,6 +90,7 @@ class UrlResolverResponse(BaseModel): class StableIdResolverContent(MetadataResult): + stable_id_type: Literal["gene", "transcript", "protein"] = "gene" feature_explorer_url: str | None = None genome_browser_url: str | None = None release_type: str | None = None diff --git a/app/api/resources/resolver_view.py b/app/api/resources/resolver_view.py index 291dc1f..78b324e 100644 --- a/app/api/resources/resolver_view.py +++ b/app/api/resources/resolver_view.py @@ -26,7 +26,7 @@ async def resolve( request: Request, stable_id: str, - type: Optional[str] = "gene", + type: Optional[Literal["gene", "transcript", "protein"]] = None, gca: Optional[str] = "", app: Optional[Literal["genome-browser", "feature-explorer"]] = DEFAULT_APP, ): diff --git a/app/api/resources/templates/shared/id_content.html b/app/api/resources/templates/shared/id_content.html index 5d4d25d..3bb82fb 100644 --- a/app/api/resources/templates/shared/id_content.html +++ b/app/api/resources/templates/shared/id_content.html @@ -3,7 +3,7 @@
- Gene + Stable ID {{ response.stable_id }}
{% if (response.code == 404 or response.code == 500) and response.message %} diff --git a/app/api/utils/commons.py b/app/api/utils/commons.py index 951bc59..cd0f230 100644 --- a/app/api/utils/commons.py +++ b/app/api/utils/commons.py @@ -13,10 +13,14 @@ def build_stable_id_resolver_content(metadata_results) -> list[StableIdResolverC content = StableIdResolverContent( feature_explorer_url=build_feature_explorer_url( - genome_id, metadata["unversioned_stable_id"] + genome_id, + metadata["unversioned_stable_id"], + metadata.get("stable_id_type", "gene"), ), genome_browser_url=build_genome_browser_url( - genome_id, metadata["unversioned_stable_id"] + genome_id, + metadata["unversioned_stable_id"], + metadata.get("stable_id_type", "gene"), ), release_type=metadata.get("release", {}).get("type", ""), release_name=metadata.get("release", {}).get("name", ""), @@ -27,12 +31,19 @@ def build_stable_id_resolver_content(metadata_results) -> list[StableIdResolverC return results -def build_feature_explorer_url(genome_id: str, stable_id: str) -> str: - return f"{ENSEMBL_URL}/feature-explorer/{genome_id}/gene:{stable_id}" +def build_feature_explorer_url( + genome_id: str, stable_id: str, stable_id_type: str = "gene" +) -> str: + return f"{ENSEMBL_URL}/feature-explorer/{genome_id}/{stable_id_type}:{stable_id}" -def build_genome_browser_url(genome_id: str, stable_id: str) -> str: - return f"{ENSEMBL_URL}/genome-browser/{genome_id}?focus=gene:{stable_id}" +def build_genome_browser_url( + genome_id: str, stable_id: str, stable_id_type: str = "gene" +) -> str: + return ( + f"{ENSEMBL_URL}/genome-browser/{genome_id}" + f"?focus={stable_id_type}:{stable_id}" + ) def is_json_request(request) -> bool: diff --git a/app/api/utils/metadata.py b/app/api/utils/metadata.py index 0053993..3081bf9 100644 --- a/app/api/utils/metadata.py +++ b/app/api/utils/metadata.py @@ -25,6 +25,11 @@ def get_metadata(matches: List[SearchMatch] = []): metadata_results[genome_id]["unversioned_stable_id"] = match.get( "unversioned_stable_id" ) + # Fast-match supplies this field. Keep the historical gene + # default for search-hub responses that do not yet include it. + metadata_results[genome_id]["stable_id_type"] = ( + match.get("type") or match.get("doc_type") or "gene" + ) except Exception as e: raise Exception( f"Failed to fetch metadata for genome '{genome_id}': {e}" diff --git a/app/api/utils/search.py b/app/api/utils/search.py index d2b6510..d60b453 100644 --- a/app/api/utils/search.py +++ b/app/api/utils/search.py @@ -71,7 +71,7 @@ def get_fast_match_results(params: SearchPayload): # version suffixes (for example, ``ENSG00000127720.3``) are removed. unversioned_stable_id = re.sub(r"\.\d+$", "", params.stable_id) matches = [] - seen_genome_ids = set() + seen_matches = set() for raw_match in raw_matches.split("+"): try: @@ -89,14 +89,19 @@ def get_fast_match_results(params: SearchPayload): if params.type and doc_type != params.type: continue - if genome_id not in seen_genome_ids: + # A stable ID normally identifies one feature type, but retain the type + # in both the result and deduplication key when no filter was supplied. + # The resolver needs it to construct a gene or transcript destination. + match_key = (genome_id, doc_type) + if match_key not in seen_matches: matches.append( { "genome_id": genome_id, "unversioned_stable_id": unversioned_stable_id, + "type": doc_type, } ) - seen_genome_ids.add(genome_id) + seen_matches.add(match_key) logger.info( f"Fast-match stable ID lookup result: {len(matches)} matches " diff --git a/app/static/APISpecification.yaml b/app/static/APISpecification.yaml index 8f83a11..c68e067 100644 --- a/app/static/APISpecification.yaml +++ b/app/static/APISpecification.yaml @@ -42,7 +42,7 @@ paths: parameters: - name: stable_id in: path - description: Gene stable id used to represent a gene in a genome. + description: Gene, transcript, or protein stable ID. required: true explode: true schema: @@ -50,11 +50,11 @@ paths: example: ENSG00000221914.11 - name: type in: query - description: Defines a type - gene/transcript/variant etc + description: Optional stable-ID type filter. If omitted, all supported types are searched. required: false schema: type: string - enum: [ gene, transcript, variant ] + enum: [ gene, transcript, protein ] example: gene - name: app diff --git a/stable_ids_sample.csv.redb b/stable_ids_sample.csv.redb new file mode 100644 index 0000000000000000000000000000000000000000..a01f642c24a3d7610373779feb8f078ec7f0dd93 GIT binary patch literal 1056768 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�NelXHh2PTzrVR~99#-_!aCIJw*G%l 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