#!/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()