objectivity_predicates / korean_sentence_splitter.py
jonghhhh's picture
Upload 3 files
7803723 verified
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Korean Sentence Splitter (한글 문장 분리기)
==========================================
정규표현식 기반 한국어 문장 분리 라이브러리.
KSS(Korean Sentence Splitter) 등 기존 연구를 참고하여 구현.
주요 특징:
- 종결어미 기반 문장 분리
- 구두점(마침표, 느낌표, 물음표 등) 처리
- 괄호/따옴표 내부 문장 보호
- 기사 제목 스타일 (명사 종결) 지원
- 약어 및 숫자 패턴 예외 처리
References:
- https://github.com/hyunwoongko/kss
- https://github.com/likejazz/korean-sentence-splitter
"""
import re
from typing import List, Optional, Tuple
from dataclasses import dataclass
from enum import Enum
class SplitMode(Enum):
"""문장 분리 모드"""
PUNCT_ONLY = "punct" # 구두점 기반만
NORMAL = "normal" # 일반 모드 (종결어미 + 구두점)
AGGRESSIVE = "aggressive" # 공격적 분리
@dataclass
class SplitterConfig:
"""문장 분리기 설정"""
mode: SplitMode = SplitMode.NORMAL
strip: bool = True
min_length: int = 2
preserve_quotes: bool = True
preserve_brackets: bool = True
class KoreanSentenceSplitter:
"""
한국어 문장 분리기
Usage:
splitter = KoreanSentenceSplitter()
sentences = splitter.split("안녕하세요. 반갑습니다!")
# ['안녕하세요.', '반갑습니다!']
"""
# ==================== 종결어미 패턴 ====================
# 평서형 종결어미 (Declarative)
DECLARATIVE_ENDINGS = [
# 격식체 (Formal) - 합쇼체
'습니다', '입니다', 'ㅂ니다',
# 비격식체 (Informal) - 해요체
'어요', '아요', '여요', '이에요', '예요', '에요', '세요', '셔요',
'죠', '지요',
# 해라체 (Plain)
'한다', '인다', '는다', '운다', '른다',
'었다', '았다', '였다', '겠다',
'더라', '더군',
# 해체 (Casual)
'해', '야', '네', '군', '구나', '구먼',
# 기타
'거든', '거든요', '답니다', '랍니다',
'데요', '래요', '대요',
]
# 의문형 종결어미 (Interrogative)
INTERROGATIVE_ENDINGS = [
'습니까', '입니까', 'ㅂ니까',
'나요', '가요',
'니', '냐', '나', '까',
'은가', '는가', '던가', '을까',
'을까요',
'지요', '죠', '지', '잖아', '잖아요',
]
# 명령형 종결어미 (Imperative)
IMPERATIVE_ENDINGS = [
'십시오', '세요', '셔요',
'아라', '어라', '여라', '거라',
'렴', '려무나',
]
# 청유형 종결어미 (Propositive)
PROPOSITIVE_ENDINGS = [
'읍시다', 'ㅂ시다',
'자', '자요',
]
# 감탄형 종결어미 (Exclamatory)
EXCLAMATORY_ENDINGS = [
'구나', '군', '네', '로구나', '는군', '구먼',
'도다', '로다',
]
# 연결어미 (분리하면 안됨) - 더 포괄적인 목록
CONNECTIVE_ENDINGS = [
'는데', '은데', 'ㄴ데',
'지만', '으나', '나',
'면서', '으면서', '며', '으며',
'고', '고서',
'니까', '으니까',
'어서', '아서', '여서',
'려고', '으려고', '러',
'다가', '었다가', '았다가',
'도록', '게',
'자마자',
'거나', '든지', '든가',
'려', '으려', '야', '어야', '아야', # 조건/의도
]
# 구두점 패턴
PUNCT_PATTERN = r'[.!?。!?]'
# 약어 패턴
ABBREV_PATTERNS = [
r'\d+\.', # 숫자.
r'[A-Za-z]+\.', # 영문.
r'\d+\.\d+', # 소수점
r'\.{2,}', # 연속 마침표
]
# 이모지 패턴
EMOJI_PATTERN = re.compile(
"["
"\U0001F600-\U0001F64F"
"\U0001F300-\U0001F5FF"
"\U0001F680-\U0001F6FF"
"\U0001F1E0-\U0001F1FF"
"\U00002702-\U000027B0"
"\U000024C2-\U0001F251"
"]+",
flags=re.UNICODE
)
# 한글 패턴
HANGUL_PATTERN = r'[\uAC00-\uD7A3\u1100-\u11FF\u3130-\u318F]'
def __init__(self, config: Optional[SplitterConfig] = None):
self.config = config or SplitterConfig()
self._compile_patterns()
def _compile_patterns(self):
"""정규표현식 패턴 컴파일"""
# 모든 종결어미 결합 (길이순 정렬)
all_endings = (
self.DECLARATIVE_ENDINGS +
self.INTERROGATIVE_ENDINGS +
self.IMPERATIVE_ENDINGS +
self.PROPOSITIVE_ENDINGS +
self.EXCLAMATORY_ENDINGS
)
all_endings = sorted(set(all_endings), key=len, reverse=True)
# 종결어미 패턴
endings_str = '|'.join(re.escape(e) for e in all_endings)
self.ending_pattern = re.compile(rf'({endings_str})$', re.UNICODE)
# 연결어미 패턴
conn_str = '|'.join(re.escape(e) for e in self.CONNECTIVE_ENDINGS)
self.connective_pattern = re.compile(rf'({conn_str})$', re.UNICODE)
# 구두점 패턴
self.punct_re = re.compile(self.PUNCT_PATTERN)
# 약어 패턴
self.abbrev_pattern = re.compile('|'.join(self.ABBREV_PATTERNS))
def split(self, text: str) -> List[str]:
"""텍스트를 문장 단위로 분리"""
if not text or not text.strip():
return []
# 전처리
text = re.sub(r'\s+', ' ', text).strip()
# 보호 영역 처리
text, protected = self._protect_regions(text)
# 분리 수행
if self.config.mode == SplitMode.PUNCT_ONLY:
sentences = self._split_punct_only(text, protected)
else:
sentences = self._split_with_endings(text, protected)
# 보호 영역 복원
sentences = self._restore_regions(sentences, protected)
# 후처리
return self._postprocess(sentences)
def _protect_regions(self, text: str) -> Tuple[str, dict]:
"""괄호/따옴표 내부 보호"""
protected = {}
counter = 0
def replace_fn(match):
nonlocal counter
token = f"__P{counter}__"
protected[token] = match.group(0)
counter += 1
return token
if self.config.preserve_quotes:
# 큰따옴표
text = re.sub(r'"[^"]*"', replace_fn, text)
text = re.sub(r'\u201C[^\u201D]*\u201D', replace_fn, text) # ""
text = re.sub(r'\u300C[^\u300D]*\u300D', replace_fn, text) # 「」
text = re.sub(r'\u300E[^\u300F]*\u300F', replace_fn, text) # 『』
# 작은따옴표
text = re.sub(r"'[^']*'", replace_fn, text)
if self.config.preserve_brackets:
text = re.sub(r'\([^)]*\)', replace_fn, text)
text = re.sub(r'\[[^\]]*\]', replace_fn, text)
return text, protected
def _restore_regions(self, sentences: List[str], protected: dict) -> List[str]:
"""보호 영역 복원"""
result = []
for sent in sentences:
for token, original in protected.items():
sent = sent.replace(token, original)
result.append(sent)
return result
def _split_punct_only(self, text: str, protected: dict = None) -> List[str]:
"""구두점 기반 분리"""
# 약어 보호
text, abbrev_map = self._protect_abbrevs(text)
# 구두점으로 분리
parts = re.split(rf'({self.PUNCT_PATTERN}+\s*)', text)
sentences = []
current = ""
for part in parts:
current += part
if self.punct_re.search(part):
sentences.append(current.strip())
current = ""
# 보호 구문(토큰) 뒤의 공백에서도 분리 체크
elif protected and re.search(r'(__P\d+__)\s*$', current):
token_match = re.search(r'(__P\d+__)', current)
if token_match:
original = protected.get(token_match.group(1), "")
if self.punct_re.search(original[-2:]):
sentences.append(current.strip())
current = ""
if current.strip():
sentences.append(current.strip())
# 약어 복원
return self._restore_abbrevs(sentences, abbrev_map)
def _split_with_endings(self, text: str, protected: dict = None) -> List[str]:
"""종결어미 + 구두점 기반 분리"""
# 약어 보호
text, abbrev_map = self._protect_abbrevs(text)
sentences = []
current = ""
i = 0
while i < len(text):
char = text[i]
current += char
# 구두점 체크
if self.punct_re.match(char):
# 연속 구두점 모두 포함 (예: ?!, !!, ...)
while i + 1 < len(text) and self.punct_re.match(text[i + 1]):
i += 1
current += text[i]
# 이모지 포함
while i + 1 < len(text) and self.EMOJI_PATTERN.match(text[i + 1]):
i += 1
current += text[i]
# 공백까지 포함
if i + 1 < len(text) and text[i + 1] in ' \t':
i += 1
current += text[i]
# 약어가 아니면 분리
if not self._is_abbrev(current.rstrip()):
sentences.append(current)
current = ""
# 종결어미 체크 (공백 앞)
elif char == ' ' and len(current) > 2:
check_text = current.rstrip()
if self._is_sentence_ending(check_text, protected):
sentences.append(current)
current = ""
i += 1
if current.strip():
sentences.append(current)
# 약어 복원
return self._restore_abbrevs(sentences, abbrev_map)
def _is_sentence_ending(self, text: str, protected: dict = None) -> bool:
"""종결어미로 끝나는지 확인 (보호 토큰 처리 포함)"""
if not text:
return False
# 보호 영역(토큰) 체크
token_match = re.search(r'(__P\d+__)$', text)
if token_match and protected:
token = token_match.group(1)
original = protected.get(token, "")
if not original:
return False
# 따옴표/괄호 내부 텍스트 추출
inner = original.strip()
# 1. 구두점으로 끝나는지 확인 (예: "안녕하세요.")
if self.punct_re.search(inner[-2:]):
return True
# 2. 종결어미로 끝나는지 확인 (따옴표 제거 후)
stripped_inner = inner.strip('\'"\"“”‘’「」『』()[]')
if stripped_inner and self._is_sentence_ending(stripped_inner):
return True
return False
if len(text) < 2:
return False
# 마지막 단어만 추출 (공백 기준)
words = text.split()
if not words:
return False
last_word = words[-1]
# 한 글자 단어는 종결어미로 판단하지 않음
if len(last_word) == 1:
return False
# 짧은 종결어미 (1글자) - 명사와 혼동되기 쉬움
# 이 경우 더 엄격한 검증 필요
SHORT_ENDINGS = ['자', '해', '야', '네', '군', '니', '냐', '나', '까']
# 긴 종결어미 (2글자 이상) - 신뢰도 높음
LONG_ENDINGS = [e for e in (
self.DECLARATIVE_ENDINGS +
self.INTERROGATIVE_ENDINGS +
self.IMPERATIVE_ENDINGS +
self.PROPOSITIVE_ENDINGS +
self.EXCLAMATORY_ENDINGS
) if len(e) >= 2]
# 연결어미로 끝나면 False
for conn in sorted(self.CONNECTIVE_ENDINGS, key=len, reverse=True):
if last_word.endswith(conn) and len(last_word) > len(conn):
return False
# 긴 종결어미 체크 (2글자 이상) - 우선 검사
for ending in sorted(LONG_ENDINGS, key=len, reverse=True):
if last_word.endswith(ending) and len(last_word) > len(ending):
return True
# 짧은 종결어미 (1글자) - 앞 글자가 용언 어간일 가능성이 높은지 확인
# 한국어 용언 어간은 대체로 모음으로 끝나지 않음 (받침 있음)
# 또는 특정 패턴의 어미 활용형
for ending in SHORT_ENDINGS:
if last_word.endswith(ending) and len(last_word) >= 2:
# 어간 추출
stem = last_word[:-len(ending)]
if len(stem) == 0:
continue
# 어간 마지막 글자의 받침 확인
last_stem_char = stem[-1]
if '\uAC00' <= last_stem_char <= '\uD7A3': # 완성형 한글
# 받침 여부 확인 (종성이 있는지)
code = ord(last_stem_char) - 0xAC00
jongsung = code % 28
# 동사/형용사 어간으로 보이는 패턴
# 예: 먹 + 자 = 먹자 (받침 있음)
# 가 + 자 = 가자 (받침 없지만 동사)
# 하 + 자 = 하자 (하다 동사)
# "하"로 끝나면 "하다" 동사일 가능성 높음
if stem.endswith('하') or stem.endswith('되'):
return True
# 받침이 있으면 동사 어간일 가능성 높음
if jongsung > 0:
return True
# 받침 없는 경우: "가자", "보자" 등 기본 동사
# 하지만 "피자"와 구분하기 어려움
# 2글자 단어 + 짧은 종결어미는 보수적으로 처리
if len(last_word) <= 2:
return False
# 3글자 이상이면 종결어미일 가능성 있음
return True
return False
def _protect_abbrevs(self, text: str) -> Tuple[str, dict]:
"""약어 보호"""
abbrev_map = {}
counter = 0
def replace_fn(match):
nonlocal counter
token = f"__A{counter}__"
abbrev_map[token] = match.group(0)
counter += 1
return token
text = self.abbrev_pattern.sub(replace_fn, text)
return text, abbrev_map
def _restore_abbrevs(self, sentences: List[str], abbrev_map: dict) -> List[str]:
"""약어 복원"""
result = []
for sent in sentences:
for token, original in abbrev_map.items():
sent = sent.replace(token, original)
result.append(sent)
return result
def _is_abbrev(self, text: str) -> bool:
"""약어 여부"""
if len(text) < 2:
return False
last_part = text[-10:] if len(text) > 10 else text
return bool(self.abbrev_pattern.search(last_part))
def _postprocess(self, sentences: List[str]) -> List[str]:
"""후처리"""
result = []
for sent in sentences:
if self.config.strip:
sent = sent.strip()
if len(sent) >= self.config.min_length:
result.append(sent)
return result
def split_with_type(self, text: str) -> List[dict]:
"""문장 분리 + 유형 분석"""
sentences = self.split(text)
result = []
for sent in sentences:
sent_type = self._detect_type(sent)
result.append({
'text': sent,
'type': sent_type
})
return result
def _detect_type(self, sent: str) -> str:
"""문장 유형 판단"""
sent = sent.rstrip()
if sent.endswith('?') or sent.endswith('\uFF1F'):
return 'interrogative'
if sent.endswith('!') or sent.endswith('\uFF01'):
return 'exclamatory'
for e in self.INTERROGATIVE_ENDINGS:
if sent.endswith(e):
return 'interrogative'
for e in self.IMPERATIVE_ENDINGS:
if sent.endswith(e):
return 'imperative'
for e in self.PROPOSITIVE_ENDINGS:
if sent.endswith(e):
return 'propositive'
return 'declarative'
# ==================== 간편 함수 ====================
def split_sentences(text: str,
mode: str = "normal",
preserve_quotes: bool = True) -> List[str]:
"""
간편 문장 분리 함수
Args:
text: 입력 텍스트
mode: 'punct' (구두점만) / 'normal' (종결어미+구두점)
preserve_quotes: 따옴표 내부 보호
Returns:
분리된 문장 리스트
Example:
>>> split_sentences("안녕하세요. 반갑습니다!")
['안녕하세요.', '반갑습니다!']
"""
config = SplitterConfig(
mode=SplitMode(mode),
preserve_quotes=preserve_quotes
)
return KoreanSentenceSplitter(config).split(text)
# ==================== 테스트 ====================
def run_tests():
"""테스트 실행"""
print("=" * 60)
print("한글 문장 분리기 테스트")
print("=" * 60)
splitter = KoreanSentenceSplitter()
test_cases = [
# 기본 구두점
"안녕하세요. 반갑습니다!",
# 종결어미 (구두점 없음)
"오늘 날씨가 좋습니다 내일도 맑을 예정입니다",
# 의문문
"뭐 먹을까요? 저는 피자가 좋아요.",
# 따옴표 보호
'"안녕하세요. 반갑습니다." 라고 말했다.',
# 괄호 보호
"서울(대한민국의 수도. 인구 1000만)은 큰 도시입니다.",
# 숫자/소수점
"3.14는 파이입니다. 원주율이죠.",
# 연결어미 vs 종결어미
"비가 오는데 우산이 없어요 어떡하죠",
# 말줄임표
"그래서... 결국 성공했어요!",
# 복잡한 문장 (KSS 예제)
"회사 동료 분들과 다녀왔는데 분위기도 좋고 음식도 맛있었어요 다만, 강남 토끼정이 강남 쉑쉑버거 골목길로 쭉 올라가야 하는데 다들 쉑쉑버거의 유혹에 넘어갈 뻔 했답니다 강남역 맛집 토끼정의 외부 모습.",
# 구어체
"ㅋㅋㅋ 너무 웃겨 진짜 최고야",
# 명사 종결 (기사 제목)
"주가 급등. 투자자들 환호",
# 여러 구두점
"정말이야?! 믿을 수 없어!!",
# 해체
"오늘 뭐해 나 심심해 놀자",
# 이모지
"오늘 너무 행복해요😊 좋은 하루였어요!",
]
for i, text in enumerate(test_cases, 1):
print(f"\n[테스트 {i}]")
print(f"입력: {text}")
result = splitter.split(text)
print(f"결과: {result}")
# 문장 유형 분석 테스트
print("\n" + "=" * 60)
print("문장 유형 분석 테스트")
print("=" * 60)
type_test = "오늘 뭐 먹을까요? 저는 피자 먹고 싶어요. 같이 가자! 얼른 준비해."
results = splitter.split_with_type(type_test)
print(f"\n입력: {type_test}")
for r in results:
print(f" [{r['type']:15}] {r['text']}")
print("\n" + "=" * 60)
print("테스트 완료!")
print("=" * 60)
if __name__ == "__main__":
run_tests()