Spaces:
Running
Running
File size: 3,309 Bytes
3942959 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 | """
Boolean query parser for PubMed-style queries.
Splits a boolean query into AND-fragments, then each fragment into OR-terms.
Strips field tags ([tiab], [MeSH Terms], etc.), wildcards, parentheses, quotes,
and filters empty / pure-operator tokens.
"""
import re
from typing import List
# PubMed field tags: [tiab], [MeSH Terms], [tw], [mp], etc.
_FIELD_TAG = re.compile(
r'\[\s*(?:'
r'ti|ab|tiab|tw|all\s+fields|mesh\s+terms?|major\s+mesh|mp|mh|sh|rn|nm|ot|'
r'pt|la|jw|so|af|aud|cois|mf|px|rf|ro|rx|sb|si|subh|ec|ip|lr|od|pg|pl|vi|'
r'au|corp|fau|gr|invt|ir|irad|cn|ed|fd|auid|book|series|pmid|tr|dn|ddt|isbn|'
r'edat|pdat|mhda|crdt|entrez\s+date|publication\s+date|mesh\s+date|create\s+date'
r')\s*(?::\w+)?\s*\]',
re.IGNORECASE,
)
_STOP_WORDS = frozenset({'and', 'or', 'not', 'and not'})
# Sentinel to protect AND NOT from being split as AND
_ANDNOT_SENTINEL = '\x00ANDNOT\x00'
def _clean_term(raw: str) -> str:
"""Strip field tags, wildcards, brackets, quotes, leading NOT from a token."""
term = _FIELD_TAG.sub('', raw)
term = term.replace('*', '')
term = term.replace('(', '').replace(')', '')
# Straight and curly quotes
term = term.replace('"', '').replace('\u201c', '').replace('\u201d', '')
term = term.replace("'", '')
# Strip leading NOT
term = re.sub(r'^\s*NOT\s+', '', term, flags=re.IGNORECASE)
return term.strip()
def parse_boolean_query(query: str) -> List[List[str]]:
"""
Parse a boolean query into groups of keyword strings.
Input:
'("diabetes mellitus" OR insulin) AND ("heart attack" OR "myocardial infarction")'
Output:
[["diabetes mellitus", "insulin"], ["heart attack", "myocardial infarction"]]
Rules:
- Split on AND (case-insensitive), but skip AND NOT fragments (negative groups dropped)
- Each AND-fragment is further split on OR
- Field tags, wildcards, parentheses, quotes, and NOT prefixes are stripped
- Empty tokens and bare boolean operators are filtered out
- If no groups are detected, the query is returned as a single flat group
(comma-separated tokens, or the whole string as one keyword)
"""
if not query or not query.strip():
return []
# Protect AND NOT so it isn't split on AND
work = re.sub(r'\bAND\s+NOT\b', _ANDNOT_SENTINEL, query, flags=re.IGNORECASE)
# Split on AND
and_fragments = re.split(r'\bAND\b', work, flags=re.IGNORECASE)
groups: List[List[str]] = []
for fragment in and_fragments:
# Drop negative fragments
if _ANDNOT_SENTINEL in fragment:
continue
# Split on OR
raw_terms = re.split(r'\bOR\b', fragment, flags=re.IGNORECASE)
terms: List[str] = []
for raw in raw_terms:
cleaned = _clean_term(raw)
if cleaned and cleaned.lower() not in _STOP_WORDS:
terms.append(cleaned)
if terms:
groups.append(terms)
# Fallback: no boolean structure detected — try comma-separated or single group
if not groups:
if ',' in query:
terms = [t.strip() for t in query.split(',') if t.strip()]
else:
terms = [query.strip()] if query.strip() else []
if terms:
groups = [terms]
return groups
|