Files
hyungi_document_server/app/services/digest/clustering.py
T
Hyungi Ahn 1ca6d8b522 refactor(digest): extract clustering helpers to clustering_common
Phase 4 Global Digest 의 클러스터링 핵심 알고리즘 (time-decay weight,
adaptive threshold, greedy cosine assign + EMA centroid, importance
normalize) 을 `app/services/clustering_common.py` 로 추출. country
축은 caller 책임 — Phase 4 cluster_country 는 그대로 country 별 호출,
신규 morning briefing 모듈이 country 없이 cluster_global 로 호출 예정.

selection.py 의 중복 _normalize 도 공통 util 로 통일.

동작 변경 0:
- LAMBDA / threshold / EMA alpha / MIN_ARTICLES 모두 Phase 4 기본값 유지
- docs.sort (in-place) → sorted (copy) 변경했으나 caller 가 정렬된
  docs 를 재사용하지 않으므로 무관 (dict element 의 weight 부여는
  reference 라 그대로 반영)

다음 commit 에서 Phase 4 회귀 검증 (digest regenerate diff 0).
2026-05-12 12:38:32 +09:00

53 lines
1.7 KiB
Python

"""Phase 4 Global Digest — country 내 topic cluster (time-decay + EMA + adaptive threshold).
알고리즘 코어는 `app/services/clustering_common.py` 로 추출되어 briefing 모듈과 공유.
본 파일은 Phase 4 고유 파라미터 (LAMBDA = ln(2)/3 일, MIN 3, MAX 10) 와 country 축 호출만 담당.
"""
import math
from core.utils import setup_logger
from services.clustering_common import (
adaptive_threshold_by_density,
greedy_assign_cluster,
)
logger = setup_logger("digest_clustering")
LAMBDA = math.log(2) / 3 # 3일 반감기 — 사용자 확정값
CENTROID_ALPHA = 0.7 # EMA: 기존 중심 70% 유지, 새 멤버 30% 반영
MIN_ARTICLES_PER_TOPIC = 3
MAX_TOPICS_PER_COUNTRY = 10
def adaptive_threshold(n_docs: int) -> float:
"""Phase 4 임계 (0.75 / 0.78 / 0.80). 외부 import 호환용 alias."""
return adaptive_threshold_by_density(n_docs)
def cluster_country(country: str, docs: list[dict]) -> list[dict]:
"""단일 country 의 docs 를 cluster 로 묶어 정렬 + normalize 후 반환.
공통 util `greedy_assign_cluster` 위에 country 라벨 로깅만 추가.
"""
if not docs:
logger.info(f"[{country}] docs=0 → skip")
return []
threshold = adaptive_threshold(len(docs))
clusters, raw_count = greedy_assign_cluster(
docs,
threshold=threshold,
centroid_alpha=CENTROID_ALPHA,
min_articles=MIN_ARTICLES_PER_TOPIC,
max_topics=MAX_TOPICS_PER_COUNTRY,
lambda_val=LAMBDA,
)
dropped = raw_count - len(clusters)
logger.info(
f"[{country}] docs={len(docs)} threshold={threshold} "
f"raw_clusters={raw_count} dropped={dropped} kept={len(clusters)}"
)
return clusters