1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
//! 세종 코퍼스 형식 변환기
use std::collections::HashMap;
use crate::tokenizer::Token;
use super::corrections::apply_context_corrections;
use super::ending_rules::init_ending_rules;
use super::hangul::normalize_jamo as hangul_normalize_jamo;
use super::lexicon::apply_lexicon_overrides;
use super::postprocess::{
apply_decomposition_corrections, apply_token_merges, apply_vv_seyo_splits,
};
use super::splitter::{is_compound_tag, split_compound_tag, split_morpheme};
use super::tag_map::tag_map;
use super::types::{DecomposedMorpheme, EndingRule, SejongToken};
/// 세종 코퍼스 형식 변환기
pub struct SejongConverter {
/// 품사 태그 매핑 테이블 (복합 → 분리) — 전역 정적 참조
tag_map: &'static HashMap<String, Vec<String>>,
/// 어미 분리 규칙
ending_rules: Vec<EndingRule>,
/// 분석결과 컬럼 사용 여부 (불규칙 활용 지원)
use_decomposition: bool,
}
impl Default for SejongConverter {
fn default() -> Self {
Self::new()
}
}
impl SejongConverter {
/// 기본 설정으로 변환기 생성
#[must_use]
pub fn new() -> Self {
Self {
tag_map: tag_map(),
ending_rules: init_ending_rules(),
use_decomposition: true, // 기본값: 분석결과 컬럼 활용
}
}
/// 분석결과 사용 여부 설정
///
/// `true`이면 mecab-ko-dic의 12번째 컬럼(분석결과)을 우선 사용합니다.
/// 불규칙 활용을 정확하게 처리하려면 `true`로 설정하세요.
#[must_use]
pub const fn with_decomposition(mut self, use_decomposition: bool) -> Self {
self.use_decomposition = use_decomposition;
self
}
/// 분석결과 컬럼에서 형태소 분해 정보 파싱
///
/// 형식: `stem/POS/*+ending/POS/*+...`
/// 예시: `가깝/VA/*+아/EC/*` → [("가깝", "VA"), ("아", "EC")]
#[must_use]
pub fn parse_decomposition(decomposition: &str) -> Vec<DecomposedMorpheme> {
if decomposition.is_empty() || decomposition == "*" {
return Vec::new();
}
let mut result = Vec::new();
// '+' 로 분리하여 각 형태소 파싱
for part in decomposition.split('+') {
let part = part.trim();
if part.is_empty() {
continue;
}
// 형식: surface/POS/* 또는 surface/POS
let segments: Vec<&str> = part.split('/').collect();
if segments.len() >= 2 {
let surface = segments[0].to_string();
let pos = segments[1].to_string();
// 빈 표면형이나 '*' 는 스킵
if !surface.is_empty() && surface != "*" && !pos.is_empty() && pos != "*" {
result.push(DecomposedMorpheme { surface, pos });
}
}
}
result
}
/// feature 문자열에서 분析결과(12번째 컬럼) 추출
///
/// mecab-ko-dic CSV 형식:
/// `품사,의미분류,종성,읽기,타입,첫품사,끝품사,분석결과`
/// (0~7, 총 8개 필드이지만 인덱스 7이 분析결과)
#[must_use]
pub fn extract_decomposition(features: &str) -> Option<String> {
let fields: Vec<&str> = features.split(',').collect();
// 분析결과는 8번째 필드 (인덱스 7) 또는 그 이후
// Inflect 타입의 경우 인덱스 7에 분析결과가 있음
if fields.len() >= 8 {
let decomp = fields[7].trim();
if !decomp.is_empty() && decomp != "*" {
return Some(decomp.to_string());
}
}
None
}
// 166차: is_compound_token 함수 제거
// 복합어 분리는 세종 코퍼스 표준에 맞게 선별적으로 적용 필요
// 현재는 복합어를 분리하지 않음
/// 복합 품사 태그인지 확인
#[must_use]
pub fn is_compound_tag(&self, pos: &str) -> bool {
is_compound_tag(pos)
}
/// 복합 품사 태그를 분리된 태그 목록으로 변환
#[must_use]
pub fn split_compound_tag(&self, pos: &str) -> Vec<String> {
split_compound_tag(self.tag_map, pos)
}
/// 표면형에서 어미를 분리
///
/// # Arguments
/// * `surface` - 표면형 (예: "갔다")
/// * `pos` - 품사 태그 (예: "VV+EF")
///
/// # Returns
/// 분리된 (표면형, 품사) 쌍의 벡터
#[must_use]
pub fn split_morpheme(&self, surface: &str, pos: &str) -> Vec<(String, String)> {
split_morpheme(surface, pos, self.tag_map, &self.ending_rules)
}
/// 토큰을 세종 형식으로 변환
///
/// 변환 우선순위:
/// 1. 분析결과 컬럼 사용 (`use_decomposition=true`, features에 분析결과 있는 경우)
/// 2. 규칙 기반 어미 분리 (`ending_rules`)
/// 3. 태그만 분리 (복합 태그인 경우)
/// 4. 그대로 반환 (단순 태그인 경우)
#[must_use]
pub fn convert_token(&self, token: &Token) -> Vec<SejongToken> {
// 143차: "는다/VV+EC"는 사전 분析결과가 잘못됨 (늘/VV+ㄴ다/EC)
// 규칙 기반으로 직접 처리: "는다/VV+EC" → "는다/EF"
let skip_decomposition = token.surface == "는다" && token.pos == "VV+EC";
// 166차: Compound 분리 보류
// 일부 복합어(밤낮)는 분리해야 하지만 대부분(인공지능, 서울특별시)은 분리하면 안 됨
// 세종 코퍼스 표준에 따라 선별적 적용 필요 - 현재는 비활성화
// 212차: 특수 동사는 분析결과 무시하고 규칙 기반 사용
// "들리다", "놀리다" 등은 VV+EF로 처리해야 함 (VV+VX+EF 아님)
let skip_decomp_verbs = ["들리다", "놀리다"];
let force_rule_based =
token.pos == "VV+EF" && skip_decomp_verbs.contains(&token.surface.as_str());
// 1. 분析결과 컬럼 활용 시도
if self.use_decomposition
&& !token.features.is_empty()
&& !skip_decomposition
&& !force_rule_based
{
if let Some(decomp) = Self::extract_decomposition(&token.features) {
let morphemes = Self::parse_decomposition(&decomp);
if !morphemes.is_empty() {
// 분析결과의 POS 태그 구조가 토큰 POS와 일치하는지 검증
let decomp_pos: String = morphemes
.iter()
.map(|m| m.pos.as_str())
.collect::<Vec<_>>()
.join("+");
if decomp_pos == token.pos {
// 245차 보정: 단일 형태소의 경우 표면형이 변경되면 decomposition 무시
if morphemes.len() == 1 && morphemes[0].surface != token.surface {
// 표면형이 다르면 decomposition 무시
} else {
return Self::morphemes_to_sejong_tokens(&morphemes, token);
}
}
// POS 구조가 일치하지 않으면 규칙 기반으로 폴백
}
}
}
// 2. 규칙 기반 어미 분리
let morphemes = self.split_morpheme(&token.surface, &token.pos);
if morphemes.len() == 1 {
// 분리되지 않은 경우
return vec![SejongToken::new(
&token.surface,
&morphemes[0].1,
token.start_pos,
token.end_pos,
)];
}
// 분리된 경우
let mut result = Vec::new();
let mut current_pos = token.start_pos;
for (surface, pos) in &morphemes {
let char_len = surface.chars().count();
let end_pos = current_pos + char_len;
result.push(SejongToken::from_split(
surface,
pos,
current_pos,
end_pos,
&token.surface,
&token.pos,
));
current_pos = end_pos;
}
result
}
/// 분해된 형태소를 `SejongToken`으로 변환
fn morphemes_to_sejong_tokens(
morphemes: &[DecomposedMorpheme],
original_token: &Token,
) -> Vec<SejongToken> {
let mut result = Vec::new();
let mut current_pos = original_token.start_pos;
for morpheme in morphemes {
let char_len = morpheme.surface.chars().count();
let end_pos = current_pos + char_len;
result.push(SejongToken::from_split(
&morpheme.surface,
&morpheme.pos,
current_pos,
end_pos,
&original_token.surface,
&original_token.pos,
));
current_pos = end_pos;
}
result
}
/// 토큰 목록을 세종 형식으로 변환
#[must_use]
pub fn convert_tokens(&self, tokens: &[Token]) -> Vec<SejongToken> {
let mut sejong_tokens: Vec<SejongToken> =
tokens.iter().flat_map(|t| self.convert_token(t)).collect();
// 잘못된 분해 패턴 보정 (갔다오/VV + ㄴ/ETM → 갔/VV + 다/EF)
apply_decomposition_corrections(&mut sejong_tokens);
// 잘못 분해된 토큰 병합 (친/VV + 구와/NNG → 친구/NNG + 와/JC)
apply_token_merges(&mut sejong_tokens);
// 고빈도 어휘 강제 매핑 (문맥 무관)
apply_lexicon_overrides(&mut sejong_tokens);
// VV "세요" 패턴 분리 (가세요/VV → 가/VV + 세요/EF)
sejong_tokens = apply_vv_seyo_splits(sejong_tokens);
// 컨텍스트 기반 품사 보정
apply_context_corrections(&mut sejong_tokens);
sejong_tokens
}
/// 세종 형식 문자열로 변환 (자모 정규화 포함)
#[must_use]
pub fn format_sejong(&self, tokens: &[SejongToken]) -> String {
tokens
.iter()
.map(|t| {
let normalized_surface = hangul_normalize_jamo(&t.surface);
format!("{}/{}", normalized_surface, t.pos)
})
.collect::<Vec<_>>()
.join(" ")
}
/// 토큰을 세종 형식 문자열로 직접 변환
#[must_use]
pub fn tokens_to_sejong_string(&self, tokens: &[Token]) -> String {
let sejong_tokens = self.convert_tokens(tokens);
self.format_sejong(&sejong_tokens)
}
/// 한글 자모 정규화 (외부 호환성 유지)
///
/// ㅏ, ㅓ, ㅣ 등 자모가 포함된 문자열을 완성형으로 변환합니다.
#[must_use]
pub fn normalize_jamo(text: &str) -> String {
hangul_normalize_jamo(text)
}
#[cfg(test)]
#[must_use]
pub(crate) fn split_prefinal_ending(ending: &str) -> (String, String) {
super::splitter::split_prefinal_ending(ending)
}
}