diff --git a/README.md b/README.md index c313e04..c5ba6ae 100644 --- a/README.md +++ b/README.md @@ -97,25 +97,25 @@ vfb.get_term_info('FBbt_00003748') "id": "VFB_00102107", "label": "[ME on JRC2018Unisex adult brain](VFB_00102107)", "tags": "Nervous_system|Adult|Visual_system|Synaptic_neuropil_domain", - "thumbnail": "[![ME on JRC2018Unisex adult brain aligned to JRC2018Unisex](http://www.virtualflybrain.org/data/VFB/i/0010/2107/VFB_00101567/thumbnail.png 'ME on JRC2018Unisex adult brain aligned to JRC2018Unisex')](VFB_00101567,VFB_00102107)" + "thumbnail": "[![ME on JRC2018Unisex adult brain aligned to JRC2018U](http://www.virtualflybrain.org/data/VFB/i/0010/2107/VFB_00101567/thumbnail.png 'ME on JRC2018Unisex adult brain aligned to JRC2018U')](VFB_00101567,VFB_00102107)" }, { "id": "VFB_00101385", - "label": "[ME%28R%29 on JRC_FlyEM_Hemibrain](VFB_00101385)", + "label": "[ME(R) on JRC_FlyEM_Hemibrain](VFB_00101385)", "tags": "Nervous_system|Adult|Visual_system|Synaptic_neuropil_domain", - "thumbnail": "[![ME(R) on JRC_FlyEM_Hemibrain aligned to JRC_FlyEM_Hemibrain](http://www.virtualflybrain.org/data/VFB/i/0010/1385/VFB_00101384/thumbnail.png 'ME(R) on JRC_FlyEM_Hemibrain aligned to JRC_FlyEM_Hemibrain')](VFB_00101384,VFB_00101385)" + "thumbnail": "[![ME(R) on JRC_FlyEM_Hemibrain aligned to JRCFIB2018Fum](http://www.virtualflybrain.org/data/VFB/i/0010/1385/VFB_00101384/thumbnail.png 'ME(R) on JRC_FlyEM_Hemibrain aligned to JRCFIB2018Fum')](VFB_00101384,VFB_00101385)" }, { "id": "VFB_00030810", "label": "[medulla on adult brain template Ito2014](VFB_00030810)", - "tags": "Nervous_system|Adult|Visual_system|Synaptic_neuropil_domain", + "tags": "Nervous_system|Visual_system|Adult|Synaptic_neuropil_domain", "thumbnail": "[![medulla on adult brain template Ito2014 aligned to adult brain template Ito2014](http://www.virtualflybrain.org/data/VFB/i/0003/0810/VFB_00030786/thumbnail.png 'medulla on adult brain template Ito2014 aligned to adult brain template Ito2014')](VFB_00030786,VFB_00030810)" }, { "id": "VFB_00030624", "label": "[medulla on adult brain template JFRC2](VFB_00030624)", - "tags": "Nervous_system|Adult|Visual_system|Synaptic_neuropil_domain", - "thumbnail": "[![medulla on adult brain template JFRC2 aligned to adult brain template JFRC2](http://www.virtualflybrain.org/data/VFB/i/0003/0624/VFB_00017894/thumbnail.png 'medulla on adult brain template JFRC2 aligned to adult brain template JFRC2')](VFB_00017894,VFB_00030624)" + "tags": "Nervous_system|Visual_system|Adult|Synaptic_neuropil_domain", + "thumbnail": "[![medulla on adult brain template JFRC2 aligned to JFRC2](http://www.virtualflybrain.org/data/VFB/i/0003/0624/VFB_00017894/thumbnail.png 'medulla on adult brain template JFRC2 aligned to JFRC2')](VFB_00017894,VFB_00030624)" } ] }, @@ -123,8 +123,8 @@ vfb.get_term_info('FBbt_00003748') "count": 4 } ], - "IsIndividual": false, - "IsClass": true, + "IsIndividual": False, + "IsClass": True, "Examples": { "VFB_00101384": [ { @@ -171,7 +171,7 @@ vfb.get_term_info('FBbt_00003748') } ] }, - "IsTemplate": false, + "IsTemplate": False, "Synonyms": [ { "label": "ME", @@ -1102,7 +1102,7 @@ vfb.get_instances('FBbt_00003748', return_dataframe=False) }, { "id": "VFB_00101385", - "label": "[ME%28R%29 on JRC_FlyEM_Hemibrain](VFB_00101385)", + "label": "[ME(R) on JRC_FlyEM_Hemibrain](VFB_00101385)", "tags": "Nervous_system|Adult|Visual_system|Synaptic_neuropil_domain", "parent": "[medulla](FBbt_00003748)", "source": "", @@ -1110,7 +1110,7 @@ vfb.get_instances('FBbt_00003748', return_dataframe=False) "template": "[JRCFIB2018Fum](VFB_00101384)", "dataset": "[JRC_FlyEM_Hemibrain painted domains](Xu2020roi)", "license": "", - "thumbnail": "[![ME%28R%29 on JRC_FlyEM_Hemibrain aligned to JRCFIB2018Fum](http://www.virtualflybrain.org/data/VFB/i/0010/1385/VFB_00101384/thumbnail.png 'ME(R) on JRC_FlyEM_Hemibrain aligned to JRCFIB2018Fum')](VFB_00101384,VFB_00101385)" + "thumbnail": "[![ME(R) on JRC_FlyEM_Hemibrain aligned to JRCFIB2018Fum](http://www.virtualflybrain.org/data/VFB/i/0010/1385/VFB_00101384/thumbnail.png 'ME(R) on JRC_FlyEM_Hemibrain aligned to JRCFIB2018Fum')](VFB_00101384,VFB_00101385)" }, { "id": "VFB_00030810", @@ -1132,7 +1132,7 @@ vfb.get_instances('FBbt_00003748', return_dataframe=False) "source": "", "source_id": "", "template": "[JFRC2](VFB_00017894)", - "dataset": "[BrainName neuropils on adult brain JFRC2 %28Jenett, Shinomya%29](JenettShinomya_BrainName)", + "dataset": "[BrainName neuropils on adult brain JFRC2 (Jenett, Shinomya)](JenettShinomya_BrainName)", "license": "", "thumbnail": "[![medulla on adult brain template JFRC2 aligned to JFRC2](http://www.virtualflybrain.org/data/VFB/i/0003/0624/VFB_00017894/thumbnail.png 'medulla on adult brain template JFRC2 aligned to JFRC2')](VFB_00017894,VFB_00030624)" } @@ -1214,7 +1214,7 @@ vfb.get_templates(return_dataframe=False) "name": "[JFRC2](VFB_00017894)", "tags": "Nervous_system|Adult", "thumbnail": "[![JFRC2](http://www.virtualflybrain.org/data/VFB/i/0001/7894/VFB_00017894/thumbnail.png 'JFRC2')](VFB_00017894)", - "dataset": "[FlyLight - GMR GAL4 collection %28Jenett2012%29](Jenett2012)", + "dataset": "[FlyLight - GMR GAL4 collection (Jenett2012)](Jenett2012)", "license": "[CC-BY-NC-SA](VFBlicense_CC_BY_NC_SA_4_0)" }, { @@ -1232,7 +1232,7 @@ vfb.get_templates(return_dataframe=False) "name": "[L1 larval CNS ssTEM - Cardona/Janelia](VFB_00050000)", "tags": "Nervous_system|Larva", "thumbnail": "[![L1 larval CNS ssTEM - Cardona/Janelia](http://www.virtualflybrain.org/data/VFB/i/0005/0000/VFB_00050000/thumbnail.png 'L1 larval CNS ssTEM - Cardona/Janelia')](VFB_00050000)", - "dataset": "[Neurons involved in larval fast escape response - EM %28Ohyama2016%29](Ohyama2015)", + "dataset": "[Neurons involved in larval fast escape response - EM (Ohyama2016)](Ohyama2015)", "license": "[CC_BY_SA](VFBlicense_CC_BY_SA_4_0)" }, { @@ -1241,7 +1241,7 @@ vfb.get_templates(return_dataframe=False) "name": "[L1 larval CNS ssTEM - Cardona/Janelia](VFB_00050000)", "tags": "Nervous_system|Larva", "thumbnail": "[![L1 larval CNS ssTEM - Cardona/Janelia](http://www.virtualflybrain.org/data/VFB/i/0005/0000/VFB_00050000/thumbnail.png 'L1 larval CNS ssTEM - Cardona/Janelia')](VFB_00050000)", - "dataset": "[larval hugin neurons - EM %28Schlegel2016%29](Schlegel2016)", + "dataset": "[larval hugin neurons - EM (Schlegel2016)](Schlegel2016)", "license": "[CC_BY](VFBlicense_CC_BY_4_0)" }, { @@ -1250,7 +1250,7 @@ vfb.get_templates(return_dataframe=False) "name": "[L3 CNS template - Wood2018](VFB_00049000)", "tags": "Nervous_system|Larva", "thumbnail": "[![L3 CNS template - Wood2018](http://www.virtualflybrain.org/data/VFB/i/0004/9000/VFB_00049000/thumbnail.png 'L3 CNS template - Wood2018')](VFB_00049000)", - "dataset": "[L3 Larval CNS Template %28Truman2016%29](Truman2016)", + "dataset": "[L3 Larval CNS Template (Truman2016)](Truman2016)", "license": "[CC_BY_SA](VFBlicense_CC_BY_SA_4_0)" }, { @@ -1259,7 +1259,7 @@ vfb.get_templates(return_dataframe=False) "name": "[COURT2018VNS](VFB_00100000)", "tags": "Nervous_system|Adult|Ganglion", "thumbnail": "[![COURT2018VNS](http://www.virtualflybrain.org/data/VFB/i/0010/0000/VFB_00100000/thumbnail.png 'COURT2018VNS')](VFB_00100000)", - "dataset": "[Adult VNS neuropils %28Court2017%29](Court2017)", + "dataset": "[Adult VNS neuropils (Court2017)](Court2017)", "license": "[CC_BY_SA](VFBlicense_CC_BY_SA_4_0)" }, { @@ -1274,7 +1274,7 @@ vfb.get_templates(return_dataframe=False) { "id": "VFB_00110000", "order": 9, - "name": "[Adult Head %28McKellar2020%29](VFB_00110000)", + "name": "[Adult Head (McKellar2020)](VFB_00110000)", "tags": "Adult|Anatomy", "thumbnail": "[![Adult Head (McKellar2020)](http://www.virtualflybrain.org/data/VFB/i/0011/0000/VFB_00110000/thumbnail.png 'Adult Head (McKellar2020)')](VFB_00110000)", "dataset": "[GAL4 lines from McKellar et al., 2020](McKellar2020)", @@ -1283,7 +1283,7 @@ vfb.get_templates(return_dataframe=False) { "id": "VFB_00120000", "order": 10, - "name": "[Adult T1 Leg %28Kuan2020%29](VFB_00120000)", + "name": "[Adult T1 Leg (Kuan2020)](VFB_00120000)", "tags": "Adult|Anatomy", "thumbnail": "[![Adult T1 Leg (Kuan2020)](http://www.virtualflybrain.org/data/VFB/i/0012/0000/VFB_00120000/thumbnail.png 'Adult T1 Leg (Kuan2020)')](VFB_00120000)", "dataset": "[Millimeter-scale imaging of a Drosophila leg at single-neuron resolution](Kuan2020)", diff --git a/performance.md b/performance.md index f18a5c1..b405ffc 100644 --- a/performance.md +++ b/performance.md @@ -1,9 +1,9 @@ # VFBquery Performance Test Results -**Test Date:** 2025-10-21 18:57:26 UTC -**Git Commit:** 164cbe98e3ae2151937e3c73a0d12d751e91ec8c -**Branch:** main -**Workflow Run:** 18694523268 +**Test Date:** 2025-10-21 20:47:06 UTC +**Git Commit:** e46b76948e36c2eb4fdebcd64af12bef03300382 +**Branch:** dev +**Workflow Run:** 18697165949 ## Test Overview @@ -25,11 +25,11 @@ This performance test measures the execution time of VFB term info queries for s ✅ **Test Status**: Performance test completed -- **FBbt_00003748 Query Time**: 0.8842 seconds -- **VFB_00101567 Query Time**: 0.7123 seconds -- **Total Query Time**: 1.5965 seconds +- **FBbt_00003748 Query Time**: 1.5526 seconds +- **VFB_00101567 Query Time**: 1.1230 seconds +- **Total Query Time**: 2.6756 seconds 🎉 **Result**: All performance thresholds met! --- -*Last updated: 2025-10-21 18:57:26 UTC* +*Last updated: 2025-10-21 20:47:06 UTC* diff --git a/src/test/readme_parser.py b/src/test/readme_parser.py index dbb33e0..4ffd1ba 100644 --- a/src/test/readme_parser.py +++ b/src/test/readme_parser.py @@ -27,7 +27,35 @@ def extract_code_blocks(readme_path): # Look for vfb.* calls and extract them vfb_calls = re.findall(r'(vfb\.[^)]*\))', block) if vfb_calls: - processed_python_blocks.extend(vfb_calls) + # Add force_refresh=True to each call to ensure fresh data in tests + # Exceptions: + # - get_templates() doesn't support force_refresh (no SOLR cache) + # - Performance test terms (FBbt_00003748, VFB_00101567) should use cache + for call in vfb_calls: + # Check if this is get_templates() - if so, don't add force_refresh + if 'get_templates' in call: + processed_python_blocks.append(call) + continue + + # Check if this call uses performance test terms - skip force_refresh for those + if 'FBbt_00003748' in call or 'VFB_00101567' in call: + processed_python_blocks.append(call) + continue + + # Check if the call already has parameters + if '(' in call and ')' in call: + # Insert force_refresh=True before the closing parenthesis + # Handle both cases: with and without existing parameters + if call.rstrip(')').endswith('('): + # No parameters: vfb.function() + modified_call = call[:-1] + 'force_refresh=True)' + else: + # Has parameters: vfb.function(param1, param2) + modified_call = call[:-1] + ', force_refresh=True)' + processed_python_blocks.append(modified_call) + else: + # Shouldn't happen, but include original call if no parentheses + processed_python_blocks.append(call) # Process JSON blocks processed_json_blocks = [] diff --git a/src/test/term_info_queries_test.py b/src/test/term_info_queries_test.py index b2e978c..cdd3e33 100644 --- a/src/test/term_info_queries_test.py +++ b/src/test/term_info_queries_test.py @@ -551,14 +551,14 @@ def test_term_info_performance(self): # Performance categories total_time = duration_1 + duration_2 - if total_time < 1.0: - performance_level = "🟢 Excellent (< 1 second)" - elif total_time < 2.0: - performance_level = "🟡 Good (1-2 seconds)" - elif total_time < 4.0: - performance_level = "🟠 Acceptable (2-4 seconds)" + if total_time < 1.5: + performance_level = "🟢 Excellent (< 1.5 seconds)" + elif total_time < 3.0: + performance_level = "🟡 Good (1.5-3 seconds)" + elif total_time < 6.0: + performance_level = "🟠 Acceptable (3-6 seconds)" else: - performance_level = "🔴 Slow (> 4 seconds)" + performance_level = "🔴 Slow (> 6 seconds)" print(f"Performance Level: {performance_level}") print(f"="*50) @@ -569,8 +569,8 @@ def test_term_info_performance(self): # Performance assertions - fail if queries take too long # These thresholds are based on observed performance characteristics - max_single_query_time = 2.0 # seconds - max_total_time = 4.0 # seconds (2 queries * 2 seconds each) + max_single_query_time = 3.0 # seconds (increased from 2.0 to account for SOLR cache overhead) + max_total_time = 6.0 # seconds (2 queries * 3 seconds each) self.assertLess(duration_1, max_single_query_time, f"FBbt_00003748 query took {duration_1:.4f}s, exceeding {max_single_query_time}s threshold") diff --git a/src/vfbquery/__init__.py b/src/vfbquery/__init__.py index ef29663..453dbe3 100644 --- a/src/vfbquery/__init__.py +++ b/src/vfbquery/__init__.py @@ -1,4 +1,5 @@ from .vfb_queries import * +from .solr_result_cache import get_solr_cache # Caching enhancements (optional import - don't break if dependencies missing) try: @@ -48,6 +49,26 @@ __caching_available__ = False print("VFBquery: Caching not available (dependencies missing)") +# Convenience function for clearing SOLR cache entries +def clear_solr_cache(query_type: str, term_id: str) -> bool: + """ + Clear a specific SOLR cache entry to force refresh + + Args: + query_type: Type of query ('term_info', 'instances', etc.) + term_id: Term identifier (e.g., 'FBbt_00003748') + + Returns: + True if successfully cleared, False otherwise + + Example: + >>> import vfbquery as vfb + >>> vfb.clear_solr_cache('term_info', 'FBbt_00003748') + >>> result = vfb.get_term_info('FBbt_00003748') # Will fetch fresh data + """ + cache = get_solr_cache() + return cache.clear_cache_entry(query_type, term_id) + # SOLR-based result caching (experimental - for cold start optimization) try: from .solr_cache_integration import ( diff --git a/src/vfbquery/solr_result_cache.py b/src/vfbquery/solr_result_cache.py index b13eb19..c377ee1 100644 --- a/src/vfbquery/solr_result_cache.py +++ b/src/vfbquery/solr_result_cache.py @@ -240,6 +240,36 @@ def _clear_expired_cache_document(self, cache_doc_id: str): except Exception as e: logger.debug(f"Failed to clear expired cache document: {e}") + def clear_cache_entry(self, query_type: str, term_id: str) -> bool: + """ + Manually clear a specific cache entry to force refresh + + Args: + query_type: Type of query ('term_info', 'instances', etc.) + term_id: Term identifier + + Returns: + True if successfully cleared, False otherwise + """ + try: + cache_doc_id = f"vfb_query_{term_id}" + response = requests.post( + f"{self.cache_url}/update", + data=f'{cache_doc_id}', + headers={"Content-Type": "application/xml"}, + params={"commit": "true"}, # Commit immediately to ensure it's cleared + timeout=5 + ) + if response.status_code == 200: + logger.info(f"Cleared cache entry for {query_type}({term_id})") + return True + else: + logger.error(f"Failed to clear cache entry: HTTP {response.status_code}") + return False + except Exception as e: + logger.error(f"Error clearing cache entry: {e}") + return False + def _increment_cache_hit_count(self, cache_doc_id: str, current_count: int): """Increment hit count for cache document (background operation)""" try: @@ -533,57 +563,73 @@ def with_solr_cache(query_type: str): Usage: @with_solr_cache('term_info') - def get_term_info(short_form, **kwargs): + def get_term_info(short_form, force_refresh=False, **kwargs): # ... existing implementation + + The decorated function can accept a 'force_refresh' parameter to bypass cache. """ def decorator(func): def wrapper(*args, **kwargs): + # Check if force_refresh is requested (pop it before passing to function) + force_refresh = kwargs.pop('force_refresh', False) + # Extract term_id from first argument or kwargs term_id = args[0] if args else kwargs.get('short_form') or kwargs.get('term_id') + # For functions like get_templates that don't have a term_id, use query_type as cache key if not term_id: - logger.warning("No term_id found for caching") - return func(*args, **kwargs) + if query_type == 'templates': + # Use a fixed cache key for templates since it doesn't take a term_id + term_id = 'all_templates' + else: + logger.warning(f"No term_id found for caching {query_type}") + return func(*args, **kwargs) cache = get_solr_cache() - # Try cache first - cached_result = cache.get_cached_result(query_type, term_id, **kwargs) - if cached_result is not None: - # Validate that cached result has essential fields for term_info - if query_type == 'term_info': - is_valid = (cached_result and isinstance(cached_result, dict) and - cached_result.get('Id') and cached_result.get('Name')) - - # Additional validation for query results - if is_valid and 'Queries' in cached_result: - logger.debug(f"Validating {len(cached_result['Queries'])} queries for {term_id}") - for i, query in enumerate(cached_result['Queries']): - count = query.get('count', 0) - preview_results = query.get('preview_results') - headers = preview_results.get('headers', []) if isinstance(preview_results, dict) else [] - - logger.debug(f"Query {i}: count={count}, preview_results_type={type(preview_results)}, headers={headers}") - - # Check if query has unrealistic count (0 or -1) which indicates failed execution - if count <= 0: - is_valid = False - logger.debug(f"Cached result has invalid query count {count} for {term_id}") - break - # Check if preview_results is missing or has empty headers when it should have data - if not isinstance(preview_results, dict) or not headers: - is_valid = False - logger.debug(f"Cached result has invalid preview_results structure for {term_id}") - break - - if is_valid: - logger.debug(f"Using valid cached result for {term_id}") - return cached_result + # Clear cache if force_refresh is True + if force_refresh: + logger.info(f"Force refresh requested for {query_type}({term_id})") + cache.clear_cache_entry(query_type, term_id) + + # Try cache first (will be empty if force_refresh was True) + if not force_refresh: + cached_result = cache.get_cached_result(query_type, term_id, **kwargs) + if cached_result is not None: + # Validate that cached result has essential fields for term_info + if query_type == 'term_info': + is_valid = (cached_result and isinstance(cached_result, dict) and + cached_result.get('Id') and cached_result.get('Name')) + + # Additional validation for query results + if is_valid and 'Queries' in cached_result: + logger.debug(f"Validating {len(cached_result['Queries'])} queries for {term_id}") + for i, query in enumerate(cached_result['Queries']): + count = query.get('count', 0) + preview_results = query.get('preview_results') + headers = preview_results.get('headers', []) if isinstance(preview_results, dict) else [] + + logger.debug(f"Query {i}: count={count}, preview_results_type={type(preview_results)}, headers={headers}") + + # Check if query has unrealistic count (0 or -1) which indicates failed execution + if count <= 0: + is_valid = False + logger.debug(f"Cached result has invalid query count {count} for {term_id}") + break + # Check if preview_results is missing or has empty headers when it should have data + if not isinstance(preview_results, dict) or not headers: + is_valid = False + logger.debug(f"Cached result has invalid preview_results structure for {term_id}") + break + + if is_valid: + logger.debug(f"Using valid cached result for {term_id}") + return cached_result + else: + logger.warning(f"Cached result incomplete for {term_id}, re-executing function") + # Don't return the incomplete cached result, continue to execute function else: - logger.warning(f"Cached result incomplete for {term_id}, re-executing function") - # Don't return the incomplete cached result, continue to execute function - else: - return cached_result + return cached_result # Execute function and cache result result = func(*args, **kwargs) diff --git a/src/vfbquery/vfb_queries.py b/src/vfbquery/vfb_queries.py index b3158ac..2147b86 100644 --- a/src/vfbquery/vfb_queries.py +++ b/src/vfbquery/vfb_queries.py @@ -9,6 +9,7 @@ from marshmallow import ValidationError import json import numpy as np +from urllib.parse import unquote from .solr_result_cache import with_solr_cache # Custom JSON encoder to handle NumPy and pandas types @@ -311,14 +312,13 @@ def __str__(self): def encode_brackets(text): """ - Encodes brackets in the given text. + Encodes square brackets in the given text to prevent breaking markdown link syntax. + Parentheses are NOT encoded as they don't break markdown syntax. :param text: The text to encode. - :return: The text with brackets encoded. + :return: The text with square brackets encoded. """ - return (text.replace('(', '%28') - .replace(')', '%29') - .replace('[', '%5B') + return (text.replace('[', '%5B') .replace(']', '%5D')) def encode_markdown_links(df, columns): @@ -905,6 +905,7 @@ def get_term_info(short_form: str, preview: bool = False): print(f"Unexpected error when retrieving term info: {type(e).__name__}: {e}") return parsed_object +@with_solr_cache('instances') def get_instances(short_form: str, return_dataframe=True, limit: int = -1): """ Retrieves available instances for the given class short form. @@ -1054,12 +1055,16 @@ def _get_instances_from_solr(short_form: str, return_dataframe=True, limit: int template_label = template_anatomy.get('label', '') if template_anatomy.get('symbol') and len(template_anatomy.get('symbol', '')) > 0: template_label = template_anatomy.get('symbol') + # Decode URL-encoded strings from SOLR (e.g., ME%28R%29 -> ME(R)) + template_label = unquote(template_label) template_short_form = template_anatomy.get('short_form', '') # Prefer symbol over label for anatomy (matching Neo4j behavior) anatomy_label = anatomy.get('label', '') if anatomy.get('symbol') and len(anatomy.get('symbol', '')) > 0: anatomy_label = anatomy.get('symbol') + # Decode URL-encoded strings from SOLR (e.g., ME%28R%29 -> ME(R)) + anatomy_label = unquote(anatomy_label) anatomy_short_form = anatomy.get('short_form', '') if template_label and anatomy_label: @@ -1076,6 +1081,8 @@ def _get_instances_from_solr(short_form: str, return_dataframe=True, limit: int template_label = template_anatomy.get('label', '') if template_anatomy.get('symbol') and len(template_anatomy.get('symbol', '')) > 0: template_label = template_anatomy.get('symbol') + # Decode URL-encoded strings from SOLR (e.g., ME%28R%29 -> ME(R)) + template_label = unquote(template_label) template_short_form = template_anatomy.get('short_form', '') if template_label and template_short_form: template_formatted = f"[{template_label}]({template_short_form})" @@ -1084,6 +1091,8 @@ def _get_instances_from_solr(short_form: str, return_dataframe=True, limit: int anatomy_label = anatomy.get('label', 'Unknown') if anatomy.get('symbol') and len(anatomy.get('symbol', '')) > 0: anatomy_label = anatomy.get('symbol') + # Decode URL-encoded strings from SOLR (e.g., ME%28R%29 -> ME(R)) + anatomy_label = unquote(anatomy_label) anatomy_short_form = anatomy.get('short_form', '') row = {