second commit
This commit is contained in:
150
aigenerated.py
Normal file
150
aigenerated.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
# --- Ресурсы для алгоритма ---
|
||||||
|
STOP_WORDS = {'пр.', 'лек.', 'лаб.', 'семинар'}
|
||||||
|
TITLES = ('доц.', 'проф.', 'асс.', 'ст.пр.')
|
||||||
|
SURNAME_ENDINGS = ('ов', 'ев', 'ин', 'ский', 'цкой', 'их', 'ых', 'ова', 'ева', 'ина', 'ская', 'ян', 'ко', "ня", "ин")
|
||||||
|
|
||||||
|
def is_surname_string(s: str) -> bool:
|
||||||
|
"""
|
||||||
|
Классифицирует строку, определяя, содержит ли она фамилию.
|
||||||
|
"""
|
||||||
|
if not isinstance(s, str) or not s:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Шаг 1: Очистка
|
||||||
|
s_clean = s.strip()
|
||||||
|
|
||||||
|
# Шаг 2: Жесткие правила "НЕ ФАМИЛИЯ"
|
||||||
|
if s_clean.lower() in STOP_WORDS:
|
||||||
|
return False
|
||||||
|
if s_clean.isupper() and len(s_clean) > 3:
|
||||||
|
return False
|
||||||
|
if re.search(r'[\(\)/«»%]', s_clean):
|
||||||
|
return False
|
||||||
|
|
||||||
|
words = s_clean.split()
|
||||||
|
if len(words) > 3:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Шаг 3: Жесткие правила "ТОЧНО ФАМИЛИЯ"
|
||||||
|
if re.search(r'\b[А-Я]\.?', s_clean): # Ищет "И.А." или "И.А"
|
||||||
|
return True
|
||||||
|
if s_clean.lower().startswith(TITLES):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Шаг 4: Эвристический анализ для оставшихся случаев
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
# Правило на капитализацию
|
||||||
|
if words and words[0][0].isupper():
|
||||||
|
score += 5
|
||||||
|
else:
|
||||||
|
# Если слово не с большой буквы, это почти точно не фамилия
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Правило на окончания
|
||||||
|
last_word = words[-1].lower()
|
||||||
|
if last_word.endswith(SURNAME_ENDINGS):
|
||||||
|
score += 6
|
||||||
|
|
||||||
|
# Правило на количество слов
|
||||||
|
if len(words) in [1, 2]:
|
||||||
|
score += 2
|
||||||
|
|
||||||
|
# Пороговое значение
|
||||||
|
THRESHOLD = 8
|
||||||
|
return score >= THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def extract_last_name(name_str: str) -> str or None:
|
||||||
|
"""
|
||||||
|
Извлекает из строки только фамилию.
|
||||||
|
|
||||||
|
Справляется с приклеенными званиями (типа "ст.пр.Дмитриев") и отбрасывает инициалы.
|
||||||
|
Ищет первое слово, написанное с заглавной буквы.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name_str: Исходная "грязная" строка с именем.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Чистая фамилия в виде строки, или None, если фамилия не найдена.
|
||||||
|
"""
|
||||||
|
# Проверка, что на вход подана строка
|
||||||
|
if not isinstance(name_str, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Паттерн для поиска:
|
||||||
|
# [А-ЯЁ] - одна заглавная русская буква в начале
|
||||||
|
# [а-яё]+ - одна или более строчных русских букв после неё
|
||||||
|
# (?:-[А-ЯЁ][а-яё]+)? - опциональная часть для двойных фамилий (например, -Петров)
|
||||||
|
pattern = r'[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?'
|
||||||
|
|
||||||
|
match = re.search(pattern, name_str)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
return match.group(0) # Возвращаем найденное совпадение
|
||||||
|
else:
|
||||||
|
return None # Если ничего не найдено
|
||||||
|
|
||||||
|
|
||||||
|
# --- Шаг 0: Константы ---
|
||||||
|
POSITIVE_KEYWORDS = ['зал', 'ауд', 'каб', 'корп', 'кор.']
|
||||||
|
NEGATIVE_KEYWORDS = ['доц', 'проф', 'асс', 'лек', 'пр']
|
||||||
|
|
||||||
|
# Транслитерация для унификации
|
||||||
|
TRANS_TABLE = str.maketrans('TCBAHIMK', 'ТКВАНІМК') # Латиница -> Кириллица
|
||||||
|
|
||||||
|
def is_room_number(s: str) -> bool:
|
||||||
|
"""
|
||||||
|
Проверяет, является ли строка номером кабинета, по многоуровневому алгоритму.
|
||||||
|
"""
|
||||||
|
# --- Шаг 1: Быстрые негативные фильтры ---
|
||||||
|
if not isinstance(s, str) or not s.strip():
|
||||||
|
return False # 1. Проверка на пустоту
|
||||||
|
|
||||||
|
if ',' in s:
|
||||||
|
return False # 2. Проверка на запятые (даты)
|
||||||
|
|
||||||
|
# 3. Проверка на "очевидный мусор"
|
||||||
|
first_word = s.strip().lower().split()[0]
|
||||||
|
if first_word in NEGATIVE_KEYWORDS:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 4. Проверка на инициалы (напр. А.Е.)
|
||||||
|
if re.search(r'\b[А-Я]\.[А-Я]\.?\b', s):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 5. Проверка на длинное слово без цифр
|
||||||
|
if not any(char.isdigit() for char in s) and len(s.split()) == 1 and len(s) > 10:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# --- Шаг 2: Быстрые позитивные фильтры ---
|
||||||
|
s_lower = s.lower()
|
||||||
|
if any(keyword in s_lower for keyword in POSITIVE_KEYWORDS):
|
||||||
|
return True # 1. Проверка по ключевым словам
|
||||||
|
|
||||||
|
# --- Шаг 3: Основной анализ ---
|
||||||
|
if not any(char.isdigit() for char in s):
|
||||||
|
return False # 1. Требуется наличие цифр
|
||||||
|
|
||||||
|
# 2. Создание "чистой" версии
|
||||||
|
clean_s = s.upper().translate(TRANS_TABLE).replace(' ', '')
|
||||||
|
|
||||||
|
# 3. Комплексный паттерн для проверки
|
||||||
|
# Пояснение:
|
||||||
|
# ^...$ - шаблон должен соответствовать всей строке
|
||||||
|
# [А-ЯЁ]?-? - необязательная буква и необязательный дефис в начале
|
||||||
|
# \d+ - одна или более цифр (ядро номера)
|
||||||
|
# (?:[.-]\d+)* - необязательные группы ".число" или "-число"
|
||||||
|
# [А-ЯЁ]?$ - необязательная буква в конце
|
||||||
|
room_pattern = re.compile(r'^[А-ЯЁ]?-?\d+(?:[.-]\d+)*[А-ЯЁ]?$')
|
||||||
|
|
||||||
|
if room_pattern.fullmatch(clean_s):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# --- Шаг 4: Финальное решение ---
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
6149
groups.json
Normal file
6149
groups.json
Normal file
File diff suppressed because it is too large
Load Diff
5
main.py
5
main.py
@@ -4,7 +4,7 @@ import xlrd
|
|||||||
|
|
||||||
import parser
|
import parser
|
||||||
import utils
|
import utils
|
||||||
|
import json
|
||||||
# Общее правило проекта, сначала в координатах идёт ROW а потом COL, нумерация с нуля
|
# Общее правило проекта, сначала в координатах идёт ROW а потом COL, нумерация с нуля
|
||||||
|
|
||||||
|
|
||||||
@@ -16,3 +16,6 @@ sh = book.sheet_by_index(0)
|
|||||||
|
|
||||||
prs = parser.Parser(sh)
|
prs = parser.Parser(sh)
|
||||||
prs.parse()
|
prs.parse()
|
||||||
|
|
||||||
|
json.dump(prs.groups, open('groups.json', 'w'), indent=2, ensure_ascii=False)
|
||||||
|
print("Saved to groups.json")
|
||||||
|
|||||||
79
parser.py
79
parser.py
@@ -2,6 +2,7 @@ import json
|
|||||||
|
|
||||||
import xlrd
|
import xlrd
|
||||||
|
|
||||||
|
import aigenerated
|
||||||
from coord import Coord, Merged
|
from coord import Coord, Merged
|
||||||
import utils
|
import utils
|
||||||
|
|
||||||
@@ -11,6 +12,8 @@ class Parser:
|
|||||||
def __init__(self, sheet: "xlrd.sheet.Sheet"):
|
def __init__(self, sheet: "xlrd.sheet.Sheet"):
|
||||||
self.sh: "xlrd.sheet.Sheet" = sheet
|
self.sh: "xlrd.sheet.Sheet" = sheet
|
||||||
self.groups = {}
|
self.groups = {}
|
||||||
|
self.teachers = set()
|
||||||
|
self.places = set()
|
||||||
print("Parser created for '{0}': size: {1}x{2}".format(self.sh.name, self.sh.nrows, self.sh.ncols))
|
print("Parser created for '{0}': size: {1}x{2}".format(self.sh.name, self.sh.nrows, self.sh.ncols))
|
||||||
|
|
||||||
def parse(self):
|
def parse(self):
|
||||||
@@ -31,6 +34,8 @@ class Parser:
|
|||||||
self.process_group(group, monday)
|
self.process_group(group, monday)
|
||||||
print("\nEND OF PROCESS GROUP\n")
|
print("\nEND OF PROCESS GROUP\n")
|
||||||
|
|
||||||
|
print(self.teachers)
|
||||||
|
|
||||||
def parse_potokoviy(self, merged: Merged):
|
def parse_potokoviy(self, merged: Merged):
|
||||||
speaker = None
|
speaker = None
|
||||||
location = None
|
location = None
|
||||||
@@ -88,8 +93,11 @@ class Parser:
|
|||||||
|
|
||||||
is_empty_lesson = right_cell.ctype in utils.EMPTY_CTYPES and merged_cell.ctype in utils.EMPTY_CTYPES
|
is_empty_lesson = right_cell.ctype in utils.EMPTY_CTYPES and merged_cell.ctype in utils.EMPTY_CTYPES
|
||||||
dispname = ""
|
dispname = ""
|
||||||
|
parsed_discipline_name = None
|
||||||
parsed_location = None
|
parsed_location = None
|
||||||
parsed_leader = None
|
parsed_leader = None
|
||||||
|
is_2pair = False
|
||||||
|
is_solid = pos_right in merged
|
||||||
parsed_uncotigorized = []
|
parsed_uncotigorized = []
|
||||||
is_wide_maybe_potokoviy = merged.width() > 4 # потоковая ли лекция (занимает несколько групп.)
|
is_wide_maybe_potokoviy = merged.width() > 4 # потоковая ли лекция (занимает несколько групп.)
|
||||||
if is_empty_lesson:
|
if is_empty_lesson:
|
||||||
@@ -100,20 +108,21 @@ class Parser:
|
|||||||
ret = self.parse_potokoviy(merged)
|
ret = self.parse_potokoviy(merged)
|
||||||
parsed_location = ret['loc']
|
parsed_location = ret['loc']
|
||||||
parsed_leader = ret['leader']
|
parsed_leader = ret['leader']
|
||||||
dispname = ret['name']
|
parsed_discipline_name = ret['name']
|
||||||
else:
|
else:
|
||||||
is_solid = pos_right in merged
|
|
||||||
is_2pair = False
|
|
||||||
may_prepod = merged.low.shift(down=2)
|
may_prepod = merged.low.shift(down=2)
|
||||||
if utils.border_bottom(self.sh, may_prepod) == 0 and utils.border_top(self.sh, may_prepod.shift(down=1)) == 0:
|
if utils.border_bottom(self.sh, may_prepod) == 0 and utils.border_top(self.sh, may_prepod.shift(down=1)) == 0:
|
||||||
next = 6
|
next = 6
|
||||||
is_2pair = True
|
is_2pair = True
|
||||||
|
|
||||||
|
if (is_solid):
|
||||||
|
parsed_discipline_name = cv
|
||||||
|
|
||||||
dispname = cv
|
dispname = cv
|
||||||
dispname += (" SOLD" if is_solid else " SPLIT")
|
dispname += (" SOLD" if is_solid else " SPLIT")
|
||||||
dispname += (" [ДВУПАРНЫЙ]" if is_2pair else "")
|
dispname += (" [ДВУПАРНЫЙ]" if is_2pair else "")
|
||||||
|
|
||||||
parsed_uncotigorized = utils.parse_all_dirt(self.sh, merged.low, 2, next-1)
|
parsed_uncotigorized = list(utils.parse_all_dirt(self.sh, merged.low, 4, next))
|
||||||
|
|
||||||
|
|
||||||
if parsed_leader: dispname += f" [{parsed_leader}]"
|
if parsed_leader: dispname += f" [{parsed_leader}]"
|
||||||
@@ -121,10 +130,57 @@ class Parser:
|
|||||||
dispname = dispname.replace("\n", "\\n")
|
dispname = dispname.replace("\n", "\\n")
|
||||||
print(f"[{group_name}] row={row}; {pos} {pos_right} {pair} {weekday}: {'[ПОТОКОВЫЙ] ' if is_wide_maybe_potokoviy else ''}{dispname} {parsed_uncotigorized}")
|
print(f"[{group_name}] row={row}; {pos} {pos_right} {pair} {weekday}: {'[ПОТОКОВЫЙ] ' if is_wide_maybe_potokoviy else ''}{dispname} {parsed_uncotigorized}")
|
||||||
|
|
||||||
|
# пытаемся из некотегорезированных данных выцепить место и лидера (препода)
|
||||||
|
prepods = set()
|
||||||
|
if parsed_leader is not None: prepods.add(aigenerated.extract_last_name(parsed_leader))
|
||||||
|
|
||||||
|
locations = set()
|
||||||
|
if parsed_location is not None: locations.add(parsed_location.replace(" ", "").replace("-", ""))
|
||||||
|
|
||||||
|
for x in list(parsed_uncotigorized):
|
||||||
|
if aigenerated.is_surname_string(x):
|
||||||
|
prepods.add(aigenerated.extract_last_name(x))
|
||||||
|
|
||||||
|
if aigenerated.is_room_number(x):
|
||||||
|
locations.add(x.replace(" ", "").replace("-", "") if x is not None else None)
|
||||||
|
|
||||||
|
# оставшееся в дисциплину (костыль)
|
||||||
|
if parsed_discipline_name is None:
|
||||||
|
parsed_discipline_name = " ".join(parsed_uncotigorized)
|
||||||
|
|
||||||
|
prepods.discard(None)
|
||||||
|
prepods.discard("")
|
||||||
|
locations.discard(None)
|
||||||
|
locations.discard("")
|
||||||
|
if not is_empty_lesson:
|
||||||
|
slots = group['slots']
|
||||||
|
w = weekday + ("_1" if weeknum == 1 else "_2")
|
||||||
|
if w not in slots.keys():
|
||||||
|
slots[w] = {}
|
||||||
|
|
||||||
|
today = slots[w]
|
||||||
|
today[pair] = {
|
||||||
|
"pos": str(pos),
|
||||||
|
"discipline": parsed_discipline_name,
|
||||||
|
"locations": list(locations),
|
||||||
|
"leads": list(prepods),
|
||||||
|
"is_solid": is_solid,
|
||||||
|
"is_2pair": is_2pair,
|
||||||
|
"is_flow": is_wide_maybe_potokoviy,
|
||||||
|
"raw": parsed_uncotigorized,
|
||||||
|
"weeday": utils.weekday_to_num(weekday),
|
||||||
|
"weeknum": weeknum
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
self.teachers.add(aigenerated.extract_last_name(parsed_leader))
|
||||||
|
|
||||||
|
|
||||||
# INCREMENT на next и конец цикла.
|
# INCREMENT на next и конец цикла.
|
||||||
row += next
|
row += next
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def parse_groups(sh, head, monday, head_rx):
|
def parse_groups(sh, head, monday, head_rx):
|
||||||
"""Распознать список групп и метаданные к ним, по сути получить список названий группы и координат её верхнего header-а (AQ6:AT6)"""
|
"""Распознать список групп и метаданные к ним, по сути получить список названий группы и координат её верхнего header-а (AQ6:AT6)"""
|
||||||
groups = {}
|
groups = {}
|
||||||
@@ -132,24 +188,27 @@ def parse_groups(sh, head, monday, head_rx):
|
|||||||
while i < len(head):
|
while i < len(head):
|
||||||
x = head[i]
|
x = head[i]
|
||||||
print(f"while i={i} head[i]={x}")
|
print(f"while i={i} head[i]={x}")
|
||||||
merged = utils.get_merged(sh, head_rx, i)
|
merged = utils.get_merged_coord(sh, Coord(head_rx, i))
|
||||||
if i > monday[1] + 1:
|
if i > monday[1] + 1:
|
||||||
if merged is None or x.value == "":
|
if merged is None or x.value == "":
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if merged.width() != 4:
|
||||||
|
print(f"WARNING: group header witdh !=4 (found: {merged.width()}); blocks !=4 not supported by parser.")
|
||||||
|
break
|
||||||
|
|
||||||
name = utils.unspace(x.value)
|
name = utils.unspace(x.value)
|
||||||
groups[name] = {
|
groups[name] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"position": [head_rx, i],
|
"position": [head_rx, i],
|
||||||
"position_human": utils.merged_humanize(merged)
|
"position_human": utils.merged_humanize(merged.as_numbers()),
|
||||||
|
"slots": {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if merged is None:
|
if merged is None:
|
||||||
i += 1
|
i += 1
|
||||||
else:
|
else:
|
||||||
i += (merged[3] - merged[1] + 1)
|
i += merged.width()
|
||||||
|
|
||||||
return groups
|
return groups
|
||||||
|
|
||||||
def get_weekday_left(sh, rowx, colx):
|
|
||||||
m = utils.get_merged(sh, rowx, colx)
|
|
||||||
return sh.cell(m[0], m[1]).value
|
|
||||||
75
utils.py
75
utils.py
@@ -26,7 +26,7 @@ def parse_all_dirt(sh, min_pos, right, down):
|
|||||||
RET = set()
|
RET = set()
|
||||||
|
|
||||||
row = min_pos.row
|
row = min_pos.row
|
||||||
while row <= min_pos.row + down:
|
while row < min_pos.row + down:
|
||||||
col = min_pos.col
|
col = min_pos.col
|
||||||
while col < min_pos.col + right:
|
while col < min_pos.col + right:
|
||||||
#print(excel_coordinate(row, col))
|
#print(excel_coordinate(row, col))
|
||||||
@@ -38,6 +38,60 @@ def parse_all_dirt(sh, min_pos, right, down):
|
|||||||
|
|
||||||
return RET
|
return RET
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# GEMINI
|
||||||
|
def normalize_name(raw_name):
|
||||||
|
"""
|
||||||
|
Приводит разнородные записи ФИО к единому структурированному виду.
|
||||||
|
"""
|
||||||
|
# Шаг 1: Очистка
|
||||||
|
name = re.sub(r'\s+', ' ', raw_name).strip()
|
||||||
|
|
||||||
|
# Шаг 2: Извлечение звания
|
||||||
|
known_titles = ['проф.', 'доц.', 'акад.', 'к.т.н.', 'д.м.н.']
|
||||||
|
title = None
|
||||||
|
for t in known_titles:
|
||||||
|
if name.lower().startswith(t):
|
||||||
|
title = t
|
||||||
|
# Удаляем звание из строки, убираем лишние пробелы
|
||||||
|
name = name[len(t):].strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
# Шаг 3 и 4: Разделение и идентификация
|
||||||
|
parts = name.split(' ')
|
||||||
|
last_name = None
|
||||||
|
initials = None
|
||||||
|
|
||||||
|
# Простой эвристический анализ
|
||||||
|
# Ищем инициалы (содержат точку или состоят из 1-2 заглавных букв)
|
||||||
|
initials_parts = []
|
||||||
|
name_parts = []
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
if '.' in part or (1 <= len(part) <= 2 and part.isupper()):
|
||||||
|
initials_parts.append(part)
|
||||||
|
else:
|
||||||
|
# Считаем все остальное частью фамилии (для двойных фамилий)
|
||||||
|
name_parts.append(part)
|
||||||
|
|
||||||
|
if name_parts:
|
||||||
|
last_name = " ".join(name_parts)
|
||||||
|
|
||||||
|
if initials_parts:
|
||||||
|
initials = "".join(initials_parts) # Сливаем "А." и "Н." в "А.Н."
|
||||||
|
|
||||||
|
# Если фамилия не найдена (например, только инициалы),
|
||||||
|
# но есть части, считаем первую часть фамилией
|
||||||
|
if not last_name and name_parts:
|
||||||
|
last_name = name_parts[0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"last_name": last_name,
|
||||||
|
"initials": initials,
|
||||||
|
"title": title
|
||||||
|
}
|
||||||
|
|
||||||
def excel_coordinate(row, col):
|
def excel_coordinate(row, col):
|
||||||
"""
|
"""
|
||||||
Преобразует координаты строки и столбца (начиная с 0) в эквивалент Excel (например, A7, CB34).
|
Преобразует координаты строки и столбца (начиная с 0) в эквивалент Excel (например, A7, CB34).
|
||||||
@@ -95,3 +149,22 @@ def find(sh, query = None):
|
|||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def weekday_to_num(st: str):
|
||||||
|
if st.upper().strip() == "ПОНЕДЕЛЬНИК":
|
||||||
|
return 1
|
||||||
|
if st.upper().strip() == "ВТОРНИК":
|
||||||
|
return 2
|
||||||
|
if st.upper().strip() == "СРЕДА":
|
||||||
|
return 3
|
||||||
|
if st.upper().strip() == "ЧЕТВЕРГ":
|
||||||
|
return 4
|
||||||
|
if st.upper().strip() == "ПЯТНИЦА":
|
||||||
|
return 5
|
||||||
|
if st.upper().strip() == "СУББОТА":
|
||||||
|
return 6
|
||||||
|
if st.upper().strip() == "ВОСКРЕСЕНЬЕ":
|
||||||
|
return 7
|
||||||
|
|
||||||
|
return -1
|
||||||
|
|
||||||
Reference in New Issue
Block a user