131 lines
4.1 KiB
Python
131 lines
4.1 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))
|