dp-zp-agent/tools/retrieval/work_selector.py

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