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

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