/*
* Copyright 2011-2013 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.analysis.kr.morph;
import org.apache.lucene.analysis.kr.utils.DictionaryUtil;
import org.apache.lucene.analysis.kr.utils.MorphUtil;
import org.apache.lucene.analysis.kr.utils.SyllableUtil;
import org.apache.lucene.analysis.kr.utils.VerbUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
/** @author smlee */
public class WordSpaceAnalyzer {
private static final Logger log = LoggerFactory.getLogger(WordSpaceAnalyzer.class);
private MorphAnalyzer morphAnal;
public WordSpaceAnalyzer() {
morphAnal = new MorphAnalyzer();
morphAnal.setExactCompound(false);
}
public List<AnalysisOutput> analyze(String input) throws MorphException {
log.trace("단어를 분석합니다. input=[{}]", input);
//List stack = new ArrayList();
WSOutput output = new WSOutput();
int wStart = 0;
int sgCount = -9;
Map<Integer, Integer> fCounter = new HashMap<Integer, Integer>();
for (int i = 0; i < input.length(); i++) {
char[] f = SyllableUtil.getFeature(input.charAt(i));
String prefix = i == input.length() - 1 ? "X" : input.substring(wStart, i + 2);
Iterator iter = DictionaryUtil.findWithPrefix(prefix);
List<AnalysisOutput> candidates = new ArrayList<AnalysisOutput>();
WordEntry entry = null;
if (input.charAt(i) == '있' || input.charAt(i) == '없' || input.charAt(i) == '앞') {
addSingleWord(input.substring(wStart, i), candidates);
// 다음 음절이 2음절 이상 단어에 포함되어 있고 마지막 음절이 아니라면 띄워쓰기 위치가 아닐 가능성이 크다.
// 부사, 관형사, 감탄사 등 단일어일 가능성인 경우 띄워쓰기가 가능하나,
// 이 경우는 다음 음절을 조사하여
} else if (i != input.length() - 1 && iter.hasNext()) {
// 아무짓도 하지 않음.
sgCount = i;
} else if (!iter.hasNext() &&
(entry = DictionaryUtil.getBusa(input.substring(wStart, i + 1))) != null) {
candidates.add(buildSingleOutput(entry));
// 현 음절이 조사나 어미가 시작되는 음절일 가능성이 있다면...
} else if (f[SyllableUtil.IDX_EOGAN] == '1' || f[SyllableUtil.IDX_JOSA1] == '1') {
if (f[SyllableUtil.IDX_JOSA1] == '1')
candidates.addAll(anlysisWithJosa(input.substring(wStart), i - wStart));
if (f[SyllableUtil.IDX_EOGAN] == '1')
candidates.addAll(anlysisWithEomi(input.substring(wStart), i - wStart));
}
// 호보가 될 가능성이 높은 순으로 정렬한다.
Collections.sort(candidates, new WSOuputComparator());
// 길이가 가장 긴 단어를 단일어로 추가한다.
appendSingleWord(candidates);
// 분석에 실패한 단어를
analysisCompouns(candidates);
// 호보가 될 가능성이 높은 순으로 정렬한다.
Collections.sort(candidates, new WSOuputComparator());
int reseult = validationAndAppend(output, candidates, input);
if (reseult == 1) {
i = output.getLastEnd() - 1;
wStart = output.getLastEnd();
} else if (reseult == -1) {
Integer index = fCounter.get(output.getLastEnd());
if (index == null) index = output.getLastEnd();
else index = index + 1;
i = index;
wStart = output.getLastEnd();
fCounter.put(output.getLastEnd(), index);
}
}
// 분석에 실패하였다면 원래 문자열을 되돌려 준다.
if (output.getLastEnd() < input.length()) {
String source = input.substring(output.getLastEnd());
int score = DictionaryUtil.getWord(source) == null ? AnalysisOutput.SCORE_ANALYSIS : AnalysisOutput.SCORE_CORRECT;
AnalysisOutput o = new AnalysisOutput(source, null, null, PatternConstants.POS_NOUN,
PatternConstants.PTN_N, score);
o.setSource(source);
output.getPhrases().add(o);
morphAnal.confirmCNoun(o);
}
return output.getPhrases();
}
/**
* 조사로 끝나는 어구를 분석한다.
*
* @throws org.apache.lucene.analysis.kr.morph.MorphException
*
*/
private List<AnalysisOutput> anlysisWithJosa(String snipt, int js) throws MorphException {
log.trace("조사로 끝나는 어구를 분석한다. snipt=[{}], js=[{}]", snipt, js);
List<AnalysisOutput> candidates = new ArrayList<AnalysisOutput>();
if (js < 1) return candidates;
int jend = findJosaEnd(snipt, js);
if (jend == -1) return candidates; // 타당한 조사가 아니라면...
String input = snipt.substring(0, jend);
boolean josaFlag = true;
for (int i = input.length() - 1; i > 0; i--) {
String stem = input.substring(0, i);
String josa = input.substring(i);
char[] feature = SyllableUtil.getFeature(josa.charAt(0));
if (josaFlag && feature[SyllableUtil.IDX_JOSA1] == '1') {
morphAnal.analysisWithJosa(stem, josa, candidates);
}
if (josaFlag && feature[SyllableUtil.IDX_JOSA2] == '0')
josaFlag = false;
if (!josaFlag) break;
}
if (input.length() == 1) {
AnalysisOutput o = new AnalysisOutput(input, null, null, PatternConstants.POS_NOUN,
PatternConstants.PTN_N, AnalysisOutput.SCORE_ANALYSIS);
candidates.add(o);
}
fillSourceString(input, candidates);
return candidates;
}
/**
* 조사의 첫음절부터 조사의 2음절이상에 사용될 수 있는 음절을 조사하여
* 가장 큰 조사를 찾는다.
*
* @throws org.apache.lucene.analysis.kr.morph.MorphException
*
*/
private int findJosaEnd(String snipt, int jstart) throws MorphException {
int jend = jstart;
// [것을]이 명사를 이루는 경우는 없다.
if (snipt.charAt(jstart - 1) == '것' && (snipt.charAt(jstart) == '을')) return jstart + 1;
if (snipt.length() > jstart + 2 && snipt.charAt(jstart + 1) == '스') { // 사랑스러운, 자랑스러운 같은 경우르 처리함.
char[] chrs = MorphUtil.decompose(snipt.charAt(jstart + 2));
if (chrs.length >= 2 && chrs[0] == 'ㄹ' && chrs[1] == 'ㅓ') return -1;
}
// 조사의 2음절로 사용될 수 마지막 음절을 찾는다.
for (int i = jstart + 1; i < snipt.length(); i++) {
char[] f = SyllableUtil.getFeature(snipt.charAt(i));
if (f[SyllableUtil.IDX_JOSA2] == '0') break;
jend = i;
}
int start = jend;
boolean hasJosa = false;
for (int i = start; i >= jstart; i--) {
String str = snipt.substring(jstart, i + 1);
if (DictionaryUtil.existJosa(str) && !findNounWithinStr(snipt, i, i + 2) &&
!isNounPart(snipt, jstart)) {
jend = i;
hasJosa = true;
break;
}
}
return (!hasJosa) ? -1 : jend + 1;
}
/** 향후 계산이나 원 문자열을 보여주기 위해 source string 을 저장한다. */
private void fillSourceString(final String source, List<AnalysisOutput> candidates) {
for (final AnalysisOutput o : candidates) {
o.setSource(source);
}
}
/** 목록의 1번지가 가장 큰 길이를 가진다. */
private void appendSingleWord(List<AnalysisOutput> candidates) throws MorphException {
if (candidates.size() == 0) return;
String source = candidates.get(0).getSource();
WordEntry entry = DictionaryUtil.getWordExceptVerb(source);
if (entry != null) {
candidates.add(buildSingleOutput(entry));
} else {
if (candidates.get(0).getPatn() > PatternConstants.PTN_VM &&
candidates.get(0).getPatn() <= PatternConstants.PTN_VMXMJ) return;
if (source.length() < 5) return;
AnalysisOutput o = new AnalysisOutput(source, null, null, PatternConstants.POS_NOUN,
PatternConstants.PTN_N, AnalysisOutput.SCORE_ANALYSIS);
o.setSource(source);
morphAnal.confirmCNoun(o);
if (o.getScore() == AnalysisOutput.SCORE_CORRECT) candidates.add(o);
}
}
private void addSingleWord(final String source, List<AnalysisOutput> candidates) throws MorphException {
WordEntry entry = DictionaryUtil.getWordExceptVerb(source);
if (entry != null) {
candidates.add(buildSingleOutput(entry));
} else {
AnalysisOutput o = new AnalysisOutput(source, null, null, PatternConstants.POS_NOUN,
PatternConstants.PTN_N, AnalysisOutput.SCORE_ANALYSIS);
o.setSource(source);
morphAnal.confirmCNoun(o);
candidates.add(o);
}
// Collections.sort(candidates, new WSOuputComparator());
}
private List anlysisWithEomi(String snipt, int estart) throws MorphException {
log.trace("어미를 분석합니다. snipt=[{}], estart=[{}]", snipt, estart);
List<AnalysisOutput> candidates = new ArrayList<AnalysisOutput>();
int eend = findEomiEnd(snipt, estart);
// 동사앞에 명사분리
int vstart = 0;
for (int i = estart - 1; i >= 0; i--) {
Iterator iter = DictionaryUtil.findWithPrefix(snipt.substring(i, estart));
if (iter.hasNext()) vstart = i;
else break;
}
if (snipt.length() > eend &&
DictionaryUtil.findWithPrefix(snipt.substring(vstart, eend + 1)).hasNext())
return candidates; // 다음음절까지 단어의 일부라면.. 분해를 안한다.
String pvword = null;
if (vstart != 0) pvword = snipt.substring(0, vstart);
List<String> eomiList = new ArrayList<String>();
Collections.addAll(eomiList, "ㄴ", "ㄹ", "ㅁ");
while (true) { // ㄹ,ㅁ,ㄴ 이기때문에 어미위치를 뒤로 잡았는데, 용언+어미의 형태가 아니라면.. 어구 끝을 하나 줄인다.
String input = snipt.substring(vstart, eend);
anlysisWithEomiDetail(input, candidates);
if (candidates.size() == 0) break;
AnalysisOutput output = candidates.get(0);
String eomi = output.getEomi();
boolean isEomi = eomiList.contains(eomi);
if (isEomi && eend > estart + 1 && output.getPatn() != PatternConstants.PTN_VM &&
candidates.get(0).getPatn() != PatternConstants.PTN_NSM) {
eend--;
} else if (pvword != null && output.getPatn() >= PatternConstants.PTN_VM && // 명사 + 용언 어구 중에.. 용언어구로 단어를 이루는 경우는 없다.
output.getPatn() <= PatternConstants.PTN_VMXMJ && DictionaryUtil.getWord(input) != null) {
candidates.clear();
break;
} else if (pvword != null && VerbUtil.verbSuffix(output.getStem())
&& DictionaryUtil.getNoun(pvword) != null) { // 명사 + 용언화 접미사 + 어미 처리
candidates.clear();
anlysisWithEomiDetail(snipt.substring(0, eend), candidates);
pvword = null;
break;
} else {
break;
}
}
if (candidates.size() > 0 && pvword != null) {
AnalysisOutput o = new AnalysisOutput(pvword, null, null, PatternConstants.POS_NOUN,
PatternConstants.PTN_N, AnalysisOutput.SCORE_ANALYSIS);
morphAnal.confirmCNoun(o);
List<CompoundEntry> cnouns = o.getCNounList();
if (cnouns.size() == 0) {
boolean is = DictionaryUtil.getWordExceptVerb(pvword) != null;
cnouns.add(new CompoundEntry(pvword, 0, is));
}
for (AnalysisOutput candidate : candidates) {
candidate.getCNounList().addAll(cnouns);
candidate.getCNounList().add(new CompoundEntry(candidate.getStem(), 0, true));
candidate.setStem(pvword + candidate.getStem()); // 이렇게 해야 WSOutput 에 복합명사 처리할 때 정상처리됨
}
}
fillSourceString(snipt.substring(0, eend), candidates);
return candidates;
}
private void anlysisWithEomiDetail(String input, List<AnalysisOutput> candidates) throws MorphException {
boolean eomiFlag = true;
int strlen = input.length();
char ch = input.charAt(strlen - 1);
char[] feature = SyllableUtil.getFeature(ch);
if (feature[SyllableUtil.IDX_YNPNA] == '1' || feature[SyllableUtil.IDX_YNPLA] == '1' ||
feature[SyllableUtil.IDX_YNPMA] == '1')
morphAnal.analysisWithEomi(input, "", candidates);
for (int i = strlen - 1; i > 0; i--) {
String stem = input.substring(0, i);
String eomi = input.substring(i);
feature = SyllableUtil.getFeature(eomi.charAt(0));
morphAnal.analysisWithEomi(stem, eomi, candidates);
if (feature[SyllableUtil.IDX_EOMI2] == '0')
break;
// if (eomiFlag) {
// morphAnal.analysisWithEomi(stem, eomi, candidates);
// }
//
// if (eomiFlag && feature[SyllableUtil.IDX_EOMI2] == '0') eomiFlag = false;
//
// if (!eomiFlag) break;
}
}
/**
* 어미의 첫음절부터 어미의 1음절이상에 사용될 수 있는 음절을 조사하여
* 가장 큰 조사를 찾는다.
*
* @throws org.apache.lucene.analysis.kr.morph.MorphException
*
*/
private int findEomiEnd(String snipt, int estart) throws MorphException {
int jend = 0;
String tail;
char[] chr = MorphUtil.decompose(snipt.charAt(estart));
if (chr.length == 3 && (chr[2] == 'ㄴ')) {
tail = '은' + snipt.substring(estart + 1);
} else if (chr.length == 3 && (chr[2] == 'ㄹ')) {
tail = '을' + snipt.substring(estart + 1);
} else if (chr.length == 3 && (chr[2] == 'ㅂ')) {
tail = '습' + snipt.substring(estart + 1);
} else {
tail = snipt.substring(estart);
}
// 조사의 2음절로 사용될 수 마지막 음절을 찾는다.
int start = 0;
for (int i = 1; i < tail.length(); i++) {
char[] f = SyllableUtil.getFeature(tail.charAt(i));
if (f[SyllableUtil.IDX_EOGAN] == '0') break;
start = i;
}
for (int i = start; i > 0; i--) { // 찾을 수 없더라도 1음절은 반드시 반환해야 한다.
String str = tail.substring(0, i + 1);
char[] chrs = MorphUtil.decompose(tail.charAt(i));
if (DictionaryUtil.existEomi(str) ||
(i < 2 && chrs.length == 3 && (chrs[2] == 'ㄹ' || chrs[2] == 'ㅁ' || chrs[2] == 'ㄴ'))) { // ㅁ,ㄹ,ㄴ이 연속된 용언은 없다, 사전을 보고 확인을 해보자
jend = i;
break;
}
}
return estart + jend + 1;
}
/** validation 후 후보가 될 가능성이 높은 최상위 것을 결과에 추가한다. */
private int validationAndAppend(WSOutput output, List<AnalysisOutput> candidates, String input) throws MorphException {
if (candidates.size() == 0) return 0;
AnalysisOutput o = candidates.remove(0);
AnalysisOutput po = output.getPhrases().size() > 0 ? output.getPhrases().get(output.getPhrases().size() - 1) : null;
String ejend = o.getSource().substring(o.getStem().length());
char[] chrs = po != null && po.getStem().length() > 0 ? MorphUtil.decompose(po.getStem().charAt(po.getStem().length() - 1)) : null;
String pjend = po != null && po.getStem().length() > 0 ? po.getSource().substring(po.getStem().length()) : null;
char ja = 'x'; // 임의의 문자
if (po != null && (po.getPatn() == PatternConstants.PTN_VM || po.getPatn() == PatternConstants.PTN_VMCM || po.getPatn() == PatternConstants.PTN_VMXM)) {
char[] chs = MorphUtil.decompose(po.getEomi().charAt(po.getEomi().length() - 1));
if (chs.length == 3) ja = chs[2];
else if (chs.length == 1) ja = chs[0];
}
int nEnd = output.getLastEnd() + o.getSource().length();
char[] f = nEnd < input.length() ? SyllableUtil.getFeature(input.charAt(nEnd)) : null;
// 밥먹고 같은 경우가 가능하나.. 먹고는 명사가 아니다.
if (po != null && po.getPatn() == PatternConstants.PTN_N && candidates.size() > 0 &&
o.getPatn() == PatternConstants.PTN_VM && candidates.get(0).getPatn() == PatternConstants.PTN_N) {
o = candidates.remove(0);
} else if (po != null && po.getPatn() >= PatternConstants.PTN_VM && candidates.size() > 0 &&
candidates.get(0).getPatn() == PatternConstants.PTN_N &&
(ja == 'ㄴ' || ja == 'ㄹ')) { // 다녀가ㄴ, 사,람(e) 로 분해 방지
o = candidates.remove(0);
}
//=============================================
if (o.getPos() == PatternConstants.POS_NOUN && MorphUtil.hasVerbOnly(o.getStem())) {
output.removeLast();
return -1;
} else if (nEnd < input.length() && f[SyllableUtil.IDX_JOSA1] == '1'
&& DictionaryUtil.getNoun(o.getSource()) != null) {
return -1;
} else if (nEnd < input.length() && o.getScore() == AnalysisOutput.SCORE_ANALYSIS
&& DictionaryUtil.findWithPrefix(ejend + input.charAt(nEnd)).hasNext()) { // 루씬하ㄴ 글형태소분석기 방지
return -1;
} else if (po != null && po.getPatn() == PatternConstants.PTN_VM && "ㅁ".equals(po.getEomi()) &&
o.getStem().equals("하")) { // 다짐 합니다 로 분리되는 것 방지
output.removeLast();
return -1;
} else if (po != null && po.getPatn() == PatternConstants.PTN_N && VerbUtil.verbSuffix(o.getStem()) &&
!"있".equals(o.getStem())) { // 사랑받다, 사랑스러운을 처리, 그러나 있은 앞 단어와 결합하지 않는다.
output.removeLast();
return -1;
} else {
output.addPhrase(o);
}
return 1;
}
private AnalysisOutput buildSingleOutput(WordEntry entry) {
char pos = PatternConstants.POS_NOUN;
int ptn = PatternConstants.PTN_N;
if (entry.getFeature(WordEntry.IDX_NOUN) == '0') {
pos = PatternConstants.POS_AID;
ptn = PatternConstants.PTN_AID;
}
AnalysisOutput o = new AnalysisOutput(entry.getWord(), null, null, pos,
ptn, AnalysisOutput.SCORE_CORRECT);
o.setSource(entry.getWord());
return o;
}
private void analysisCompouns(List<AnalysisOutput> candidates) throws MorphException {
// 복합명사 분해여부 결정하여 분해
//boolean changed = false;
boolean correct = false;
for (AnalysisOutput o : candidates) {
if (o.getScore() == AnalysisOutput.SCORE_CORRECT) {
if (o.getPatn() != PatternConstants.PTN_NJ) correct = true;
// "활성화해"가 [활성화(N),하(t),어야(e)] 분석성공하였는데 [활성/화해]분해되는 것을 방지
if ("하".equals(o.getVsfx())) break;
continue;
}
if (o.getPatn() <= PatternConstants.PTN_VM && o.getStem().length() > 2) {
if (!(correct && o.getPatn() == PatternConstants.PTN_N)) morphAnal.confirmCNoun(o);
//if (o.getScore() == AnalysisOutput.SCORE_CORRECT) changed = true;
}
}
}
/**
* 문자열에 명사를 찾습니다.
*
* @param str 분석하고자 하는 전체 문자열
* @param ws 문자열에서 명사를 찾는 시작위치
* @param es 문자열에서 명사를 찾는 끝 위치
* @throws org.apache.lucene.analysis.kr.morph.MorphException
*
*/
private boolean findNounWithinStr(String str, int ws, int es) throws MorphException {
if (str.length() < es) return false;
for (int i = es; i < str.length(); i++) {
char[] f = SyllableUtil.getFeature(str.charAt(i));
if (i == str.length() || (f[SyllableUtil.IDX_JOSA1] == '1')) {
return (DictionaryUtil.getWord(str.substring(ws, i)) != null);
}
}
return false;
}
private boolean isNounPart(String str, int jstart) throws MorphException {
//TODO: 이게 왜 들어가 있지? 이유가 없는 듯
// if (true) return false;
for (int i = jstart - 1; i >= 0; i--) {
if (DictionaryUtil.getWordExceptVerb(str.substring(i, jstart + 1)) != null)
return true;
}
return false;
}
private void printCandidate(WSOutput output) {
List<AnalysisOutput> os = output.getPhrases();
for (AnalysisOutput o : os) {
System.out.print(o.toString() + "(" + o.getScore() + ")| ");
}
System.out.println("<==");
}
}