jonghhhh commited on
Commit
90804ac
·
verified ·
1 Parent(s): 7b261cf

Upload 2 files

Browse files
src/korean_sentence_splitter.py ADDED
@@ -0,0 +1,601 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Korean Sentence Splitter (한글 문장 분리기)
5
+ ==========================================
6
+
7
+ 정규표현식 기반 한국어 문장 분리 라이브러리.
8
+ KSS(Korean Sentence Splitter) 등 기존 연구를 참고하여 구현.
9
+
10
+ 주요 특징:
11
+ - 종결어미 기반 문장 분리
12
+ - 구두점(마침표, 느낌표, 물음표 등) 처리
13
+ - 괄호/따옴표 내부 문장 보호
14
+ - 기사 제목 스타일 (명사 종결) 지원
15
+ - 약어 및 숫자 패턴 예외 처리
16
+
17
+ References:
18
+ - https://github.com/hyunwoongko/kss
19
+ - https://github.com/likejazz/korean-sentence-splitter
20
+ """
21
+
22
+ import re
23
+ from typing import List, Optional, Tuple
24
+ from dataclasses import dataclass
25
+ from enum import Enum
26
+
27
+
28
+ class SplitMode(Enum):
29
+ """문장 분리 모드"""
30
+ PUNCT_ONLY = "punct" # 구두점 기반만
31
+ NORMAL = "normal" # 일반 모드 (종결어미 + 구두점)
32
+ AGGRESSIVE = "aggressive" # 공격적 분리
33
+
34
+
35
+ @dataclass
36
+ class SplitterConfig:
37
+ """문장 분리기 설정"""
38
+ mode: SplitMode = SplitMode.NORMAL
39
+ strip: bool = True
40
+ min_length: int = 2
41
+ preserve_quotes: bool = True
42
+ preserve_brackets: bool = True
43
+
44
+
45
+ class KoreanSentenceSplitter:
46
+ """
47
+ 한국어 문장 분리기
48
+
49
+ Usage:
50
+ splitter = KoreanSentenceSplitter()
51
+ sentences = splitter.split("안녕하세요. 반갑습니다!")
52
+ # ['안녕하세요.', '반갑습니다!']
53
+ """
54
+
55
+ # ==================== 종결어미 패턴 ====================
56
+
57
+ # 평서형 종결어미 (Declarative)
58
+ DECLARATIVE_ENDINGS = [
59
+ # 격식체 (Formal) - 합쇼체
60
+ '습니다', '입니다', 'ㅂ니다',
61
+ # 비격식체 (Informal) - 해요체
62
+ '어요', '아요', '여요', '이에요', '예요', '에요', '세요', '셔요',
63
+ '죠', '지요',
64
+ # 해라체 (Plain)
65
+ '한다', '인다', '는다', '운다', '른다',
66
+ '었다', '았다', '였다', '겠다',
67
+ '더라', '더군',
68
+ # 해체 (Casual)
69
+ '해', '야', '네', '군', '구나', '구먼',
70
+ # 기타
71
+ '거든', '거든요', '답니다', '랍니다',
72
+ '데요', '래요', '대요',
73
+ ]
74
+
75
+ # 의문형 종결어미 (Interrogative)
76
+ INTERROGATIVE_ENDINGS = [
77
+ '습니까', '입니까', 'ㅂ니까',
78
+ '나요', '가요',
79
+ '니', '냐', '나', '까',
80
+ '은가', '는가', '던가', '을까',
81
+ '을까요',
82
+ '지요', '죠', '지', '잖아', '잖아요',
83
+ ]
84
+
85
+ # 명령형 종결어미 (Imperative)
86
+ IMPERATIVE_ENDINGS = [
87
+ '십시오', '세요', '셔요',
88
+ '아라', '어라', '여라', '거라',
89
+ '렴', '려무나',
90
+ ]
91
+
92
+ # 청유형 종결어미 (Propositive)
93
+ PROPOSITIVE_ENDINGS = [
94
+ '읍시다', 'ㅂ시다',
95
+ '자', '자요',
96
+ ]
97
+
98
+ # 감탄형 종결어미 (Exclamatory)
99
+ EXCLAMATORY_ENDINGS = [
100
+ '구나', '군', '네', '로구나', '는군', '구먼',
101
+ '도다', '로다',
102
+ ]
103
+
104
+ # 연결어미 (분리하면 안됨) - 더 포괄적인 목록
105
+ CONNECTIVE_ENDINGS = [
106
+ '는데', '은데', 'ㄴ데',
107
+ '지만', '으나', '나',
108
+ '면서', '으면서', '며', '으며',
109
+ '고', '고서',
110
+ '니까', '으니까',
111
+ '어서', '아서', '여서',
112
+ '려고', '으려고', '러',
113
+ '다가', '었다가', '았다가',
114
+ '도록', '게',
115
+ '자마자',
116
+ '거나', '든지', '든가',
117
+ '려', '으려', '야', '어야', '아야', # 조건/의도
118
+ ]
119
+
120
+ # 구두점 패턴
121
+ PUNCT_PATTERN = r'[.!?。!?]'
122
+
123
+ # 약어 패턴
124
+ ABBREV_PATTERNS = [
125
+ r'\d+\.', # 숫자.
126
+ r'[A-Za-z]+\.', # 영문.
127
+ r'\d+\.\d+', # 소수점
128
+ r'\.{2,}', # 연속 마침표
129
+ ]
130
+
131
+ # 이모지 패턴
132
+ EMOJI_PATTERN = re.compile(
133
+ "["
134
+ "\U0001F600-\U0001F64F"
135
+ "\U0001F300-\U0001F5FF"
136
+ "\U0001F680-\U0001F6FF"
137
+ "\U0001F1E0-\U0001F1FF"
138
+ "\U00002702-\U000027B0"
139
+ "\U000024C2-\U0001F251"
140
+ "]+",
141
+ flags=re.UNICODE
142
+ )
143
+
144
+ # 한글 패턴
145
+ HANGUL_PATTERN = r'[\uAC00-\uD7A3\u1100-\u11FF\u3130-\u318F]'
146
+
147
+ def __init__(self, config: Optional[SplitterConfig] = None):
148
+ self.config = config or SplitterConfig()
149
+ self._compile_patterns()
150
+
151
+ def _compile_patterns(self):
152
+ """정규표현식 패턴 컴파일"""
153
+ # 모든 종결어미 결합 (길이순 정렬)
154
+ all_endings = (
155
+ self.DECLARATIVE_ENDINGS +
156
+ self.INTERROGATIVE_ENDINGS +
157
+ self.IMPERATIVE_ENDINGS +
158
+ self.PROPOSITIVE_ENDINGS +
159
+ self.EXCLAMATORY_ENDINGS
160
+ )
161
+ all_endings = sorted(set(all_endings), key=len, reverse=True)
162
+
163
+ # 종결어미 패턴
164
+ endings_str = '|'.join(re.escape(e) for e in all_endings)
165
+ self.ending_pattern = re.compile(rf'({endings_str})$', re.UNICODE)
166
+
167
+ # 연결어미 패턴
168
+ conn_str = '|'.join(re.escape(e) for e in self.CONNECTIVE_ENDINGS)
169
+ self.connective_pattern = re.compile(rf'({conn_str})$', re.UNICODE)
170
+
171
+ # 구두점 패턴
172
+ self.punct_re = re.compile(self.PUNCT_PATTERN)
173
+
174
+ # 약어 패턴
175
+ self.abbrev_pattern = re.compile('|'.join(self.ABBREV_PATTERNS))
176
+
177
+ def split(self, text: str) -> List[str]:
178
+ """텍스트를 문장 단위로 분리"""
179
+ if not text or not text.strip():
180
+ return []
181
+
182
+ # 전처리
183
+ text = re.sub(r'\s+', ' ', text).strip()
184
+
185
+ # 보호 영역 처리
186
+ text, protected = self._protect_regions(text)
187
+
188
+ # 분리 수행
189
+ if self.config.mode == SplitMode.PUNCT_ONLY:
190
+ sentences = self._split_punct_only(text, protected)
191
+ else:
192
+ sentences = self._split_with_endings(text, protected)
193
+
194
+ # 보호 영역 복원
195
+ sentences = self._restore_regions(sentences, protected)
196
+
197
+ # 후처리
198
+ return self._postprocess(sentences)
199
+
200
+ def _protect_regions(self, text: str) -> Tuple[str, dict]:
201
+ """괄호/따옴표 내부 보호"""
202
+ protected = {}
203
+ counter = 0
204
+
205
+ def replace_fn(match):
206
+ nonlocal counter
207
+ token = f"__P{counter}__"
208
+ protected[token] = match.group(0)
209
+ counter += 1
210
+ return token
211
+
212
+ if self.config.preserve_quotes:
213
+ # 큰따옴표
214
+ text = re.sub(r'"[^"]*"', replace_fn, text)
215
+ text = re.sub(r'\u201C[^\u201D]*\u201D', replace_fn, text) # ""
216
+ text = re.sub(r'\u300C[^\u300D]*\u300D', replace_fn, text) # 「」
217
+ text = re.sub(r'\u300E[^\u300F]*\u300F', replace_fn, text) # 『』
218
+ # 작은따옴표
219
+ text = re.sub(r"'[^']*'", replace_fn, text)
220
+
221
+ if self.config.preserve_brackets:
222
+ text = re.sub(r'\([^)]*\)', replace_fn, text)
223
+ text = re.sub(r'\[[^\]]*\]', replace_fn, text)
224
+
225
+ return text, protected
226
+
227
+ def _restore_regions(self, sentences: List[str], protected: dict) -> List[str]:
228
+ """보호 영역 복원"""
229
+ result = []
230
+ for sent in sentences:
231
+ for token, original in protected.items():
232
+ sent = sent.replace(token, original)
233
+ result.append(sent)
234
+ return result
235
+
236
+ def _split_punct_only(self, text: str, protected: dict = None) -> List[str]:
237
+ """구두점 기반 분리"""
238
+ # 약어 보호
239
+ text, abbrev_map = self._protect_abbrevs(text)
240
+
241
+ # 구두점으로 분리
242
+ parts = re.split(rf'({self.PUNCT_PATTERN}+\s*)', text)
243
+
244
+ sentences = []
245
+ current = ""
246
+ for part in parts:
247
+ current += part
248
+ if self.punct_re.search(part):
249
+ sentences.append(current.strip())
250
+ current = ""
251
+ # 보호 구문(토큰) 뒤의 공백에서도 분리 체크
252
+ elif protected and re.search(r'(__P\d+__)\s*$', current):
253
+ token_match = re.search(r'(__P\d+__)', current)
254
+ if token_match:
255
+ original = protected.get(token_match.group(1), "")
256
+ if self.punct_re.search(original[-2:]):
257
+ sentences.append(current.strip())
258
+ current = ""
259
+
260
+ if current.strip():
261
+ sentences.append(current.strip())
262
+
263
+ # 약어 복원
264
+ return self._restore_abbrevs(sentences, abbrev_map)
265
+
266
+ def _split_with_endings(self, text: str, protected: dict = None) -> List[str]:
267
+ """종결어미 + 구두점 기반 분리"""
268
+ # 약어 보호
269
+ text, abbrev_map = self._protect_abbrevs(text)
270
+
271
+ sentences = []
272
+ current = ""
273
+
274
+ i = 0
275
+ while i < len(text):
276
+ char = text[i]
277
+ current += char
278
+
279
+ # 구두점 체크
280
+ if self.punct_re.match(char):
281
+ # 연속 구두점 모두 포함 (예: ?!, !!, ...)
282
+ while i + 1 < len(text) and self.punct_re.match(text[i + 1]):
283
+ i += 1
284
+ current += text[i]
285
+
286
+ # 이모지 포함
287
+ while i + 1 < len(text) and self.EMOJI_PATTERN.match(text[i + 1]):
288
+ i += 1
289
+ current += text[i]
290
+
291
+ # 공백까지 포함
292
+ if i + 1 < len(text) and text[i + 1] in ' \t':
293
+ i += 1
294
+ current += text[i]
295
+
296
+ # 약어가 아니면 분리
297
+ if not self._is_abbrev(current.rstrip()):
298
+ sentences.append(current)
299
+ current = ""
300
+
301
+ # 종결어미 체크 (공백 앞)
302
+ elif char == ' ' and len(current) > 2:
303
+ check_text = current.rstrip()
304
+ if self._is_sentence_ending(check_text, protected):
305
+ sentences.append(current)
306
+ current = ""
307
+
308
+ i += 1
309
+
310
+ if current.strip():
311
+ sentences.append(current)
312
+
313
+ # 약어 복원
314
+ return self._restore_abbrevs(sentences, abbrev_map)
315
+
316
+ def _is_sentence_ending(self, text: str, protected: dict = None) -> bool:
317
+ """종결어미로 끝나는지 확인 (보호 토큰 처리 포함)"""
318
+ if not text:
319
+ return False
320
+
321
+ # 보호 영역(토큰) 체크
322
+ token_match = re.search(r'(__P\d+__)$', text)
323
+ if token_match and protected:
324
+ token = token_match.group(1)
325
+ original = protected.get(token, "")
326
+ if not original:
327
+ return False
328
+
329
+ # 따옴표/괄호 내부 텍스트 추출
330
+ inner = original.strip()
331
+ # 1. 구두점으로 끝나는지 확인 (예: "안녕하세요.")
332
+ if self.punct_re.search(inner[-2:]):
333
+ return True
334
+
335
+ # 2. 종결어미로 끝나는지 확인 (따옴표 제거 후)
336
+ stripped_inner = inner.strip('\'"\"“”‘’「」『』()[]')
337
+ if stripped_inner and self._is_sentence_ending(stripped_inner):
338
+ return True
339
+ return False
340
+
341
+ if len(text) < 2:
342
+ return False
343
+
344
+ # 마지막 단어만 추출 (공백 기준)
345
+ words = text.split()
346
+ if not words:
347
+ return False
348
+ last_word = words[-1]
349
+
350
+ # 한 글자 단어는 종결어미로 판단하지 않음
351
+ if len(last_word) == 1:
352
+ return False
353
+
354
+ # 짧은 종결어미 (1글자) - 명사와 혼동되기 쉬움
355
+ # 이 경우 더 엄격한 검증 필요
356
+ SHORT_ENDINGS = ['자', '해', '야', '네', '군', '니', '냐', '나', '까']
357
+
358
+ # 긴 종결어미 (2글자 이상) - 신뢰도 높음
359
+ LONG_ENDINGS = [e for e in (
360
+ self.DECLARATIVE_ENDINGS +
361
+ self.INTERROGATIVE_ENDINGS +
362
+ self.IMPERATIVE_ENDINGS +
363
+ self.PROPOSITIVE_ENDINGS +
364
+ self.EXCLAMATORY_ENDINGS
365
+ ) if len(e) >= 2]
366
+
367
+ # 연결어미로 끝나면 False
368
+ for conn in sorted(self.CONNECTIVE_ENDINGS, key=len, reverse=True):
369
+ if last_word.endswith(conn) and len(last_word) > len(conn):
370
+ return False
371
+
372
+ # 긴 종결어미 체크 (2글자 이상) - 우선 검사
373
+ for ending in sorted(LONG_ENDINGS, key=len, reverse=True):
374
+ if last_word.endswith(ending) and len(last_word) > len(ending):
375
+ return True
376
+
377
+ # 짧은 종결어미 (1글자) - 앞 글자가 용언 어간일 가능성이 높은지 확인
378
+ # 한국어 용언 어간은 대체로 모음으로 끝나지 않음 (받침 있음)
379
+ # 또는 특정 패턴의 어미 활용형
380
+ for ending in SHORT_ENDINGS:
381
+ if last_word.endswith(ending) and len(last_word) >= 2:
382
+ # 어간 추출
383
+ stem = last_word[:-len(ending)]
384
+ if len(stem) == 0:
385
+ continue
386
+
387
+ # 어간 마지막 글자의 받침 확인
388
+ last_stem_char = stem[-1]
389
+ if '\uAC00' <= last_stem_char <= '\uD7A3': # 완성형 한글
390
+ # 받침 여부 확인 (종성이 있는지)
391
+ code = ord(last_stem_char) - 0xAC00
392
+ jongsung = code % 28
393
+
394
+ # 동사/형용사 어간으로 보이는 패턴
395
+ # 예: 먹 + 자 = 먹자 (받침 있음)
396
+ # 가 + 자 = 가자 (받침 없지만 동사)
397
+ # 하 + 자 = 하자 (하다 동사)
398
+
399
+ # "하"로 끝나면 "하다" 동사일 가능성 높음
400
+ if stem.endswith('하') or stem.endswith('되'):
401
+ return True
402
+
403
+ # 받침이 있으면 동사 어간일 가능성 높음
404
+ if jongsung > 0:
405
+ return True
406
+
407
+ # 받침 없는 경우: "가자", "보자" 등 기본 동사
408
+ # 하지만 "피자"와 구분하기 어려움
409
+ # 2글자 단어 + 짧은 종결어미는 보수적으로 처리
410
+ if len(last_word) <= 2:
411
+ return False
412
+
413
+ # 3글자 이상이면 종결어미일 가능성 있음
414
+ return True
415
+
416
+ return False
417
+
418
+ def _protect_abbrevs(self, text: str) -> Tuple[str, dict]:
419
+ """약어 보호"""
420
+ abbrev_map = {}
421
+ counter = 0
422
+
423
+ def replace_fn(match):
424
+ nonlocal counter
425
+ token = f"__A{counter}__"
426
+ abbrev_map[token] = match.group(0)
427
+ counter += 1
428
+ return token
429
+
430
+ text = self.abbrev_pattern.sub(replace_fn, text)
431
+ return text, abbrev_map
432
+
433
+ def _restore_abbrevs(self, sentences: List[str], abbrev_map: dict) -> List[str]:
434
+ """약어 복원"""
435
+ result = []
436
+ for sent in sentences:
437
+ for token, original in abbrev_map.items():
438
+ sent = sent.replace(token, original)
439
+ result.append(sent)
440
+ return result
441
+
442
+ def _is_abbrev(self, text: str) -> bool:
443
+ """약어 여부"""
444
+ if len(text) < 2:
445
+ return False
446
+ last_part = text[-10:] if len(text) > 10 else text
447
+ return bool(self.abbrev_pattern.search(last_part))
448
+
449
+ def _postprocess(self, sentences: List[str]) -> List[str]:
450
+ """후처리"""
451
+ result = []
452
+ for sent in sentences:
453
+ if self.config.strip:
454
+ sent = sent.strip()
455
+ if len(sent) >= self.config.min_length:
456
+ result.append(sent)
457
+ return result
458
+
459
+ def split_with_type(self, text: str) -> List[dict]:
460
+ """문장 분리 + 유형 분석"""
461
+ sentences = self.split(text)
462
+ result = []
463
+
464
+ for sent in sentences:
465
+ sent_type = self._detect_type(sent)
466
+ result.append({
467
+ 'text': sent,
468
+ 'type': sent_type
469
+ })
470
+
471
+ return result
472
+
473
+ def _detect_type(self, sent: str) -> str:
474
+ """문장 유형 판단"""
475
+ sent = sent.rstrip()
476
+
477
+ if sent.endswith('?') or sent.endswith('\uFF1F'):
478
+ return 'interrogative'
479
+ if sent.endswith('!') or sent.endswith('\uFF01'):
480
+ return 'exclamatory'
481
+
482
+ for e in self.INTERROGATIVE_ENDINGS:
483
+ if sent.endswith(e):
484
+ return 'interrogative'
485
+
486
+ for e in self.IMPERATIVE_ENDINGS:
487
+ if sent.endswith(e):
488
+ return 'imperative'
489
+
490
+ for e in self.PROPOSITIVE_ENDINGS:
491
+ if sent.endswith(e):
492
+ return 'propositive'
493
+
494
+ return 'declarative'
495
+
496
+
497
+ # ==================== 간편 함수 ====================
498
+
499
+ def split_sentences(text: str,
500
+ mode: str = "normal",
501
+ preserve_quotes: bool = True) -> List[str]:
502
+ """
503
+ 간편 문장 분리 함수
504
+
505
+ Args:
506
+ text: 입력 텍스트
507
+ mode: 'punct' (구두점만) / 'normal' (종결어미+구두점)
508
+ preserve_quotes: 따옴표 내부 보호
509
+
510
+ Returns:
511
+ 분리된 문장 리스트
512
+
513
+ Example:
514
+ >>> split_sentences("안녕하세요. 반갑습니다!")
515
+ ['안녕하세요.', '반갑습니다!']
516
+ """
517
+ config = SplitterConfig(
518
+ mode=SplitMode(mode),
519
+ preserve_quotes=preserve_quotes
520
+ )
521
+ return KoreanSentenceSplitter(config).split(text)
522
+
523
+
524
+ # ==================== 테스트 ====================
525
+
526
+ def run_tests():
527
+ """테스트 실행"""
528
+ print("=" * 60)
529
+ print("한글 문장 분리기 테스트")
530
+ print("=" * 60)
531
+
532
+ splitter = KoreanSentenceSplitter()
533
+
534
+ test_cases = [
535
+ # 기본 구두점
536
+ "안녕하세요. 반갑습니다!",
537
+
538
+ # 종결어미 (구두점 없음)
539
+ "오늘 날씨가 좋습니다 내일도 맑을 예정입니다",
540
+
541
+ # 의문문
542
+ "뭐 먹을까요? 저는 피자가 좋아요.",
543
+
544
+ # 따옴표 보호
545
+ '"안녕하세요. 반갑습니다." 라고 말했다.',
546
+
547
+ # 괄호 보호
548
+ "서울(대한민국의 수도. 인구 1000만)은 큰 도시입니다.",
549
+
550
+ # 숫자/소수점
551
+ "3.14는 파이입니다. 원주율이죠.",
552
+
553
+ # 연결어미 vs 종결어미
554
+ "비가 오는데 우산이 없어요 어떡하죠",
555
+
556
+ # 말줄임표
557
+ "그래서... 결국 성공했어요!",
558
+
559
+ # 복잡한 문장 (KSS 예제)
560
+ "회사 동료 분들과 다녀왔는데 분위기도 좋고 음식도 맛있었어요 다만, 강남 토끼정이 강남 쉑쉑버거 골목길로 쭉 올라가야 하는데 다들 쉑쉑버거의 유혹에 넘어갈 뻔 했답니다 강남역 맛집 토끼정의 외부 모습.",
561
+
562
+ # 구어체
563
+ "ㅋㅋㅋ 너무 웃겨 진짜 최고야",
564
+
565
+ # 명사 종결 (기사 제목)
566
+ "주가 급등. 투자자들 환호",
567
+
568
+ # 여러 구두점
569
+ "정말이야?! 믿을 수 없어!!",
570
+
571
+ # 해체
572
+ "오늘 뭐해 나 심심해 놀자",
573
+
574
+ # ���모지
575
+ "오늘 너무 행복해요😊 좋은 하루였어요!",
576
+ ]
577
+
578
+ for i, text in enumerate(test_cases, 1):
579
+ print(f"\n[테스트 {i}]")
580
+ print(f"입력: {text}")
581
+ result = splitter.split(text)
582
+ print(f"결과: {result}")
583
+
584
+ # 문장 유형 분석 테스트
585
+ print("\n" + "=" * 60)
586
+ print("문장 유형 분석 테스트")
587
+ print("=" * 60)
588
+
589
+ type_test = "오늘 뭐 먹을까요? 저는 피자 먹고 싶어요. 같이 가자! 얼른 준비해."
590
+ results = splitter.split_with_type(type_test)
591
+ print(f"\n입력: {type_test}")
592
+ for r in results:
593
+ print(f" [{r['type']:15}] {r['text']}")
594
+
595
+ print("\n" + "=" * 60)
596
+ print("테스트 완료!")
597
+ print("=" * 60)
598
+
599
+
600
+ if __name__ == "__main__":
601
+ run_tests()
src/subjectless_predicates_122725_v2.py ADDED
@@ -0,0 +1,448 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 언론 보도 객관성 측정을 위한 무주체 피동형 술어 정규표현식 (v2)
3
+ ================================================================================
4
+
5
+ I. 객관성 의심 술어 (DOUBT): 기자 의견으로 여겨질 수 있는 무주체 주관적 술어
6
+ II. 객관성 지지 술어 (SUPPORT): 사실 확인/명시적 출처 기반 술어
7
+
8
+ * 무주체 피동형 술어: 발언/판단의 주체가 문장에 없어 기자 의견으로 읽힐 수 있는 표현
9
+ """
10
+
11
+ import re
12
+ from typing import Dict, Pattern, List, Any
13
+ from korean_sentence_splitter import KoreanSentenceSplitter
14
+
15
+ # =============================================================================
16
+ # I. 객관성 의심 술어 (DOUBT):
17
+ # =============================================================================
18
+
19
+ DOUBT_PREDICATES: Dict[str, Pattern] = {
20
+
21
+ # =========================================================================
22
+ # 1. 분석/해석형
23
+ # =========================================================================
24
+ "분석형": re.compile(
25
+ r"(?:"
26
+ # --- 피동형 술어 ---
27
+ r"분석(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
28
+ r"풀이(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
29
+ r"해석(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
30
+ r"진단(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
31
+ r"읽(?:힌다|힙니다|혔다|혔습니다|히고\s*있다|히고\s*있습니다)|"
32
+ # --- 명사형 술어: 분석/풀이/해석/진단 ---
33
+ r"(?:분석|풀이|해석|진단)(?:이다|입니다|이었다|이었습니다|이에요)|"
34
+ r"(?:라는|다는|이라는|란|는|은|인)\s*(?:분석|풀이|해석|진단)(?:이다|입니다|이었다|이었습니다|이에요)|"
35
+ r"(?:라는|다는|이라는|란|는|은|인)?\s*(?:분석|풀이|해석|진단)(?:도\s*있다|도\s*있습니다|도\s*나온다|도\s*나옵니다|도\s*나오고\s*있다|도\s*나오고\s*있습니다)|"
36
+ r"(?:라는|다는|이라는|란|는|은|인)?\s*(?:분석|풀이|해석|진단)(?:이\s*나온다|이\s*나옵니다|이\s*나오고\s*있다|이\s*나오고\s*있습니다|이\s*나왔다|이\s*나왔습니다|이\s*지배적이다|이\s*지배적입니다)"
37
+ r")"
38
+ ),
39
+
40
+ # =========================================================================
41
+ # 2. 전망/예측형
42
+ # =========================================================================
43
+ "전망형": re.compile(
44
+ r"(?:"
45
+ # --- 피동형 술어 ---
46
+ r"전망(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
47
+ r"예상(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
48
+ r"예측(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
49
+ r"점쳐(?:진다|집니다|졌다|졌습니다|지고\s*있다|지고\s*있습니다)|"
50
+ r"예견(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
51
+ # --- 명사형 술어 ---
52
+ r"(?:전망|예상|예측)(?:이다|입니다|이었다|이었습니다|이에요)|"
53
+ r"(?:라는|다는|이라는|란|는|은|인)\s*(?:전망|예상|예측|관측)(?:이다|입니다|이었다|이었습니다|이에요)|"
54
+ r"(?:라는|다는|이라는|란|는|은|인)?\s*(?:전망|예상|예측|관측)(?:도\s*있다|도\s*있습니다|도\s*나온다|도\s*나옵니다|도\s*나오고\s*있다|도\s*나오고\s*있습니다|이\s*우세하다|이\s*우세합니다|이\s*지배적이다|이\s*지배적입니다)|"
55
+ r"(?:라는|다는|이라는|란|는|은|인)?\s*(?:전망|예상|예측|관측)(?:이\s*나온다|이\s*나옵니다|이\s*나오고\s*있다|이\s*나오고\s*있습니다|이\s*나왔다|이\s*나왔습니다)"
56
+ r")"
57
+ ),
58
+
59
+ # =========================================================================
60
+ # 3. 관측/추정형
61
+ # =========================================================================
62
+ "관측형": re.compile(
63
+ r"(?:"
64
+ # --- 피동형 술어 ---
65
+ r"관측(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
66
+ r"추정(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
67
+ r"추측(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
68
+ r"짐작(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
69
+ # --- 명사형 술어 ---
70
+ r"(?:관측|추정|추측)(?:이다|입니다|이었다|이었습니다|이에요)|"
71
+ r"(?:라는|다는|이라는|란|는|은|인)\s*(?:관측|추정|추측)(?:이다|입니다|이었다|이었습니다|이에요)|"
72
+ r"(?:라는|다는|이라는|란|는|���|인)?\s*(?:관측|추정|추측)(?:도\s*있다|도\s*있습니다|도\s*나온다|도\s*나옵니다|이\s*나온다|이\s*나옵니다|이\s*나오고\s*있다|이\s*나오고\s*있습니다)"
73
+ r")"
74
+ ),
75
+
76
+ # =========================================================================
77
+ # 4. 전언/보도형
78
+ # =========================================================================
79
+ "전언형": re.compile(
80
+ r"(?:"
81
+ # --- 피동형 술어 ---
82
+ r"알려(?:진다|집니다|졌다|졌습니다|지고\s*있다|지고\s*있습니다)|"
83
+ r"전해(?:진다|집니다|졌다|졌습니다|지고\s*있다|지고\s*있습니다)|"
84
+ r"보도(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
85
+ r"전달(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
86
+ # 것으로 + 전언
87
+ r"것으로\s*(?:알려졌다|알려졌습니다|알려집니다|알려지고\s*있다|알려지고\s*있습니다|전해졌다|전해졌습니다|전해집니다|전해지고\s*있다|전해지고\s*있습니다)|"
88
+ # --- 명사형 술어 ---
89
+ r"(?:소식|보도|소문)(?:이다|입니다|이었다|이었습니다|이에요)|"
90
+ r"(?:라는|다는|이라는|란|는|은|인)\s*(?:소식|보도|소문)(?:이다|입니다|이었다|이었습니다|이에요)|"
91
+ r"(?:라는|다는)?\s*(?:소식|보도)(?:이\s*전해졌다|이\s*전해졌습니다|이\s*전해지고\s*있다|이\s*전해지고\s*있습니다|이\s*들려온다|이\s*들려옵니다|이\s*들려왔다|이\s*들려왔습니다)|"
92
+ # ~라는 겁니다/것입니다
93
+ r"(?:라는|다는)\s*(?:겁니다|것입니다|것이다|얘기다|얘기입니다|이야기다|이야기입니다)"
94
+ r")"
95
+ ),
96
+
97
+ # =========================================================================
98
+ # 5. 평가/판단형
99
+ # =========================================================================
100
+ "평가형": re.compile(
101
+ r"(?:"
102
+ # --- 피동형 술어 ---
103
+ r"평가(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다|받고\s*있다|받고\s*있습니다|받았다|받았습니다|받는다|받습니다)|"
104
+ r"판단(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
105
+ r"인식(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
106
+ r"간주(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
107
+ r"여겨(?:진다|집니다|졌다|졌습니다|지고\s*있다|지고\s*있습니다)|"
108
+ # --- 명사형 술어 ---
109
+ r"(?:평가|판단|인식)(?:다|이다|입니다|였다|이었다|이었습니다|이에요)|"
110
+ r"(?:라는|다는|이라는|란|는|은|인)\s*(?:평가|판단|인식)(?:다|이다|입니다|였다|이었다|이었습니다)|"
111
+ r"(?:라는|다는|이라는|란|는|은|인)?\s*(?:평가|판단|인식)(?:도\s*있다|도\s*있습니다|도\s*나온다|도\s*나옵니다|도\s*나오고\s*있다|도\s*나오고\s*있습니다)|"
112
+ r"(?:라는|다는|이라는|란|는|은|인)?\s*(?:평가|판단)(?:가\s*나온다|가\s*나옵니다|가\s*나오고\s*있다|가\s*나오고\s*있습니다|가\s*나왔다|가\s*나왔습니다|이\s*나온다|이\s*나옵니다)|"
113
+ r"(?:라는|다는)?\s*(?:평가|판단)(?:를\s*받고\s*있다|를\s*받고\s*있습니다|를\s*받았다|를\s*받았습니다|를\s*받는다|를\s*받습니다)"
114
+ r")"
115
+ ),
116
+
117
+ # =========================================================================
118
+ # 6. 비판/지적형
119
+ # =========================================================================
120
+ "비판형": re.compile(
121
+ r"(?:"
122
+ # --- 피동형 술어 ---
123
+ r"비판(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다|받고\s*있다|받고\s*있습니다|받았다|받았습니다|받는다|받습니다)|"
124
+ r"비난(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다|받고\s*있다|받고\s*있습니다|받았다|받았습니다|받는다|받습니다)|"
125
+ r"지적(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다|받고\s*있다|받고\s*있습니다|받았다|받았습니다|받는다|받습니다)|"
126
+ # --- 명사형 술어 ---
127
+ r"(?:비판|비난|지적)(?:이다|입니다|이었다|이었습니다|이에요)|"
128
+ r"(?:라는|다는|이라는|란|는|은|인)\s*(?:비판|비난|지적)(?:이다|입니다|이었다|이었습니다)|"
129
+ r"(?:라는|다는|이라는|란|는|은|인)?\s*(?:비판|비난|지적)(?:도\s*있다|도\s*있습니다|도\s*나온다|도\s*나옵니다|도\s*나오고\s*있다|도\s*나오고\s*있습니다|도\s*제기됐다|도\s*제기됐습니다)|"
130
+ r"(?:라는|다는|이라는|란|는|은|인)?\s*(?:비판|비��|지적)(?:이\s*나온다|이\s*나옵니다|이\s*나오고\s*있다|이\s*나오고\s*있습니다|이\s*나왔다|이\s*나왔습니다|이\s*제기됐다|이\s*제기됐습니다|이\s*제기되고\s*있다|이\s*제기되고\s*있습니다|이\s*쏟아지고\s*있다|이\s*쏟아지고\s*있습니다|이\s*쏟아졌다|이\s*쏟아졌습니다|이\s*잇따르고\s*있다|이\s*잇따르고\s*있습니다|이\s*잇따랐다|이\s*잇따랐습니다)|"
131
+ r"(?:비판|비난|지적)(?:을\s*받고\s*있다|을\s*받고\s*있습니다|을\s*받았다|을\s*받았습니다|을\s*받는다|을\s*받습니다|을\s*면치\s*못하고\s*있다|을\s*면치\s*못하고\s*있습니다)"
132
+ r")"
133
+ ),
134
+
135
+ # =========================================================================
136
+ # 7. 제기/거론형
137
+ # =========================================================================
138
+ "제기형": re.compile(
139
+ r"(?:"
140
+ r"제기(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
141
+ r"거론(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
142
+ r"언급(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
143
+ r"지목(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
144
+ r"논의(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
145
+ r"검토(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
146
+ r"거명(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)"
147
+ r")"
148
+ ),
149
+
150
+ # =========================================================================
151
+ # 8. 우려/의혹형
152
+ # =========================================================================
153
+ "우려형": re.compile(
154
+ r"(?:"
155
+ # 우려
156
+ r"우려(?:가|도|를)?\s*(?:있다|있습니다|나온다|나옵니다|나오고\s*있다|나오고\s*있습니다|나왔다|나왔습니다|제기됐다|제기됐습니다|제기되고\s*있다|제기되고\s*있습니다|커지고\s*있다|커지고\s*있습니다|커졌다|커졌습니다|낳고\s*있다|낳고\s*있습니다|낳았다|낳았습니다|높아지고\s*있다|높아지고\s*있습니다)|"
157
+ # 의혹
158
+ r"의혹(?:이|을|도)?\s*(?:있다|있습니다|제기됐다|제기됐습니다|제기되고\s*있다|제기되고\s*있습니다|불거졌다|불거졌습니다|불거지고\s*있다|불거지고\s*있습니다|일고\s*있다|일고\s*있습니다|일었다|일었습니다|사고\s*있다|사고\s*있습니다|샀다|샀습니다|증폭되고\s*있다|증폭되고\s*있습니다|확산되고\s*있다|확산되고\s*있습니다)|"
159
+ # 논란
160
+ r"논란(?:이|도)?\s*(?:있다|있습니다|일고\s*있다|일고\s*있습니다|일었다|일었습니다|되고\s*있다|되고\s*있습니다|됐다|됐습니다|불거졌다|불거졌습니다|불거지고\s*있다|불거지고\s*있습니다|예상된다|예상됩니다|이어지고\s*있다|이어지고\s*있습니다)|"
161
+ # 의문
162
+ r"의문(?:이|도)?\s*(?:있다|있습니다|제기됐다|제기됐습니다|제기되고\s*있다|제기되고\s*있습니다|일고\s*있다|일고\s*있습니다|남는다|남습니다|남아\s*있다|남아\s*있습니다|든다|듭니다)|"
163
+ # 명사형
164
+ r"(?:우려|의혹|논란|의문)(?:다|이다|입니다|이었다|이었습니다|이에요)|"
165
+ r"(?:라는|다는|이라는|란|는|은|인)\s*(?:우려|의혹|논란|의문)(?:다|이다|입니다|이었다|이었습니다)|"
166
+ r"(?:라는|다는)?\s*(?:우려|의혹|논란|의문)(?:도\s*있다|도\s*있습니다|도\s*나온다|도\s*나옵니다|도\s*제기됐다|도\s*제기됐습니다)"
167
+ r")"
168
+ ),
169
+
170
+ # =========================================================================
171
+ # 9. 가능성/여지형
172
+ # =========================================================================
173
+ "가능성형": re.compile(
174
+ r"(?:"
175
+ r"가능성(?:이|도|을)?\s*(?:있다|있습니다|크다|큽니다|높다|높습니다|낮다|낮습니다|제기됐다|제기됐습니다|거론되고\s*있다|거론되고\s*있습니다|점쳐지고\s*있다|점쳐지고\s*있습니다|배제할\s*수\s*없다|배제할\s*수\s*없습니다|열려\s*있다|열려\s*있습니다|열렸다|열렸습니다|제기된다|제기됩니다|나온다|나옵니다)|"
176
+ r"개연성(?:이|도)?\s*(?:있다|있습니다|크다|큽니다|높다|높습니다|낮다|낮습니다)|"
177
+ r"여지(?:가|도|를)?\s*(?:있다|있습니다|남아\s*있다|남아\s*있습니다|남겨져\s*있다|남겨져\s*있습니다|남는다|남습니다|남겼다|남겼습니다)|"
178
+ r"(?:라는|다는)?\s*(?:가능성|개연성|여지)(?:도\s*있다|도\s*있습니다|이\s*제기됐다|이\s*제기됐습니다|이\s*나온다|이\s*나옵니다)"
179
+ r")"
180
+ ),
181
+
182
+ # =========================================================================
183
+ # 10. 분위기/목소리형
184
+ # =========================================================================
185
+ "분위기형": re.compile(
186
+ r"(?:"
187
+ # 분위기
188
+ r"분위기(?:다|이다|입니다|이었다|이었습니다|가\s*감지되고\s*있다|가\s*감지되고\s*있습니다|가\s*형성되고\s*있다|가\s*형성되고\s*있습니다|가\s*확산되고\s*있다|가\s*확산되고\s*있습니다|가\s*팽배하다|가\s*팽배합니다|가\s*역력하다|가\s*역력합니다)|"
189
+ # 목소리
190
+ r"목소리(?:가|도)?\s*(?:나온다|나옵니다|나오고\s*있다|나오고\s*있습니다|나왔다|나왔습니다|높아지고\s*있다|높아지고\s*있습니다|높아졌다|높아졌습니다|커지고\s*있다|커지고\s*있습니다|커졌다|커졌습니다|있다|있습니다)|"
191
+ # 기대
192
+ r"기대(?:가|를|도)?\s*(?:모아지고\s*있다|모아지고\s*있습니다|모이고\s*있다|모이고\s*있습니다|높아지고\s*있다|높아지고\s*있습니다|커지고\s*있다|커지고\s*있습니다|크다|큽니다|높다|높습니다)|"
193
+ # 관심/이목
194
+ r"(?:관심|이목)(?:이|을)?\s*(?:쏠리고\s*있다|쏠리고\s*있습니다|쏠렸다|쏠렸습니다|집중되고\s*있다|집중되고\s*있습니다|집중됐다|집중됐습니다|모아지고\s*있다|모아지고\s*있습니다)|"
195
+ # 기류/조짐/흐름
196
+ r"(?:기류|조짐|흐름|양상)(?:이|가)?\s*(?:감지되고\s*있다|감지되고\s*있습니다|감지됐다|감지됐습니다|포착되고\s*있다|포착되고\s*있습니다|나타나고\s*있다|나타나고\s*있습니다)"
197
+ r")"
198
+ ),
199
+
200
+ # =========================================================================
201
+ # 11. 주장/입장형
202
+ # =========================================================================
203
+ "주장형": re.compile(
204
+ r"(?:"
205
+ # --- 피동형 술어 ---
206
+ r"주장(?:된다|됩니다|됐다|됐습니다|되고\s*있다|되고\s*있습니다)|"
207
+ # --- 명사형 술어 ---
208
+ r"(?:주장|입장|방침|계획|생각|확신|설명|해명)(?:이다|입니다|이었다|이었습니다|이에요)|"
209
+ r"(?:라는|다는|이라는|란|는|은|인)\s*(?:주장|입장|방침|계획|생각|확신|설명|해명)(?:이다|입니다|이었다|이었습니다)|"
210
+ r"(?:라는|다는|이라는|란|는|은|인)?\s*(?:주장|입장)(?:도\s*있다|도\s*있습니다|도\s*나온다|도\s*나옵니다|도\s*나오고\s*있다|도\s*나오고\s*있습니다)|"
211
+ r"(?:라는|다는|이라는|란|는|은|인)?\s*(?:주장|입장|설명|해명)(?:이\s*나온다|이\s*나옵니다|이\s*나오고\s*있다|이\s*나오고\s*있습니다|이\s*나왔다|이\s*나왔습니다)"
212
+ r")"
213
+ ),
214
+
215
+ # =========================================================================
216
+ # 12. 시각/견해형
217
+ # =========================================================================
218
+ "시각형": re.compile(
219
+ r"(?:"
220
+ # 단독 사용
221
+ r"(?:시각|견해|관점|자평)(?:이다|다|입니다|이었다|였다|이었습니다|이에요)|"
222
+ # ~라는 + 명사
223
+ r"(?:라는|다는|이라는|란|는|은|인)\s*(?:시각|견해|관점|인식|자평)(?:이다|다|입니다|이었다|였다|이었습니다)|"
224
+ # 명사 + 도 있다/지배적이다
225
+ r"(?:라는|다는|이라는|란|는|은|인)?\s*(?:시각|견해|관점|인식)(?:도\s*있다|도\s*있습니다|도\s*나온다|도\s*나옵니다|이\s*있다|이\s*있습니다|이\s*나온다|이\s*나옵니다|이\s*지배적이다|이\s*지배적입니다|가\s*지배적이다|가\s*지배적입니다|이\s*우세하다|이\s*우세합니다|가\s*우세하다|가\s*우세합니다)"
226
+ r")"
227
+ ),
228
+
229
+ # =========================================================================
230
+ # 13. 격찬/혹평형 (극단적 평가)
231
+ # =========================================================================
232
+ "격찬형": re.compile(
233
+ r"(?:"
234
+ r"(?:격찬|찬사|호평)(?:이|을)?\s*(?:쏟아졌다|쏟아졌습니다|쏟아지고\s*있다|쏟아지고\s*있습니다|이어졌다|이어졌습니다|이어지고\s*있다|이어지고\s*있습니다|나왔다|나왔습니다|나오고\s*있다|나오고\s*있습니다|받았다|받았습니다|받고\s*있다|받고\s*있습니다)|"
235
+ r"(?:혹평|악평)(?:이|을)?\s*(?:쏟아졌다|쏟아졌습니다|쏟아지고\s*있다|쏟아지고\s*있습니다|이어졌다|이어졌습니다|이어지고\s*있다|이어지고\s*있습니다|나왔다|나왔습니다|나오고\s*있다|나오고\s*있습니다|받았다|받았습니다|받고\s*있다|받고\s*있습니다)|"
236
+ r"(?:라는|다는)?\s*(?:격찬|찬사|호평|혹평|악평)(?:이다|입니다|이었다|이었습니다)"
237
+ r")"
238
+ ),
239
+
240
+ # =========================================================================
241
+ # 15. 관용표현형
242
+ # =========================================================================
243
+ "관용표현형": re.compile(
244
+ r"(?:"
245
+ # ~한 셈이다
246
+ r"[은는인된한했던]\s*셈(?:이다|입니다|이에요|이었다|이었습니다)|"
247
+ r"셈(?:이다|입니다|이에요|이었다|이었습니다)|"
248
+ # ~해야 할 판이다
249
+ r"(?:해야\s*할|하게\s*된|하게\s*됐)\s*판(?:이다|입니다|이에요)|"
250
+ # ~로 보인다/여겨진다/비춰진다
251
+ r"(?:으로|로)\s*(?:보인다|보입니다|보여진다|보여집니다|보이고\s*있다|보이고\s*있습니다)|"
252
+ r"(?:으로|로)\s*(?:여겨진다|여겨집집니다|여겨지고\s*있다|여겨지고\s*있습니다)|"
253
+ r"(?:으로|로)\s*(?:비춰진다|비춰집니다|비쳐진다|비쳐집니다|비쳐지고\s*있다|비쳐지고\s*있습니다)|"
254
+ r"(?:으로|로)\s*(?:받아들여지고\s*있다|받아들여지고\s*있습니다|받아들여진다|받아들여집니다|받아들여졌다|받아들여졌습니다)|"
255
+ # ~것 아니냐는/~지 않겠느냐는
256
+ r"(?:는|은)\s*것\s*아니(?:냐는|냐고|겠냐는|냐며)|"
257
+ r"(?:지|치)\s*않(?:겠느냐는|을까\s*하는|을까\s*싶은|느냐는)|"
258
+ # ~가 아닌가 싶다
259
+ r"(?:가|이)\s*아닌가\s*(?:싶다|싶습니다|하는|하다|합니다)|"
260
+ # ~를 짐작하게/케 한다
261
+ r"(?:을|를)?\s*짐작(?:하게|케)\s*(?:한다|합니다|했다|했습니다)"
262
+ r")"
263
+ ),
264
+
265
+ # =========================================================================
266
+ # 16. 완화표현형 (Hedges)
267
+ # =========================================================================
268
+ "완화표현형": re.compile(
269
+ r"(?:"
270
+ # 것으로 + 술어
271
+ r"것으로\s*(?:보인다|보입니다|보여진다|보여집니다)|"
272
+ r"것으로\s*(?:추정된다|추정됩니다|추정되고\s*있다|추정되고\s*있습니다)|"
273
+ r"것으로\s*(?:판단된다|판단됩니다|판단되고\s*있다|판단되고\s*있습니다)|"
274
+ r"것으로\s*(?:분석된다|분석됩니다|분석되고\s*있다|분석되고\s*있습니다)|"
275
+ r"것으로\s*(?:예상된다|예상됩니다|예상되고\s*있다|예상되고\s*있습니다)|"
276
+ r"것으로\s*(?:전망된다|전망됩니다|전망되고\s*있다|전망되고\s*있습니다)|"
277
+ r"것으로\s*(?:관측된다|관측됩니다|관측되고\s*있다|관측되고\s*있습니다)|"
278
+ r"것으로\s*(?:평가된다|평가됩니다|평가되고\s*있다|평가되고\s*있습니다)|"
279
+ r"것으로\s*(?:풀이된다|풀이됩니다|풀이되고\s*있다|풀이되고\s*있습니다)|"
280
+ r"것으로\s*(?:해석된다|해석됩니다|해석되고\s*있다|해석되고\s*있습니다)|"
281
+ r"것으로\s*(?:파악된다|파악됩니다|파악되고\s*있다|파악되고\s*있습니다)|"
282
+ r"것으로\s*(?:나타났다|나타났습니다|나타나고\s*있다|나타나고\s*있습니다)|"
283
+ # 듯 + 술어
284
+ r"듯\s*(?:보인다|보입니다|하다|합니다|싶다|싶습니다)|"
285
+ # ~지도 모른다
286
+ r"[을를]지도?\s*모른(?:다|릅니다)|"
287
+ # ~ㄹ 것 같다
288
+ r"[을를]\s*것\s*같(?:다|습니다)"
289
+ r")"
290
+ ),
291
+ }
292
+
293
+
294
+ # =============================================================================
295
+ # II. 객관성 지지 술어 (SUPPORT):
296
+ # =============================================================================
297
+
298
+ SUPPORT_PREDICATES: Dict[str, Pattern] = {
299
+
300
+ # =========================================================================
301
+ # 1. 확인/검증형
302
+ # =========================================================================
303
+ "확인형": re.compile(
304
+ r"(?:"
305
+ # 확인/밝혀지다/드러나다
306
+ r"확인(?:됐다|됐습니다|된다|됩니다|되고\s*있다|되고\s*있습니다|했다|했습니다|한다|합니다)|"
307
+ r"밝혀(?:졌다|졌습니다|진다|집니다|지고\s*있다|지고\s*있습니다)|"
308
+ r"드러(?:났다|났습니다|난다|납니다|나고\s*있다|나고\s*있습니다)|"
309
+ r"판명(?:됐다|됐습니다|된다|됩니다|났다|났습니다)|"
310
+ r"입증(?:됐다|됐습니다|된다|됩니다|되고\s*있다|되고\s*있습니다|했다|했습니다)|"
311
+ r"규명(?:됐다|됐습니다|된다|됩니다|되고\s*있다|되고\s*있습니다|했다|했습니다)|"
312
+ r")"
313
+ ),
314
+
315
+ # =========================================================================
316
+ # 2. 발견/탐지형
317
+ # =========================================================================
318
+ "발견형": re.compile(
319
+ r"(?:"
320
+ r"발견(?:됐다|됐습니다|된다|됩니다|되고\s*있다|되고\s*있습니다|했다|했습니다|한다|합니다)|"
321
+ r"발각(?:됐다|됐습니다|된다|됩니다)|"
322
+ r"적발(?:됐다|됐습니다|된다|됩니다|되고\s*있다|되고\s*있습니다|했다|했습니다)|"
323
+ r"포착(?:됐다|됐습니다|된다|됩니다|되고\s*있다|되고\s*있습니다|했다|했습니다)|"
324
+ r"감지(?:됐다|됐습니다|된다|됩니다|되고\s*있다|되고\s*있습니다|했다|했습니다)|"
325
+ r"파악(?:됐다|됐습니다|된다|됩니다|되고\s*있다|되고\s*있습니다|했다|했습니다|한다|합니다)"
326
+ r")"
327
+ ),
328
+
329
+ # =========================================================================
330
+ # 3. 기록/집계형
331
+ # =========================================================================
332
+ "기록형": re.compile(
333
+ r"(?:"
334
+ r"기록(?:됐다|됐습니다|된다|됩니다|되고\s*있다|되고\s*있습니다|했다|했습니다|한다|합니다)|"
335
+ r"집계(?:됐다|됐습니다|된다|됩니다|되고\s*있다|되고\s*있습니다|했다|했습니다|한다|합니다)|"
336
+ r"조사(?:됐다|됐습니다|된다|됩니다|되고\s*있다|되고\s*있습니다|했다|했습니다|한다|합니다)|"
337
+ r"측정(?:됐다|됐습니다|된다|됩니다|되고\s*있다|되고\s*있습니다|했다|했습니다|한다|합니다)|"
338
+ r"나타(?:났다|났습니다|난다|납니다)"
339
+ r")"
340
+ ),
341
+ }
342
+
343
+
344
+ # =============================================================================
345
+ # 유틸리티 함수
346
+ # =============================================================================
347
+
348
+ def analyze_objectivity(text: str) -> Dict[str, Any]:
349
+ """텍스트를 문장 단위로 나누어 객관성 의심/지지 요소를 분석"""
350
+ splitter = KoreanSentenceSplitter()
351
+ sentences = splitter.split(text)
352
+
353
+ doubt_predicates = []
354
+ support_predicates = []
355
+ doubt_sentences = []
356
+ support_sentences = []
357
+
358
+ doubt_sentence_count = 0
359
+ support_sentence_count = 0
360
+
361
+ for sent in sentences:
362
+ sent_doubt_matches = []
363
+ sent_support_matches = []
364
+
365
+ # 중복 방지를 위한 스팬(Span) 기반 체크
366
+ doubt_spans = {}
367
+ support_spans = {}
368
+
369
+ for _, pattern in DOUBT_PREDICATES.items():
370
+ for match in pattern.finditer(sent):
371
+ m_text = match.group(0).strip()
372
+ if m_text:
373
+ doubt_spans[match.span()] = m_text
374
+
375
+ for _, pattern in SUPPORT_PREDICATES.items():
376
+ for match in pattern.finditer(sent):
377
+ m_text = match.group(0).strip()
378
+ if m_text:
379
+ support_spans[match.span()] = m_text
380
+
381
+ # 문장 내 추출 결과 정리
382
+ if doubt_spans:
383
+ sorted_doubt = [doubt_spans[s] for s in sorted(doubt_spans.keys())]
384
+ doubt_predicates.extend(sorted_doubt)
385
+ doubt_sentences.append(sent)
386
+ doubt_sentence_count += 1
387
+
388
+ if support_spans:
389
+ sorted_support = [support_spans[s] for s in sorted(support_spans.keys())]
390
+ support_predicates.extend(sorted_support)
391
+ support_sentences.append(sent)
392
+ support_sentence_count += 1
393
+
394
+ total_sentences = doubt_sentence_count + support_sentence_count
395
+
396
+ return {
397
+ "doubt_predicates": doubt_predicates,
398
+ "support_predicates": support_predicates,
399
+ "doubt_sentences": doubt_sentences,
400
+ "support_sentences": support_sentences,
401
+ "doubt_count": doubt_sentence_count,
402
+ "support_count": support_sentence_count,
403
+ "objectivity_ratio": round(support_sentence_count / total_sentences, 4) if total_sentences > 0 else None
404
+ }
405
+
406
+
407
+ def find_doubt_predicates(text: str) -> Dict[str, List[str]]:
408
+ """객관성 의심 술어만 반환"""
409
+ results = {}
410
+ for category, pattern in DOUBT_PREDICATES.items():
411
+ matches = pattern.findall(text)
412
+ if matches:
413
+ results[category] = matches
414
+ return results
415
+
416
+
417
+ def find_support_predicates(text: str) -> Dict[str, List[str]]:
418
+ """객관성 지지 술어만 반환"""
419
+ results = {}
420
+ for category, pattern in SUPPORT_PREDICATES.items():
421
+ matches = pattern.findall(text)
422
+ if matches:
423
+ results[category] = matches
424
+ return results
425
+
426
+
427
+ def print_pattern_summary():
428
+ """패턴 요약 출력"""
429
+ print("=" * 70)
430
+ print("언론 보도 객관성 측정용 무주체 술어 정규표현식 v2")
431
+ print("=" * 70)
432
+ print()
433
+ print("I. 객관성 의심 술어 (DOUBT) - 무주체 주관적 술어")
434
+ print("-" * 70)
435
+ for i, (name, _) in enumerate(DOUBT_PREDICATES.items(), 1):
436
+ print(f" {i:2d}. {name}")
437
+ print(f"\n 총 {len(DOUBT_PREDICATES)}개 카테고리")
438
+ print()
439
+ print("II. 객관성 지지 술어 (SUPPORT) - 사실 확인/명시적 출처")
440
+ print("-" * 70)
441
+ for i, (name, _) in enumerate(SUPPORT_PREDICATES.items(), 1):
442
+ print(f" {i:2d}. {name}")
443
+ print(f"\n 총 {len(SUPPORT_PREDICATES)}개 카테고리")
444
+ print("=" * 70)
445
+
446
+
447
+ if __name__ == "__main__":
448
+ print_pattern_summary()