package org.apache.lucene.analysis.kr.morph; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; 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; /** * * @author smlee * */ public class WordSpaceAnalyzer { private MorphAnalyzer morphAnal; public WordSpaceAnalyzer() { morphAnal = new MorphAnalyzer(); morphAnal.setExactCompound(false); } public List analyze(String input) throws MorphException { List stack = new ArrayList(); WSOutput output = new WSOutput(); int wStart = 0; int sgCount = -9; Map<Integer, Integer> fCounter = new HashMap(); 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(); 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(); } /** * 조사로 끝나는 어구를 분석한다. * @param snipt * @param js * @return * @throws MorphException */ private List anlysisWithJosa(String snipt, int js) throws MorphException { List<AnalysisOutput> candidates = new ArrayList(); 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음절이상에 사용될 수 있는 음절을 조사하여 * 가장 큰 조사를 찾는다. * @param snipt * @param jstart * @return * @throws 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; } } if(!hasJosa) return -1; return jend+1; } /** * 향후 계산이나 원 문자열을 보여주기 위해 source string 을 저장한다. * @param source * @param candidates */ private void fillSourceString(String source, List<AnalysisOutput> candidates) { for(AnalysisOutput o : candidates) { o.setSource(source); } } /** * 목록의 1번지가 가장 큰 길이를 가진다. * @param candidates */ 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(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 { List<AnalysisOutput> candidates = new ArrayList(); 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); while(true) { // ㄹ,ㅁ,ㄴ 이기때문에 어미위치를 뒤로 잡았는데, 용언+어미의 형태가 아니라면.. 어구 끝을 하나 줄인다. String input = snipt.substring(vstart,eend); anlysisWithEomiDetail(input, candidates); if(candidates.size()==0) break; if(("ㄹ".equals(candidates.get(0).getEomi()) || "ㅁ".equals(candidates.get(0).getEomi()) || "ㄴ".equals(candidates.get(0).getEomi())) && eend>estart+1 && candidates.get(0).getPatn()!=PatternConstants.PTN_VM && candidates.get(0).getPatn()!=PatternConstants.PTN_NSM ) { eend--; }else if(pvword!=null&&candidates.get(0).getPatn()>=PatternConstants.PTN_VM&& // 명사 + 용언 어구 중에.. 용언어구로 단어를 이루는 경우는 없다. candidates.get(0).getPatn()<=PatternConstants.PTN_VMXMJ && DictionaryUtil.getWord(input)!=null){ candidates.clear(); break; }else if(pvword!=null&&VerbUtil.verbSuffix(candidates.get(0).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)); if(eomiFlag) { morphAnal.analysisWithEomi(stem,eomi,candidates); } if(eomiFlag&&feature[SyllableUtil.IDX_EOMI2]=='0') eomiFlag = false; if(!eomiFlag) break; } } /** * 어미의 첫음절부터 어미의 1음절이상에 사용될 수 있는 음절을 조사하여 * 가장 큰 조사를 찾는다. * @param snipt * @param jstart * @return * @throws MorphException */ private int findEomiEnd(String snipt, int estart) throws MorphException { int jend = 0; String tail = null; 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 후 후보가 될 가능성이 높은 최상위 것을 결과에 추가한다. * * @param output * @param candidates * @param stack */ 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 문자열에서 명사를 찾는 끝 위치 * @return * @throws 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 { 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("<=="); } }