"""Typed evidence from the RAG text actually delivered after its context budget. No Neo4j query, source oracle, benchmark answer or unseen Markdown is used. Taxonomy years are deliberately not treated as years of academic works. """ from __future__ import annotations import re from .context_policy import normalized, text_contains_value WORK_TYPES = { 'bakalarska praca': 'bachelor_thesis', 'diplomova praca': 'diploma_thesis', 'dizertacna praca': 'doctoral_thesis', 'bakalarsky projekt': 'bachelor_project', 'diplomovy projekt': 'diploma_project', 'timovy projekt': 'team_project', 'vyskumny projekt': 'research_project', 'vedecky projekt': 'research_project', } WORK_LABELS = { 'bachelor_thesis': 'bakalárska práca', 'diploma_thesis': 'diplomová práca', 'doctoral_thesis': 'dizertačná práca', 'bachelor_project': 'bakalársky projekt', 'diploma_project': 'diplomový projekt', 'team_project': 'tímový projekt', 'research_project': 'výskumný projekt', } TITLE_LABELS = {'nazov', 'nazov prace', 'nazov bakalarskej prace', 'nazov diplomovej prace', 'predbezny nazov', 'tema', 'navrh na temu', 'navrh na nazov', 'navrh na nazov bp', 'navrh na nazov dp', 'pisomna praca'} PROFILE = re.compile(r'^pages/(?:students/\d{4}/[^/]+|interns/[^/]+)/README\.md$') TYPE_PATTERN = re.compile(r'\b(bakalarsk\w*|diplomov\w*|dizertacn\w*|timov\w*|vyskumn\w*|vedeck\w*)\s+(prac\w*|projekt\w*)\b') def work_type(text: str) -> str | None: value = normalized(text) match = TYPE_PATTERN.search(value) if not match: return None kind, noun = match.groups() prefix = next((p for p in ['bakalarsk', 'diplomov', 'dizertacn', 'timov', 'vyskumn', 'vedeck'] if kind.startswith(p)), None) lookup = {('bakalarsk', 'prac'): 'bachelor_thesis', ('bakalarsk', 'projekt'): 'bachelor_project', ('diplomov', 'prac'): 'diploma_thesis', ('diplomov', 'projekt'): 'diploma_project', ('dizertacn', 'prac'): 'doctoral_thesis', ('timov', 'projekt'): 'team_project', ('vyskumn', 'projekt'): 'research_project', ('vedeck', 'projekt'): 'research_project'} return lookup.get((prefix, 'projekt' if noun.startswith('projekt') else 'prac')) def academic_scope(path: str) -> tuple[str, int | None] | None: for heading in reversed(path.split('>')): if normalized(heading).startswith(('priprava na ', 'priprava k ')): return None kind = work_type(heading) if kind: years = [int(year) for year in re.findall(r'(? str | None: value = re.sub(r'\[([^\]]+)\]\([^)]*\)', r'\1', value).strip(' \t*`"„“”') value = value.rstrip('.') if not 6 <= len(value) <= 300 or normalized(value) in {'resers', 'tutorial', 'projekt', 'dokument', 'pisomna praca'}: return None if re.match(r'https?://', value) or value.endswith(':'): return None return value def source_blocks(payload: dict) -> list[dict]: context = payload.get('context', '') sources = payload.get('sources', []) # Reject injected/ambiguous framing rather than attributing it to a different # student. These markers are generated by the RAG renderer, not the model. if re.findall(r'^ZAČIATOK ZDROJA (S\d+)\s*$', context, re.M) != [s['source_id'] for s in sources]: raise ValueError('Invalid RAG evidence framing') blocks = [] for source in sources: sid = re.escape(source['source_id']) matches = re.findall(rf'^ZAČIATOK ZDROJA {sid}\n([\s\S]*?)^KONIEC ZDROJA {sid}\s*$', context, re.M) if len(matches) != 1: raise ValueError('Invalid RAG source boundary') full = matches[0] _, marker, body = full.partition('OBSAH ZDROJA\n') if not marker: raise ValueError('Missing RAG source content') blocks.append({**source, 'full_text': full, 'body': body}) return blocks def requested_types(question: str, people: list[str]) -> dict[str, str]: text = normalized(question) types = [(match.start(), match.end(), work_type(match[0])) for match in TYPE_PATTERN.finditer(text)] unique = {kind for _, _, kind in types if kind} if len(unique) == 1: return {person: next(iter(unique)) for person in people} result = {} # Explicit "pri osobe X ..." clauses outrank proximity: the next person's # name can be closer to a type phrase than the name governing that clause. for clause in re.split(r'\b(?:pri osobe|pre osobu|u osoby)\b', text)[1:]: names = [person for person in people if text_contains_value(clause, person, person=True)] kinds = {work_type(match[0]) for match in TYPE_PATTERN.finditer(clause)} - {None} if len(names) == len(kinds) == 1: result[names[0]] = next(iter(kinds)) if len(result) == len(people): return result suffix = r'(?:a|u|ovi|om|e|ou|y|ovej|oveho|ym|emu)?' positions = [] for person in people: pattern = r'\b' + r'\s+'.join(re.escape(token) + suffix for token in normalized(person).split()) + r'\b' positions.extend((m.start(), m.end(), person) for m in re.finditer(pattern, text)) for start, end, kind in types: distances = [(max(start - right, left - end, 0), person) for left, right, person in positions] if not distances or not kind: continue best = min(distance for distance, _ in distances) nearest = {person for distance, person in distances if distance == best} if len(nearest) == 1: person = nearest.pop() if person not in result: result[person] = kind return result def rag_evidence_context(question: str, payload: dict) -> dict: blocks = source_blocks(payload) if payload.get('sources') else [] facts = [] people = [] for source in blocks: if not PROFILE.fullmatch(source.get('document_path', '')) or not source.get('title'): continue person = source['title'] if person not in people: people.append(person) base = {'person': person, 'source_id': source['source_id'], 'source_url': source.get('source_url'), 'document_path': source['document_path']} windows = [] scope = None heading_stack = [] lines = [] def flush(): if lines: windows.append((scope, '\n'.join(lines))) lines.clear() for line in source['body'].splitlines(): heading = re.match(r'^(?:Sekcia(?: dôkazu)?:|#{1,6}\s)(.*)', line) if line.startswith(('RELEVANTNÁ SEKCIA ', 'DÔKAZ Z ', 'ĎALŠÍ DÔKAZ ', 'ĎALŠÍ RELEVANTNÝ ', 'NAJRELEVANTNEJŠÍ ', 'ZAČIATOK RELEVANTNEJ ')): flush(); scope = None; heading_stack.clear() elif line.startswith('Dokument:'): flush(); scope = None; heading_stack.clear() elif heading: flush() if line.startswith('Sekcia'): scope = academic_scope(heading[1]) heading_stack = [(0, scope)] else: level = len(line) - len(line.lstrip('#')) while heading_stack and heading_stack[-1][0] >= level: heading_stack.pop() detected = academic_scope(heading[1]) scope = (None if normalized(heading[1]).startswith(('priprava na ', 'priprava k ')) else detected or (heading_stack[-1][1] if heading_stack else None)) heading_stack.append((level, scope)) else: lines.append(line) flush() unscoped = [] for section, text in windows: titles = [] text_lines = text.splitlines() for index, line in enumerate(text_lines): if ':' in line: label, value = line.split(':', 1) if normalized(label) in TITLE_LABELS: value_index = index if not value.strip(): value_index = next((j for j in range(index+1, len(text_lines)) if text_lines[j].strip()), index) value = text_lines[value_index] if value_index != index else '' following = next((v.strip() for v in text_lines[value_index+1:] if v.strip()), '') if following == '[OBSAH ZDROJA OREZANÝ]' or value.strip() == '[OBSAH ZDROJA OREZANÝ]': continue title = clean_title(value) if title: titles.append(title) start = re.search(r'rok zaciatku studia\s+([12]\d{3})\b', normalized(line)) if start: facts.append({**base, 'start_year': int(start[1])}) if section: kind, year = section if not titles: # Wiki also puts an unlabelled title immediately after its # academic heading (sometimes preceded by explicit study # start). Never promote meeting notes, tasks or list items. lead_lines = [v.strip() for v in text_lines if v.strip() and not normalized(v).startswith('rok zaciatku studia')] lead = lead_lines[0] if lead_lines else '' next_line = lead_lines[1] if len(lead_lines) > 1 else '' if (lead and re.match(r'^(?:Cie[lľe]+\s*:|Zadanie\s*:|Myšlienky\s*:|1[.)]\s)', next_line, re.I) and not re.match(r'^(?:[-*+]\s|\d+[.)]\s|#{1,6}\s)', lead) and not normalized(lead).startswith(('stretnutie','virtualne stretnutie','stav','ulohy', 'ciel','zadanie','zasobnik','rok ','zaciatok studia','projekt ','praca ','tema na '))): title = clean_title(lead) if title and len(normalized(title).split()) >= 3: titles.append(title) record = {**base, 'work_type': kind} if year is not None: record['year'] = year for title in titles or [None]: facts.append({**record, **({'title': title} if title else {})}) else: unscoped.extend(titles) # A root proposal is attached only when its exact wording also occurs in # the academic section. A taxonomy year alone never licenses this join. for title in unscoped: for section, text in windows: if section and normalized(title) in normalized(text): facts.append({**base, 'title': title, 'work_type': section[0], **({'year': section[1]} if section[1] is not None else {})}) # A root label explicitly naming BP/DP licenses only that type; # attach it only if the delivered evidence has exactly one such # academic scope. Taxonomy categories still cannot supply a year. for label, kind in [('navrh na nazov dp', 'diploma_thesis'), ('navrh na nazov bp', 'bachelor_thesis')]: explicit = any(re.search(r'\b'+re.escape(label)+r'\s+'+re.escape(normalized(title))+r'\b', normalized(text)) for section, text in windows if section is None) scopes = {s for s, _ in windows if s and s[0] == kind} if explicit and len(scopes) == 1: only = next(iter(scopes)) facts.append({**base, 'title': title, 'work_type': kind, **({'year': only[1]} if only[1] is not None else {})}) for field, values in [('keyword', source.get('tags', [])), ('category', source.get('categories', [])), ('document_author', [source['author']] if source.get('author') else [])]: facts.extend({**base, field: value} for value in values) facts = list({tuple(sorted(row.items())): row for row in facts}.values()) requested = [person for person in people if text_contains_value(question, person, person=True)] expected_types = requested_types(question, requested) query = normalized(question) academic = bool(TYPE_PATTERN.search(query) or re.search(r'\b(?:rok\w* prac\w*|nazov\w* prac\w*)\b', query)) start_year = bool(re.search(r'\b(?:zaciat\w* stud\w*|zacal\w* stud\w*)\b', query)) requirements = {'people': requested, 'work_types': expected_types, 'academic': academic, 'title': 'nazov' in query or 'tema' in query, 'year': academic and ('rok' in query or 'porovnaj' in query), 'start_year': start_year, 'response_mode': 'claims' if academic or start_year else 'quotes'} stated_years = re.findall(r'\b(?:v roku|z roku|pre rok|na rok|roku|rok)\s+(\d+)\b', query) if academic else [] requirements['years'] = ([int(value) for value in stated_years if len(value) == 4] if not requirements['start_year'] else []) requirements['unsupported_year'] = any(len(value) != 4 for value in stated_years) if requested: facts = [row for row in facts if row['person'] in requested] return {'question': question, **{key: payload.get(key) for key in ['status', 'reason', 'complete', 'truncated', 'truncation_reasons', 'index_version']}, 'sources': payload.get('sources', []), 'facts': facts, 'source_blocks': blocks, 'requirements': requirements, 'aggregation': None, 'answer_candidates': []} def rag_prose_issues(question: str, answer: str, payload: dict) -> list[str]: """Conservative guard for the legacy free-prose client; typed delivery is stricter.""" context = rag_evidence_context(question, payload) if not context['requirements']['academic']: return [] text = answer from .context_policy import text_urls for url in sorted(text_urls(text), key=len, reverse=True): text = text.replace(url, '') normal = normalized(text) if re.search(r'nepodarilo.*najst|nenasli|nie je uveden|nie su uveden', normal): return [] suffix = r'(?:a|u|ovi|om|e|ou|y|ovej|oveho|ym|emu)?' positions = [] for person in context['requirements']['people']: pattern = r'\b' + r'\s+'.join(re.escape(token) + suffix for token in normalized(person).split()) + r'\b' positions.extend((m.start(), m.end(), person) for m in re.finditer(pattern, normal)) positions.sort() issues = [] for index, (start, end, person) in enumerate(positions): clause = normal[end:positions[index + 1][0] if index + 1 < len(positions) else len(normal)] rows = [row for row in context['facts'] if row['person'] == person] requested_type = context['requirements']['work_types'].get(person) academic = [row for row in rows if row.get('work_type') and (not requested_type or row['work_type'] == requested_type)] named_titles = [row for row in academic if row.get('title') and normalized(row['title']) in clause] relevant = named_titles or academic years = {str(row['year']) for row in relevant if row.get('year') is not None} if context['requirements']['start_year']: years |= {str(row['start_year']) for row in rows if row.get('start_year') is not None} if set(re.findall(r'\b[12]\d{3}\b', clause)) - years: issues.append('unsupported_person_work_year_binding') declared_type = work_type(clause) if requested_type and declared_type and declared_type != requested_type: issues.append('wrong_requested_work_type') return list(dict.fromkeys(issues))