"""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))