from __future__ import annotations import re import unicodedata from collections import defaultdict from dataclasses import dataclass from typing import Any GRAPH_SCOPE = "zpwiki" THESIS_TYPES = { "bachelor_thesis", "diploma_thesis", "doctoral_thesis", } WORK_TYPE_LABELS = { "bachelor_project": "Bakalársky projekt", "bachelor_thesis": "Bakalárska práca", "diploma_thesis": "Diplomová práca", "diploma_project": "Diplomový projekt", "team_project": "Tímový projekt", "research_project": "Výskumný projekt", "doctoral_thesis": "Dizertačná práca", } WORK_HEADING_PATTERNS = ( ("bachelor_project", "bakalarsky projekt", None), ("bachelor_thesis", "bakalarska praca", "bp"), ("diploma_thesis", "diplomova praca", "dp"), ("diploma_project", "diplomovy projekt", None), ("team_project", "timovy projekt", "tp"), ("research_project", "vyskumny projekt", "vp"), ("research_project", "vedecky projekt", "vp"), ("doctoral_thesis", "dizertacna praca", "phd"), ) TITLE_LABELS = { "pisomna praca", "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", "navrh na nazov bakalarskej prace", "navrh na nazov bakalarkej prace", "navrh na nazov diplomovej prace", } WEAK_TITLE_LABELS = { "navrh na zadanie", "navrh na zadanie bp", "navrh na zadanie dp", "navrh na zadanie prace", "navrh na zadanie bakalarskej prace", "navrh na zadanie diplomovej prace", } FALLBACK_TITLE_LABELS = { "bachelor_thesis": { "navrh na nazov bp", "navrh na nazov bakalarskej prace", "navrh na nazov bakalarkej prace", "navrh na temu", }, "diploma_thesis": { "navrh na nazov dp", "navrh na nazov diplomovej prace", "navrh na temu", }, } TITLE_BLOCK_BOUNDARY_PREFIXES = ( "zadanie", "navrh na zadanie", "navrh na zadamie", "ciel", "ciele", "ulohy", "uloha", "zasobnik", "stretnutie", "sttetnutie", "virtualne stretnutie", "stav", "myslienky", "napady", "namety", "stare zadanie", "analyza ulohy", ) NON_TITLE_PREFIXES = ( "pozriet si", "prejst si", "konzultacie", "projekt odlozeny", "chceme vytvorit", "dokument", "sekcia", "rok zaciatku studia", "meno veduceho", "veduci", "konzultant", "spolupraca", "repozitar", "git repozitar", "crzp", "literatura", "suvisiace materialy", "praca suvisi", "do buducnosti", "do dalsieho", "do buduceho", "poznamky", "kriteria", "projektove stranky", "pisomna praca", "vystupy", "hotove", "ukoncil predcasne", "praca uspesne obhajena", "projekt zakonceny", "dp sa preklada", "na maise", "je potrebne", "todo", "to-do", "v roku", ) @dataclass(frozen=True) class WorkHeading: work_type: str heading: str category_prefix: str | None def normalize_text(value: str) -> str: decomposed = unicodedata.normalize( "NFKD", str(value), ) without_marks = "".join( char for char in decomposed if not unicodedata.combining(char) ) return re.sub( r"\s+", " ", without_marks.lower(), ).strip() def normalize_key(value: str) -> str: normalized = normalize_text(value) normalized = re.sub( r"[^a-z0-9]+", "-", normalized, ) return normalized.strip("-") def source_url_from_path(path: str) -> str: normalized = path.replace("\\", "/") if normalized.startswith("pages/"): normalized = normalized[len("pages/"):] if normalized.endswith("/README.md"): normalized = normalized[:-len("/README.md")] elif normalized.endswith(".md"): normalized = normalized[:-3] return ( "https://zp.kemt.fei.tuke.sk/" + normalized.strip("/") ) def document_kind(path: str) -> str: normalized = path.replace("\\", "/") if normalized.startswith("pages/students/"): return "student" if normalized.startswith("pages/interns/"): return "intern" if normalized.startswith("pages/topics/"): return "topic" return "other" def person_profile_path( path: str, ) -> str | None: normalized = path.replace("\\", "/") parts = normalized.split("/") if ( len(parts) >= 4 and parts[0] == "pages" and parts[1] == "students" and re.fullmatch( r"20\d{2}", parts[2], ) and parts[3] ): return ( "pages/students/" + parts[2] + "/" + parts[3] + "/README.md" ) if ( len(parts) >= 3 and parts[0] == "pages" and parts[1] == "interns" and parts[2] ): return ( "pages/interns/" + parts[2] + "/README.md" ) return None def person_kind_from_profile_path( path: str, ) -> str | None: normalized = path.replace("\\", "/") profile_path = person_profile_path( normalized ) if profile_path != normalized: return None if normalized.startswith( "pages/students/" ): return "student" if normalized.startswith( "pages/interns/" ): return "intern" return None def extract_full_years(value: str) -> list[int]: years = [ int(match) for match in re.findall( r"\b(20\d{2})\b", value, ) ] for match in re.finditer( r"\b(20\d{2})\s*/\s*(\d{2})\b", value, ): first = int(match.group(1)) second_short = int(match.group(2)) second = ( (first // 100) * 100 + second_short ) if second not in years: years.append(second) return sorted(set(years)) def parse_category( category: str, ) -> tuple[str, int | None]: match = re.fullmatch( r"(bp|dp|tp|vp|phd)(20\d{2})", category.lower(), ) if not match: return "other", None prefix = match.group(1) year = int(match.group(2)) category_types = { "bp": "bachelor_thesis", "dp": "diploma_thesis", "tp": "team_project", "vp": "research_project", "phd": "doctoral_thesis", } return category_types[prefix], year def find_work_heading( heading: str, ) -> WorkHeading | None: normalized = normalize_text(heading) for ( work_type, phrase, category_prefix, ) in WORK_HEADING_PATTERNS: if phrase in normalized: return WorkHeading( work_type=work_type, heading=heading, category_prefix=category_prefix, ) return None def category_years( categories: list[str], prefix: str | None, ) -> list[int]: if prefix is None: return [] pattern = re.compile( rf"^{re.escape(prefix)}(20\d{{2}})$", flags=re.IGNORECASE, ) result = [] for category in categories: match = pattern.fullmatch(category) if match: result.append( int(match.group(1)) ) return sorted(set(result)) def resolve_work_year( heading: WorkHeading, categories: list[str], ) -> int | None: heading_years = extract_full_years( heading.heading ) category_candidates = category_years( categories, heading.category_prefix, ) if heading.work_type == "diploma_project": if heading_years: return heading_years[-1] return None common = sorted( set(heading_years) & set(category_candidates) ) if common: return common[-1] if len(heading_years) > 1: return heading_years[-1] if heading_years: return heading_years[-1] if category_candidates: return category_candidates[-1] return None def clean_markdown_line( line: str, ) -> str: value = line.strip() value = value.replace( "*", "", ).replace( "`", "", ) return value.strip() def strip_list_prefix( line: str, ) -> str: value = clean_markdown_line( line ) value = re.sub( r"^[-+•]\s+(?:\[[ xX-]\]\s*)?", "", value, ) return value.strip() def is_numbered_or_bulleted_line( line: str, ) -> bool: value = clean_markdown_line( line ) if re.match( r"^\d+[\.\)]\s*", value, ): return True return bool( re.match( r"^[-+•]\s+", value, ) ) def is_title_candidate( line: str, ) -> bool: value = strip_list_prefix( line ) if not value: return False normalized = normalize_text( value ) if normalized.startswith( NON_TITLE_PREFIXES ): return False if value.startswith( ( "http://", "https://", "[", "./", "../", ) ): return False if ( "http://" in value or "https://" in value ): return False if re.match( r"^\d+[\.\)]\s*", value, ): return False if value.endswith( ":" ): return False if len(value) < 6: return False if len(value) > 180: return False return True def immediate_title_after_label( lines: list[str], start_index: int, ) -> str | None: for raw_line in lines[start_index:]: if not raw_line.strip(): continue if is_numbered_or_bulleted_line( raw_line ): return None candidate = strip_list_prefix( raw_line ) if not is_title_candidate( candidate ): return None return candidate.rstrip( " ," ) return None def extract_labeled_title( texts: list[str], labels: set[str], ) -> str | None: for text in texts: lines = text.splitlines() for index, raw_line in enumerate( lines ): line = strip_list_prefix( raw_line ) if not line: continue before, separator, after = ( line.partition( ":" ) ) if not separator: continue normalized_before = ( normalize_text( before ) ) if normalized_before not in labels: continue title = re.sub(r'\[([^\]]+)\]\([^)]*\)', r'\1', after).strip() if title: if is_title_candidate( title ): return title.rstrip( " ," ) continue candidate = ( immediate_title_after_label( lines, index + 1, ) ) if candidate: return candidate return None def extract_leading_title( texts: list[str], ) -> str | None: for text in texts: candidates: list[str] = [] saw_content = False for raw_line in text.splitlines(): if not raw_line.strip(): continue line = clean_markdown_line( raw_line ) stripped = strip_list_prefix( raw_line ) normalized = normalize_text( stripped ) if normalized.startswith( ( "dokument:", "sekcia:", "rok zaciatku studia", ) ): continue if ( stripped.startswith( "[" ) and "](" in stripped ): continue if normalized.startswith( ( "spolupraca", "suvisiaca praca", "suvisiaca dizertacna praca", ) ): continue saw_content = True if is_numbered_or_bulleted_line( raw_line ): if candidates: return candidates[-1] return None if normalized.startswith( TITLE_BLOCK_BOUNDARY_PREFIXES ): if candidates: return candidates[-1] return None if not is_title_candidate( line ): continue candidates.append( stripped.rstrip( " ," ) ) if candidates: return candidates[-1] if saw_content: return None return None def extract_work_title( texts: list[str], ) -> str | None: title = extract_labeled_title( texts, TITLE_LABELS - {"tema", "navrh na temu", "pisomna praca"}, ) if title: return title title = extract_labeled_title(texts, {"tema", "navrh na temu", "pisomna praca"}) if title: return title title = extract_labeled_title( texts, WEAK_TITLE_LABELS, ) if title: return title return extract_leading_title( texts ) def extract_explicit_proposal_title( work_type: str, chunks: list[dict[str, Any]], ) -> tuple[str | None, list[str]]: labels = FALLBACK_TITLE_LABELS.get( work_type ) if not labels: return None, [] candidates: dict[ str, dict[str, Any], ] = {} for chunk in sorted( chunks, key=lambda item: int( item.get( "chunk_index", 0, ) ), ): text = str( chunk.get( "text", "", ) ) if not text: continue lines = text.splitlines() for index, raw_line in enumerate( lines ): line = strip_list_prefix( raw_line ) if not line: continue before, separator, after = ( line.partition( ":" ) ) if not separator: continue normalized_before = ( normalize_text( before ) ) if normalized_before not in labels: continue title = after.strip() if title: if not is_title_candidate( title ): continue title = title.rstrip( " ," ) else: title = ( immediate_title_after_label( lines, index + 1, ) ) if not title: continue normalized_title = ( normalize_text( title ) ) candidate = candidates.setdefault( normalized_title, { "title": title, "chunk_ids": set(), }, ) chunk_id = chunk.get( "chunk_id" ) if chunk_id: candidate[ "chunk_ids" ].add( str(chunk_id) ) if len(candidates) != 1: return None, [] candidate = next( iter(candidates.values()) ) return ( str(candidate["title"]), sorted( candidate["chunk_ids"] ), ) ROOT_PROFILE_TITLE_LABELS = { "nazov", "nazov prace", "nazov bakalarskej prace", "nazov diplomovej prace", "predbezny nazov", } def extract_profile_root_title( chunks: list[dict[str, Any]], ) -> tuple[str | None, list[str]]: candidates: dict[ str, dict[str, Any], ] = {} for chunk in sorted( chunks, key=lambda item: int( item.get( "chunk_index", 0, ) ), ): text = str( chunk.get( "text", "", ) ) if not text: continue has_section_marker = any( normalize_text( strip_list_prefix( line ) ).startswith( "sekcia:" ) for line in text.splitlines() ) if has_section_marker: continue title = extract_labeled_title( [text], ROOT_PROFILE_TITLE_LABELS, ) if not title: continue normalized_title = normalize_text( title ) candidate = candidates.setdefault( normalized_title, { "title": title, "chunk_ids": set(), }, ) chunk_id = chunk.get( "chunk_id" ) if chunk_id: candidate[ "chunk_ids" ].add( str(chunk_id) ) if len(candidates) != 1: return None, [] candidate = next( iter( candidates.values() ) ) return ( str( candidate["title"] ), sorted( candidate[ "chunk_ids" ] ), ) def latest_thesis_category( categories: list[str], ) -> tuple[str, int] | None: candidates: list[ tuple[str, int] ] = [] for category in categories: work_type, year = ( parse_category( category ) ) if ( work_type not in THESIS_TYPES or year is None ): continue candidates.append( ( work_type, year, ) ) if not candidates: return None latest_year = max( year for _, year in candidates ) latest = sorted( { ( work_type, year, ) for work_type, year in candidates if year == latest_year } ) if len(latest) != 1: return None return latest[0] def extract_start_year( path: str, chunks: list[dict[str, Any]], ) -> int | None: pattern = re.compile( r"rok\s+zaciatku\s+studia" r"\s*\*?\s*:\s*\*?\s*" r"(20\d{2})" ) for chunk in chunks: normalized = normalize_text( str(chunk.get("text", "")) ) match = pattern.search( normalized ) if match: return int( match.group(1) ) match = re.search( r"pages/students/(20\d{2})/", path.replace("\\", "/"), ) if match: return int( match.group(1) ) return None def topic_key_from_document_path( path: str, ) -> str | None: normalized = path.replace("\\", "/") if not normalized.startswith( "pages/topics/" ): return None value = normalized[ len("pages/topics/"): ] if value.endswith("/README.md"): value = value[:-len("/README.md")] elif value.endswith(".md"): value = value[:-3] if not value: return None return normalize_key( value.split("/")[0] ) def is_topic_root_document( path: str, ) -> bool: normalized = path.replace("\\", "/") return bool( re.fullmatch( r"pages/topics/[^/]+/README\.md", normalized, ) ) def keyword_display_name( value: str, ) -> str: aliases = { "nlp": "NLP", "rag": "RAG", "ner": "NER", "qa": "QA", "ie": "IE", "lm": "LM", "llm": "LLM", "nmt": "NMT", "pos": "POS", "gpu": "GPU", "spacy": "Spacy", "db": "DB", } key = normalize_key(value) if key in aliases: return aliases[key] return str(value).strip() def extract_works( document: dict[str, Any], chunks: list[dict[str, Any]], ) -> list[dict[str, Any]]: grouped: dict[ tuple[str, str], dict[str, Any], ] = {} categories = list( document.get( "categories", [], ) ) for chunk in sorted( chunks, key=lambda item: int( item.get( "chunk_index", 0, ) ), ): heading_paths = chunk.get( "heading_paths", [], ) chunk_headings: dict[ tuple[str, str], WorkHeading, ] = {} for path in heading_paths: values = ( path if isinstance( path, list, ) else [path] ) heading = None for raw_heading in reversed( values ): if not raw_heading: continue heading = find_work_heading( str(raw_heading) ) if heading is not None: break if heading is None: continue key = ( heading.work_type, normalize_text( heading.heading ), ) chunk_headings[key] = heading for key, heading in ( chunk_headings.items() ): entry = grouped.setdefault( key, { "heading": heading, "texts": [], "chunk_ids": [], }, ) text = str( chunk.get( "text", "", ) ) if text: entry["texts"].append( text ) chunk_id = chunk.get( "chunk_id" ) if chunk_id: entry["chunk_ids"].append( str(chunk_id) ) work_type_counts: dict[ str, int, ] = defaultdict(int) for entry in grouped.values(): work_type_counts[ entry["heading"].work_type ] += 1 result = [] for entry in grouped.values(): heading = entry["heading"] year = resolve_work_year( heading, categories, ) title = extract_work_title( entry["texts"] ) source_chunk_ids = set( entry["chunk_ids"] ) if ( title is None and work_type_counts[ heading.work_type ] == 1 ): ( fallback_title, fallback_chunk_ids, ) = extract_explicit_proposal_title( heading.work_type, chunks, ) if fallback_title: title = fallback_title source_chunk_ids.update( fallback_chunk_ids ) result.append( { "work_type": heading.work_type, "work_type_label": ( WORK_TYPE_LABELS[ heading.work_type ] ), "heading": heading.heading, "year": year, "title": title, "source_chunk_ids": sorted( source_chunk_ids ), } ) ( root_title, root_title_chunk_ids, ) = extract_profile_root_title( chunks ) thesis_category = ( latest_thesis_category( categories ) ) if ( root_title and thesis_category ): ( thesis_type, thesis_year, ) = thesis_category matching_theses = [ item for item in result if ( item[ "work_type" ] == thesis_type and item[ "year" ] == thesis_year ) ] if len( matching_theses ) == 1: existing = ( matching_theses[0] ) if existing[ "title" ] is None: existing[ "title" ] = root_title existing[ "source_chunk_ids" ] = sorted( set( existing[ "source_chunk_ids" ] ) | set( root_title_chunk_ids ) ) elif not matching_theses: result.append( { "work_type": ( thesis_type ), "work_type_label": ( WORK_TYPE_LABELS[ thesis_type ] ), "heading": ( WORK_TYPE_LABELS[ thesis_type ] + " " + str( thesis_year ) ), "year": thesis_year, "title": root_title, "source_chunk_ids": ( root_title_chunk_ids ), } ) return sorted( result, key=lambda item: ( item["year"] or 0, item["work_type"], item["heading"], ), ) def build_graph_payload( documents: list[dict[str, Any]], chunks: list[dict[str, Any]], ) -> dict[str, list[dict[str, Any]]]: chunks_by_document: dict[ str, list[dict[str, Any]], ] = defaultdict(list) for chunk in chunks: path = str( chunk.get( "document_path", "", ) ) if path: chunks_by_document[ path ].append(chunk) documents_by_path = { str(document["path"]): document for document in documents } people: dict[ str, dict[str, Any], ] = {} document_authors: dict[ str, dict[str, Any], ] = {} document_nodes: dict[ str, dict[str, Any], ] = {} categories: dict[ str, dict[str, Any], ] = {} topics: dict[ str, dict[str, Any], ] = {} keywords: dict[ str, dict[str, Any], ] = {} theses: dict[ str, dict[str, Any], ] = {} works: dict[ str, dict[str, Any], ] = {} person_documents = set() document_author_documents = set() document_categories = set() document_keywords = set() document_describes_topics = set() person_theses = set() person_works = set() thesis_documents = set() work_documents = set() for document in documents: path = str( document["path"] ) title = str( document.get( "title", path, ) ) kind = document_kind( path ) doc_categories = [ str(value) for value in document.get( "categories", [], ) ] doc_tags = [ str(value) for value in document.get( "tags", [], ) ] author = document.get( "author" ) document_nodes[path] = { "path": path, "name": title, "title": title, "source_url": ( source_url_from_path( path ) ), "document_kind": kind, "published": bool( document.get( "published", False, ) ), "categories": ( doc_categories ), "tags": doc_tags, "author": ( str(author) if author else None ), "graph_scope": ( GRAPH_SCOPE ), } if author: author_name = str( author ) author_id = normalize_key( author_name ) if author_id not in document_authors: document_authors[ author_id ] = { "id": author_id, "name": author_name, "graph_scope": ( GRAPH_SCOPE ), } document_author_documents.add( ( author_id, path, ) ) for category in doc_categories: category_type, year = ( parse_category( category ) ) categories[category] = { "name": category, "category_type": ( category_type ), "year": year, "graph_scope": ( GRAPH_SCOPE ), } document_categories.add( ( path, category, ) ) for tag in doc_tags: keyword_id = normalize_key( tag ) if not keyword_id: continue if keyword_id not in keywords: keywords[keyword_id] = { "id": keyword_id, "name": ( keyword_display_name( tag ) ), "graph_scope": ( GRAPH_SCOPE ), } document_keywords.add( ( path, keyword_id, ) ) described_topic = ( topic_key_from_document_path( path ) ) if described_topic: if ( described_topic not in topics or is_topic_root_document( path ) ): topics[ described_topic ] = { "id": described_topic, "name": title, "graph_scope": ( GRAPH_SCOPE ), } document_describes_topics.add( ( path, described_topic, ) ) profile_path = person_profile_path( path ) if profile_path is None: continue profile_document = documents_by_path.get( profile_path ) if profile_document is None: continue person_kind = ( person_kind_from_profile_path( profile_path ) ) if person_kind is None: continue person_id = ( f"person:{profile_path}" ) profile_title = str( profile_document.get( "title", profile_path, ) ) profile_chunks = ( chunks_by_document.get( profile_path, [], ) ) if person_id not in people: people[person_id] = { "id": person_id, "name": profile_title, "person_kind": person_kind, "start_year": ( extract_start_year( profile_path, profile_chunks, ) if person_kind == "student" else None ), "source_document": ( profile_path ), "graph_scope": ( GRAPH_SCOPE ), } person_documents.add( ( person_id, path, ) ) if ( person_kind != "student" or path != profile_path ): continue document_chunks = ( chunks_by_document.get( profile_path, [], ) ) for work in extract_works( document, document_chunks, ): common_data = { "name": ( work["title"] or work["heading"] ), "title": work[ "title" ], "heading": work[ "heading" ], "year": work[ "year" ], "source_document": path, "source_chunk_ids": work[ "source_chunk_ids" ], "graph_scope": ( GRAPH_SCOPE ), } if work["work_type"] in THESIS_TYPES: thesis_id = ( "thesis:" + path + ":" + normalize_key( work["heading"] ) ) theses[thesis_id] = { "id": thesis_id, "thesis_type": work[ "work_type" ], "thesis_type_label": ( work[ "work_type_label" ] ), **common_data, } person_theses.add( ( person_id, thesis_id, ) ) thesis_documents.add( ( thesis_id, path, ) ) continue work_id = ( "work:" + path + ":" + normalize_key( work["heading"] ) ) works[work_id] = { "id": work_id, "work_type": work[ "work_type" ], "work_type_label": work[ "work_type_label" ], **common_data, } person_works.add( ( person_id, work_id, ) ) work_documents.add( ( work_id, path, ) ) def relation_rows( pairs: set[tuple[str, str]], source_name: str, target_name: str, ) -> list[dict[str, str]]: return [ { source_name: source, target_name: target, } for source, target in sorted( pairs ) ] return { "people": sorted( people.values(), key=lambda row: row["id"], ), "document_authors": sorted( document_authors.values(), key=lambda row: row["id"], ), "documents": sorted( document_nodes.values(), key=lambda row: row[ "path" ], ), "categories": sorted( categories.values(), key=lambda row: row[ "name" ], ), "topics": sorted( topics.values(), key=lambda row: row["id"], ), "keywords": sorted( keywords.values(), key=lambda row: row["id"], ), "theses": sorted( theses.values(), key=lambda row: row["id"], ), "works": sorted( works.values(), key=lambda row: row["id"], ), "person_documents": relation_rows( person_documents, "person_id", "document_path", ), "document_author_documents": relation_rows( document_author_documents, "document_author_id", "document_path", ), "document_categories": relation_rows( document_categories, "document_path", "category_name", ), "document_keywords": relation_rows( document_keywords, "document_path", "keyword_id", ), "document_describes_topics": ( relation_rows( document_describes_topics, "document_path", "topic_id", ) ), "person_theses": relation_rows( person_theses, "person_id", "thesis_id", ), "person_works": relation_rows( person_works, "person_id", "work_id", ), "thesis_documents": relation_rows( thesis_documents, "thesis_id", "document_path", ), "work_documents": relation_rows( work_documents, "work_id", "document_path", ), }