Spaces:
Sleeping
Sleeping
| #!/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" # 공격적 분리 | |
| 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() | |