fix: добавлена _log_unmatched в generator.py

This commit is contained in:
2026-07-30 17:38:32 +04:00
commit 3b1e8171bf
15 changed files with 3289 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
# -*- coding: utf-8 -*-
"""DokoGen — Склонение (pymorphy3 + правила для ФИО/организаций)"""
from functools import lru_cache
from typing import Dict
import re
try:
import pymorphy3
morph = pymorphy3.MorphAnalyzer()
MORPH_AVAILABLE = True
except ImportError:
morph = None
MORPH_AVAILABLE = False
CASES = ["nomn", "gent", "datv", "accs", "ablt", "loct"]
CASE_MAP = {
"nominative": "nomn", "им": "nomn",
"genitive": "gent", "род": "gent",
"dative": "datv", "дат": "datv",
"accusative": "accs", "вин": "accs",
"instrumental": "ablt", "твор": "ablt",
"prepositional": "loct", "пр": "loct",
}
CASE_NAMES = {
"nomn": "Именительный (Кто? Что?)",
"gent": "Родительный (Кого? Чего?)",
"datv": "Дательный (Кому? Чему?)",
"accs": "Винительный (Кого? Что?)",
"ablt": "Творительный (Кем? Чем?)",
"loct": "Предложный (О ком? О чём?)",
}
CASE_SHORT = {
"nomn": "им.п.", "gent": "род.п.", "datv": "дат.п.",
"accs": "вин.п.", "ablt": "твор.п.", "loct": "пр.п.",
}
def _normalize_case(case: str) -> str:
return CASE_MAP.get(case, "nomn" if case not in CASES else case)
def inflect_name(name: str) -> Dict[str, str]:
"""Склонение названия организации по всем падежам."""
return {gr: company_name_decline(name, gr) for gr in CASES}
@lru_cache(maxsize=512)
def company_name_decline(name: str, case: str) -> str:
"""Склонение названия организации."""
if not name:
return ""
case_gr = _normalize_case(case)
if case_gr == "nomn":
return name
m = re.match(r'^(.+?)\s*([\u00ab\"].*[\u00bb\"])$', name.strip())
if m:
prefix = m.group(1).strip()
quoted = m.group(2)
if prefix:
return decline(prefix, case_gr, decline_all=True) + " " + quoted
return name
return decline(name, case_gr, decline_all=True)
@lru_cache(maxsize=512)
def decline(text: str, case: str, decline_all: bool = False) -> str:
"""Склонение строки (ФИО, фразы) по падежам."""
if not text:
return ""
case_gr = _normalize_case(case)
if case_gr == "nomn":
return text
clean_text, quoted = _extract_quoted(text)
tokens = re.split(r"(\s+)", clean_text)
content_tokens = [t for t in tokens if not t.isspace() and t.strip()]
only_first = False
if not decline_all and len(content_tokens) >= 2:
for ct in content_tokens[1:]:
if ct[0].islower():
only_first = True
break
result_parts = []
content_idx = 0
for token in tokens:
if token.isspace() or not token.strip():
result_parts.append(token)
continue
stripped = token.strip()
if only_first and content_idx > 0:
result_parts.append(token)
content_idx += 1
continue
if _is_quoted(stripped):
result_parts.append(token)
content_idx += 1
continue
if stripped.isupper() and len(stripped) > 1:
result_parts.append(token)
content_idx += 1
continue
inflected = _inflect_word(stripped, case_gr)
if stripped[0].isupper():
inflected = inflected[0].upper() + inflected[1:]
result_parts.append(inflected)
content_idx += 1
return _restore_quoted("".join(result_parts), quoted)
def _inflect_word(word: str, case_gr: str) -> str:
"""Склонение одного слова через pymorphy3 с fallback на ручные правила."""
if not word or len(word) < 2 or case_gr == "nomn":
return word
if not MORPH_AVAILABLE or morph is None:
return _rules_decline(word, case_gr) or word
try:
parsed = morph.parse(word)
if not parsed:
return _rules_decline(word, case_gr) or word
best = parsed[0]
inflected = best.inflect({case_gr})
if inflected:
return inflected.word
return _rules_decline(word, case_gr) or word
except Exception:
return _rules_decline(word, case_gr) or word
def _rules_decline(word: str, case_gr: str) -> str:
"""Ручное склонение фамилий."""
w = word.lower()
# Мужские фамилии на -ов/-ев/-ёв
if w.endswith(('ов', 'ев', 'ёв')) and not w.endswith(('ова', 'ева', 'ёва')):
return {
'gent': word + 'а', 'datv': word + 'у', 'accs': word + 'а',
'ablt': word + 'ым', 'loct': word + 'е'
}.get(case_gr, word)
# Мужские фамилии на -ин
if w.endswith('ин') and not w.endswith('ина'):
return {
'gent': word + 'а', 'datv': word + 'у', 'accs': word + 'а',
'ablt': word + 'ым', 'loct': word + 'е'
}.get(case_gr, word)
# Женские фамилии на -ова/-ева/-ёва
if w.endswith(('ова', 'ева', 'ёва')):
stem = word[:-1]
return {
'gent': stem + 'ой', 'datv': stem + 'ой', 'accs': stem + 'у',
'ablt': stem + 'ой', 'loct': stem + 'ой'
}.get(case_gr, word)
# Женские фамилии на -ина
if w.endswith('ина'):
stem = word[:-1]
return {
'gent': stem + 'ой', 'datv': stem + 'ой', 'accs': stem + 'у',
'ablt': stem + 'ой', 'loct': stem + 'ой'
}.get(case_gr, word)
return word
def _extract_quoted(text: str):
"""Извлекает подстроки в кавычках, заменяет плейсхолдерами."""
if not text:
return text, {}
pattern = re.compile(r'(\u00ab[^\u00bb]*\u00bb|"[^"]*")')
placeholders = {}
counter = [0]
def _replace(m):
ph = f'__Q_{counter[0]}__'
placeholders[ph] = m.group(0)
counter[0] += 1
return ph
return pattern.sub(_replace, text), placeholders
def _restore_quoted(text: str, placeholders: dict) -> str:
for ph, original in placeholders.items():
text = text.replace(ph, original)
return text
def _is_quoted(word: str) -> bool:
w = word.strip()
if not w:
return False
return (w.startswith("\u00ab") and w.endswith("\u00bb")) or \
(w.startswith('"') and w.endswith('"'))
# ==================== ФОРМАТИРОВАНИЕ ФИО ====================
def get_short_fio(fio: str) -> str:
"""Фамилия И.О."""
if not fio:
return ""
parts = fio.split()
if len(parts) >= 2:
initials = parts[1][0] + "."
if len(parts) >= 3:
initials += parts[2][0] + "."
return parts[0] + " " + initials
return fio
def get_initials(fio: str) -> str:
"""И.О. Фамилия"""
if not fio:
return ""
parts = fio.split()
if len(parts) >= 2:
initials = parts[1][0] + "."
if len(parts) >= 3:
initials += parts[2][0] + "."
return initials + " " + parts[0]
return fio