diff --git a/app/core/data/__init__.py b/app/core/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/data/stopwords_ko.txt b/app/core/data/stopwords_ko.txt new file mode 100644 index 0000000..5051854 --- /dev/null +++ b/app/core/data/stopwords_ko.txt @@ -0,0 +1,414 @@ +# ── 일반 의존명사·불특정 명사 ── +것 +수 +거 +게 +때 +곳 +중 +동안 +경우 +만큼 +자체 +가운데 +바 +터 +등 +점 +측 +뒤 +앞 +위 +아래 +안 +밖 +간 +내 +외 +상 +하 +전 +후 +초 +말 +초반 +후반 + +# ── 지시·대명사 성향 명사 ── +이것 +그것 +저것 +우리 +저희 +자기 +자신 +누구 +무엇 +여기 +거기 +저기 +이쪽 +저쪽 +그쪽 +이곳 +그곳 +저곳 +아무 +어느 +어떤 +무슨 +이런 +그런 +저런 +이렇 +그렇 +저렇 + +# ── 시간·수량·단위 명사 ── +오늘 +어제 +내일 +최근 +이번 +다음 +지난 +이전 +이후 +시간 +분기 +매년 +매월 +매일 +년도 +월초 +월말 +연초 +연말 +초기 +후기 +중기 +올해 +내년 +작년 +재작년 +당시 +현재 +현재는 +과거 +미래 +순간 +기간 +사이 +즈음 +무렵 +요즘 +근래 +당분간 +한동안 + +# ── 개발 블로그 필러 명사 ── +글 +내용 +방법 +이야기 +소개 +정리 +설명 +이해 +참고 +예시 +예제 +기반 +관련 +부분 +요소 +항목 +단계 +과정 +결과 +시작 +종료 +끝 +처음 +마지막 +작업 +진행 +사용 +적용 +활용 +구현 +개발 +프로그램 +코드 +기능 +동작 +실행 +수행 +처리 +문제 +해결 +발생 +확인 +가능 +필요 +중요 +다양 +간단 +복잡 +유용 +전체 +모두 +각각 +여러 +다른 +기본 +일반 +보통 +실제 +정도 +관점 +측면 +방식 +방향 +형태 +구조 +형식 +도구 +수단 +방안 +대안 +솔루션 +목적 +목표 +이유 +원인 +영향 +효과 +장점 +단점 +특징 +핵심 +기준 +조건 +상황 +환경 +맥락 +배경 +요약 +정의 +개념 +원리 +규칙 +패턴 +전략 +계획 +설계 +분석 +비교 +검토 +리뷰 +개요 +결론 +마무리 +서론 +본론 + +# ── 인칭·관계 명사 ── +사람 +분 +님 +씨 +여러분 +독자 +개발자 +팀 +회사 +조직 +그룹 +커뮤니티 + +# ── 동사·형용사 어간이 명사로 분석되는 잔여형 ── +하기 +되기 +보기 +이기 +하지 +되지 +있지 +없지 +하면 +되면 +있으면 +없으면 +하고 +되고 +있고 +없고 +하는 +되는 +있는 +없는 +하여 +되어 + +# ── 영어 일반 불용어 (SL 토큰 대응) ── +the +and +for +with +that +this +from +have +has +was +are +were +been +but +not +you +your +our +his +her +they +them +will +would +can +could +should +about +into +than +then +some +any +all +each +every +use +used +using +one +two +get +got +make +made +its +their +it +is +in +of +to +a +an +at +be +do +by +or +as +on +if +so +we +he +she +no +my +me +up +out +may +just +also +more +most +how +when +what +who +why +where +which +both +here +there +very +well +now +still +even +after +before +since +while +once +always +never +often +again +already +still +yet +too +much +many +few +new +old +big +small +good +bad +first +last +next +same +other +long +short +high +low +these +those +such +like +only +over +own +right +left +off +set +put +let +far +add +run +try +see +say +way +day +year +time +work +part +data +type +list +item +value +name +key +number +index +line +page +file +path diff --git a/app/services/trend/tfidf.py b/app/services/trend/tfidf.py new file mode 100644 index 0000000..0386b24 --- /dev/null +++ b/app/services/trend/tfidf.py @@ -0,0 +1,58 @@ +"""TF-IDF 기반 키워드 추출 (DP-381).""" + +from __future__ import annotations + +from sklearn.feature_extraction.text import TfidfVectorizer + + +class TfidfAnalyzer: + """scikit-learn TfidfVectorizer 기반 상위 키워드 추출. + + tokenized_docs: KoreanTokenizer.tokenize() 결과 (공백 조인 문자열 리스트) + 반환: [(keyword, score), ...] 내림차순, len <= top_n + """ + + def __init__( + self, + top_n: int = 30, + min_df: int = 2, + max_df: float = 0.8, + ngram_range: tuple[int, int] = (1, 2), + max_features: int = 5000, + ) -> None: + self._top_n = top_n + self._min_df = min_df + self._max_df = max_df + self._ngram_range = ngram_range + self._max_features = max_features + + def extract(self, tokenized_docs: list[str]) -> list[tuple[str, float]]: + """TF-IDF 피팅 후 상위 top_n 키워드를 반환한다. + + 문서 수 < min_df 일 때 min_df=1 로 fallback (cold start 대응). + """ + docs = [d for d in tokenized_docs if d] + if not docs: + return [] + + cold_start = len(docs) < self._min_df + effective_min_df = 1 if cold_start else self._min_df + effective_max_df = 1.0 if cold_start else self._max_df + vectorizer = TfidfVectorizer( + min_df=effective_min_df, + max_df=effective_max_df, + ngram_range=self._ngram_range, + max_features=self._max_features, + token_pattern=r"(?u)\b\w+\b", + ) + try: + matrix = vectorizer.fit_transform(docs) + except ValueError: + return [] + + terms = vectorizer.get_feature_names_out() + scores = matrix.sum(axis=0).A1 + ranked = sorted(zip(terms, scores), key=lambda x: x[1], reverse=True)[ + : self._top_n + ] + return [(term, round(float(score), 4)) for term, score in ranked] diff --git a/app/services/trend/tokenizer.py b/app/services/trend/tokenizer.py new file mode 100644 index 0000000..099b2ae --- /dev/null +++ b/app/services/trend/tokenizer.py @@ -0,0 +1,57 @@ +"""한국어 형태소 기반 토크나이저 (DP-381).""" + +from __future__ import annotations + +from pathlib import Path + +from kiwipiepy import Kiwi + +_ALLOWED_POS = {"NNG", "NNP", "SL"} +_MIN_LENGTH = 2 +_DEFAULT_STOPWORDS_PATH = ( + Path(__file__).resolve().parents[2] / "core" / "data" / "stopwords_ko.txt" +) + + +def _load_stopwords(path: Path) -> set[str]: + words: set[str] = set() + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + words.add(line.lower()) + return words + + +class KoreanTokenizer: + """kiwipiepy 기반 한국어 토크나이저. + + POS 필터: NNG(일반명사) / NNP(고유명사) / SL(외래어) + min_length=2, 순수 숫자 토큰 제외, 불용어 제거 + """ + + def __init__(self, stopwords_path: Path | None = None) -> None: + self._kiwi = Kiwi() + self._stopwords = _load_stopwords(stopwords_path or _DEFAULT_STOPWORDS_PATH) + + def tokenize_one(self, text: str) -> list[str]: + """단일 텍스트를 필터링된 토큰 리스트로 변환.""" + if not text or not text.strip(): + return [] + tokens: list[str] = [] + for token in self._kiwi.tokenize(text): + if token.tag not in _ALLOWED_POS: + continue + form = token.form.lower().strip() + if len(form) < _MIN_LENGTH: + continue + if form.isdigit(): + continue + if form in self._stopwords: + continue + tokens.append(form) + return tokens + + def tokenize(self, texts: list[str]) -> list[str]: + """복수 텍스트를 TfidfVectorizer 입력용 공백 조인 문자열 리스트로 변환.""" + return [" ".join(self.tokenize_one(t)) for t in texts] diff --git a/requirements.txt b/requirements.txt index f236281..2795dd1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,5 @@ faiss-cpu>=1.9.0,<2.0.0 sqlalchemy>=2.0.0,<3.0.0 psycopg2-binary>=2.9.0,<3.0.0 rapidfuzz>=3.0.0,<4.0.0 +kiwipiepy>=0.17.0,<1.0.0 +scikit-learn>=1.4.0,<2.0.0 diff --git a/tests/test_trend_tfidf.py b/tests/test_trend_tfidf.py new file mode 100644 index 0000000..19c0f32 --- /dev/null +++ b/tests/test_trend_tfidf.py @@ -0,0 +1,61 @@ +"""TfidfAnalyzer 단위 테스트 (DP-381).""" + +from __future__ import annotations + +from app.services.trend.tfidf import TfidfAnalyzer + + +def _analyzer(**kwargs) -> TfidfAnalyzer: + return TfidfAnalyzer(**kwargs) + + +def test_extract_returns_top_n() -> None: + docs = [ + "react kubernetes docker", + "react nextjs typescript", + "kubernetes docker deploy", + "typescript python golang", + "python fastapi sqlalchemy", + ] + result = _analyzer(top_n=3).extract(docs) + assert len(result) <= 3 + + +def test_extract_sorted_desc() -> None: + docs = [ + "react kubernetes docker", + "react nextjs typescript", + "kubernetes docker deploy", + "typescript python golang", + "python fastapi sqlalchemy", + ] + result = _analyzer().extract(docs) + scores = [score for _, score in result] + assert scores == sorted(scores, reverse=True) + + +def test_extract_empty_docs() -> None: + assert _analyzer().extract([]) == [] + assert _analyzer().extract(["", " ", ""]) == [] + + +def test_extract_cold_start_fallback() -> None: + # 문서 1개 → min_df=2 미달 → min_df=1 fallback으로 결과 반환 + docs = ["react kubernetes docker"] + result = _analyzer(min_df=2).extract(docs) + assert len(result) > 0 + + +def test_extract_returns_tuple_format() -> None: + docs = [ + "react kubernetes", + "react docker", + "kubernetes docker", + ] + result = _analyzer().extract(docs) + assert len(result) > 0 + for item in result: + assert isinstance(item, tuple) + assert len(item) == 2 + assert isinstance(item[0], str) + assert isinstance(item[1], float) diff --git a/tests/test_trend_tokenizer.py b/tests/test_trend_tokenizer.py new file mode 100644 index 0000000..30b9b7b --- /dev/null +++ b/tests/test_trend_tokenizer.py @@ -0,0 +1,48 @@ +"""KoreanTokenizer 단위 테스트 (DP-381).""" + +from __future__ import annotations + +from app.services.trend.tokenizer import KoreanTokenizer + + +def _tokenizer() -> KoreanTokenizer: + return KoreanTokenizer() + + +def test_tokenize_one_extracts_nouns() -> None: + result = _tokenizer().tokenize_one("파이썬으로 머신러닝 모델을 구현했다") + assert "파이썬" in result + assert "머신러닝" in result or "모델" in result + # 조사/어미는 제외 + for token in result: + assert token not in {"으로", "을", "다"} + + +def test_tokenize_one_filters_short_tokens() -> None: + result = _tokenizer().tokenize_one("나는 AI를 공부한다") + for token in result: + assert len(token) >= 2 + + +def test_tokenize_one_filters_numbers_only() -> None: + result = _tokenizer().tokenize_one("2024년 1월에 123개의 서비스를 배포했다") + for token in result: + assert not token.isdigit() + + +def test_tokenize_one_empty_input() -> None: + tok = _tokenizer() + assert tok.tokenize_one("") == [] + assert tok.tokenize_one(" ") == [] + + +def test_tokenize_joins_with_space() -> None: + texts = ["파이썬 서버", "쿠버네티스 배포"] + result = _tokenizer().tokenize(texts) + assert len(result) == 2 + for doc in result: + assert isinstance(doc, str) + # 복수 토큰이면 공백으로 연결 + tokens = doc.split() + for t in tokens: + assert len(t) >= 2