Files
dokogen152/dokogen/declension.py
T

223 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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