80 lines
3.6 KiB
Python
80 lines
3.6 KiB
Python
"""Source-heading selectors; negated project numbers never become positive filters."""
|
|
from dataclasses import dataclass
|
|
import re
|
|
import unicodedata
|
|
|
|
|
|
def normalize(text):
|
|
return ''.join(c for c in unicodedata.normalize('NFKD',text).casefold() if not unicodedata.combining(c))
|
|
|
|
|
|
def label_key(text):
|
|
return ' '.join(re.sub(r'[^\w]+',' ',normalize(text)).split())
|
|
|
|
|
|
QUOTED=re.compile(r'[„“"«]([^„“”"»]+)[“”"»]')
|
|
HEADING=re.compile(r'(?:diplomovy|bakalarsky|timovy|vyskumny|vedecky) projekt(?: [1-9]\d?)? [12]\d{3}|'
|
|
r'(?:diplomova|bakalarska|dizertacna) praca [12]\d{3}')
|
|
NUMBER=re.compile(r'\bprojekt\w*\s+([1-9]\d?)(?!\d)\b')
|
|
NEGATIVE=re.compile(r'\b(?:nie|okrem|bez|s vynimkou)\s+(?:(?:diplomov|bakalarsk|timov|vyskumn|vedeck)\w*\s+)?$')
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WorkSelector:
|
|
labels: tuple=()
|
|
excluded_labels: tuple=()
|
|
numbers: tuple=()
|
|
excluded_numbers: tuple=()
|
|
unnumbered: bool=False
|
|
multiple: bool=False
|
|
positive_text: str=''
|
|
unsupported_negation: bool=False
|
|
|
|
@property
|
|
def specific(self):
|
|
return bool(self.labels or self.excluded_labels or self.numbers or self.excluded_numbers or self.unnumbered)
|
|
|
|
def matches(self,label):
|
|
key=label_key(label);match=NUMBER.search(key);number=int(match[1]) if match else None
|
|
return ((not self.labels or key in self.labels) and key not in self.excluded_labels and
|
|
(not self.numbers or number in self.numbers) and number not in self.excluded_numbers and
|
|
(not self.unnumbered or number is None))
|
|
|
|
|
|
def parse_work_selector(question):
|
|
text=normalize(question);positive=[];negative=[];excluded_spans=[]
|
|
for match in QUOTED.finditer(text):
|
|
key=label_key(match[1])
|
|
if not HEADING.fullmatch(key):continue
|
|
neg=NEGATIVE.search(text[:match.start()])
|
|
if neg:
|
|
negative.append(key);excluded_spans.append((neg.start(),match.end()))
|
|
else:positive.append(key)
|
|
numbers=[];excluded=[];unsupported=False
|
|
for match in NUMBER.finditer(text):
|
|
if any(quote.start()<=match.start()<quote.end() for quote in QUOTED.finditer(text)):continue
|
|
neg=NEGATIVE.search(text[:match.start()])
|
|
if neg:
|
|
excluded.append(int(match[1]));excluded_spans.append((neg.start(),match.end()))
|
|
# Year-qualified exclusions require the exact quoted heading.
|
|
if re.match(r'\s+(?:(?:v|z) roku\s+)?\d{4}\b',text[match.end():]):unsupported=True
|
|
else:numbers.append(int(match[1]))
|
|
cleaned=text
|
|
for start,end in sorted(excluded_spans,reverse=True):cleaned=cleaned[:start]+' '*(end-start)+cleaned[end:]
|
|
if re.search(r'\bnie\s+(?:(?:diplomov|bakalarsk|timov|vyskumn|vedeck)\w*\s+)?(?:projekt\w*|prac\w*|(?:(?:v|z)\s+)?(?:rok\w*\s+)?\d{4})\b',cleaned):
|
|
unsupported=True
|
|
multiple=bool(re.search(r'\b(?:oba|obe|oboch|obidv\w*|vsetk\w*)\s+'
|
|
r'(?:(?:jeho|tieto|uveden\w*|diplomov\w*|bakalarsk\w*|timov\w*|vyskumn\w*|vedeck\w*)\s+)*'
|
|
r'(?:projekt\w*|prac\w*)\b',text)) or len(set(positive))>1 or len(set(numbers))>1
|
|
return WorkSelector(tuple(dict.fromkeys(positive)),tuple(dict.fromkeys(negative)),
|
|
tuple(dict.fromkeys(numbers)),tuple(dict.fromkeys(excluded)),
|
|
bool(re.search(r'\bnecislo\w*\b|\bprojekt\w*\s+bez cisla\b',text)),multiple,cleaned,unsupported)
|
|
|
|
|
|
def selector_years(question):
|
|
selector=parse_work_selector(question)
|
|
text=QUOTED.sub(' ',selector.positive_text)
|
|
years=[int(v) for v in re.findall(r'\b\d{4}\b',text)]
|
|
years.extend(int(label.split()[-1]) for label in selector.labels)
|
|
return list(dict.fromkeys(years))
|