"""스토리 요약/분석 모듈 (계획서 과제2 ②, p.13 / p.21 고도화). 계획서 요약 시스템 흐름: 입력 ⇒ 추출적 요약(중요 정보 식별) ⇒ 추상적 요약(내용 재구성) ⇒ 최종 요약 본 모듈은 데이터(요약 정답셋) 없이도 동작하는 부분까지 구현한다. ① 추출적 요약 (extractive) — 정답셋 불필요. 비지도 TextRank 변형. 문장 분할 → 문장 간 lemma 코사인 유사도 그래프 → PageRank 중심성 → 상위 문장 선택. (계획서: "구성 요소 추출 모델 활용해 핵심 내용 추출") ② 추상적 요약 (abstractive) — LLM 훅(옵션). 키 없으면 추출적 결과로 폴백. 자체 sLLM(고려대 2차저작 생성지원 모델) 으로 교체할 자리. ③ 통합 요약 (hybrid) — ①의 핵심 문장을 ②의 입력으로 (계획서 통합 요약 시스템). 사용자 맞춤형 옵션(요약 상세도/강조 주제)은 계획서 2단계 '사용자 맞춤형 요약' 반영. 평가지표 No.7(N-gram ROUGE)는 scripts/eval_rouge.py 로 측정한다 (정답셋 들어오면). """ from __future__ import annotations import logging import math import re from collections import Counter from dataclasses import dataclass from app.engine.structural import extract_lemmas logger = logging.getLogger(__name__) DETAIL_RATIOS = {"brief": 0.2, "standard": 0.3, "detailed": 0.45} EXTRACTIVE_STRATEGIES = {"textrank", "coverage", "lead"} # 문장 분할 — 종결부호 기준 (한국어 '다./요./까?/!' + 줄바꿈) _SENT_SPLIT = re.compile(r"(?<=[.!?。…])\s+|\n+") def split_sentences(text: str) -> list[str]: raw = _SENT_SPLIT.split(text.strip()) return [s.strip() for s in raw if s.strip()] def _lemma_vector(sentence: str) -> Counter: return Counter(extract_lemmas(sentence)) def _cosine(a: Counter, b: Counter) -> float: if not a or not b: return 0.0 common = set(a) & set(b) if not common: return 0.0 dot = sum(a[t] * b[t] for t in common) na = math.sqrt(sum(v * v for v in a.values())) nb = math.sqrt(sum(v * v for v in b.values())) return dot / (na * nb) if na and nb else 0.0 def _textrank_scores(vectors: list[Counter], damping: float = 0.85, iters: int = 30) -> list[float]: """문장 그래프 PageRank. 정답셋 불필요한 비지도 중심성.""" n = len(vectors) if n == 0: return [] if n == 1: return [1.0] # 유사도 인접행렬 (자기 자신 제외) sim = [[0.0] * n for _ in range(n)] for i in range(n): for j in range(i + 1, n): s = _cosine(vectors[i], vectors[j]) sim[i][j] = sim[j][i] = s # 행 정규화 row_sum = [sum(sim[i]) for i in range(n)] scores = [1.0 / n] * n for _ in range(iters): new = [(1 - damping) / n] * n for i in range(n): for j in range(n): if i == j or row_sum[j] == 0: continue new[i] += damping * scores[j] * sim[j][i] / row_sum[j] scores = new return scores def _normalize(scores: list[float]) -> list[float]: """0~1 정규화. 모든 값이 같으면 동률로 취급한다.""" if not scores: return [] low, high = min(scores), max(scores) if high - low < 1e-12: return [1.0] * len(scores) return [(score - low) / (high - low) for score in scores] def _coverage_scores(sentences: list[str], vectors: list[Counter]) -> list[float]: """문서의 고유 내용어를 넓게 담는 문장을 우선하는 점수. TextRank만 쓰면 같은 사건을 반복하는 문장이 상위를 독점할 수 있다. 이 점수는 문서 전체에서 드문 내용어(인물·사건·시간 등)가 든 문장을 높게 평가하고, 선택 단계에서 이미 선택한 문장과의 중복을 감점한다. 외부 모델이나 정답셋을 보지 않는 순수 추출 방식이다. """ document_frequency: Counter = Counter() for vector in vectors: document_frequency.update(vector.keys()) n = max(1, len(sentences)) lexical = [ sum(count * math.log((n + 1) / (document_frequency[token] + 0.5)) for token, count in vector.items()) / max(1, sum(vector.values())) for vector in vectors ] # 회고록·에피소드 형식에서는 첫 문장이 사건의 맥락을 잡는 경우가 많다. 단, # 위치만으로 결정되지 않게 작은 사전 등록 가중치만 둔다. position = [1.0 - (index / max(1, n - 1)) * 0.35 for index in range(n)] centrality = _normalize(_textrank_scores(vectors)) lexical = _normalize(lexical) return [0.50 * c + 0.38 * l + 0.12 * p for c, l, p in zip(centrality, lexical, position)] def _coverage_select(vectors: list[Counter], base_scores: list[float], k: int) -> list[int]: """MMR 방식으로 중요하면서 서로 다른 정보를 담는 문장 k개를 고른다.""" chosen: list[int] = [] remaining = set(range(len(vectors))) while remaining and len(chosen) < k: def candidate_score(index: int) -> tuple[float, float, int]: redundancy = max((_cosine(vectors[index], vectors[other]) for other in chosen), default=0.0) # 중복 문장은 낮추되, 낮은 중요도의 문장이 끼어들 정도로 과도하게 벌주진 않는다. value = base_scores[index] - 0.22 * redundancy return (value, base_scores[index], -index) best = max(remaining, key=candidate_score) chosen.append(best) remaining.remove(best) return chosen @dataclass class SummaryResult: extractive: str # 추출적 요약 (선택된 원문 문장) abstractive: str | None # 추상적 요약 (LLM, 없으면 None) final: str # 최종 요약 (abstractive 우선, 없으면 extractive) selected_indices: list[int] # 선택된 문장 인덱스 (원문 순서) mode: str # "extractive" | "hybrid" num_sentences_in: int num_sentences_out: int detail: str = "standard" emphasis: list[str] | None = None def extractive_summary( text: str, ratio: float = 0.3, max_sentences: int | None = None, emphasis: list[str] | None = None, detail: str = "standard", strategy: str = "textrank", ) -> SummaryResult: """비지도 추출적 요약 — 정답셋/LLM/외부호출 불필요.""" sentences = split_sentences(text) n = len(sentences) clean_emphasis = [term.strip() for term in (emphasis or []) if term.strip()] if n == 0: return SummaryResult("", None, "", [], "extractive", 0, 0, detail, clean_emphasis) if n <= 2: joined = " ".join(sentences) return SummaryResult( joined, None, joined, list(range(n)), "extractive", n, n, detail, clean_emphasis, ) k = max(1, math.ceil(n * ratio)) if max_sentences is not None: k = min(k, max_sentences) if strategy not in EXTRACTIVE_STRATEGIES: raise ValueError("strategy must be one of %s" % sorted(EXTRACTIVE_STRATEGIES)) vectors = [_lemma_vector(s) for s in sentences] scores = _textrank_scores(vectors) if strategy == "textrank" else _coverage_scores(sentences, vectors) if strategy == "lead": scores = [float(n - index) for index in range(n)] # 사용자가 지정한 주제를 포함한 문장에 명시적 가중치를 준다. # TextRank 중심성은 유지하되 강조 요청이 상위 선택에 반영되도록 한다. if clean_emphasis: lowered_terms = [term.casefold() for term in clean_emphasis] for i, sentence in enumerate(sentences): matched = sum(term in sentence.casefold() for term in lowered_terms) scores[i] += 2.0 * matched / len(lowered_terms) # 상위 k개 문장 선택 → 원문 등장 순서로 재정렬 (가독성) top = (_coverage_select(vectors, scores, k) if strategy == "coverage" else sorted(range(n), key=lambda i: scores[i], reverse=True)[:k]) top_sorted = sorted(top) summary = " ".join(sentences[i] for i in top_sorted) return SummaryResult( extractive=summary, abstractive=None, final=summary, selected_indices=top_sorted, mode="extractive", num_sentences_in=n, num_sentences_out=len(top_sorted), detail=detail, emphasis=clean_emphasis, ) _ABSTRACTIVE_PROMPT = """다음은 어떤 글에서 추출한 핵심 문장들이다. 이 내용을 바탕으로 간결하고 자연스러운 한국어 요약문을 작성하라. 원문에 없는 사실을 지어내지 말 것. [핵심 문장] """ class Summarizer: """통합 요약기 — 추출적(항상) + 추상적(LLM 옵션). 계획서 통합 요약 시스템: 추출적으로 중요 문장을 뽑은 뒤 추상적으로 재구성. use_llm=False 이거나 키가 없으면 추출적 결과를 최종 요약으로 사용. """ def __init__(self, settings=None): from app.core.config import get_settings self.settings = settings or get_settings() def summarize( self, text: str, ratio: float | None = None, max_sentences: int | None = None, use_abstractive: bool = True, detail: str = "standard", emphasis: list[str] | None = None, strategy: str = "textrank", ) -> SummaryResult: effective_ratio = ratio if ratio is not None else DETAIL_RATIOS.get(detail, 0.3) base = extractive_summary( text, ratio=effective_ratio, max_sentences=max_sentences, emphasis=emphasis, detail=detail, strategy=strategy, ) if not base.extractive: return base if use_abstractive and self.settings.use_llm_extractor and self.settings.has_openai: abstractive = self._abstractive(base.extractive, detail, emphasis or []) if abstractive: return SummaryResult( extractive=base.extractive, abstractive=abstractive, final=abstractive, selected_indices=base.selected_indices, mode="hybrid", num_sentences_in=base.num_sentences_in, num_sentences_out=base.num_sentences_out, detail=detail, emphasis=base.emphasis, ) return base def _abstractive( self, extractive_text: str, detail: str, emphasis: list[str] ) -> str | None: try: from openai import OpenAI client = OpenAI(api_key=self.settings.openai_api_key) resp = client.chat.completions.create( model=self.settings.openai_extraction_model, temperature=0.2, messages=[ {"role": "system", "content": "You are a concise Korean summarizer. Never hallucinate."}, { "role": "user", "content": ( _ABSTRACTIVE_PROMPT + extractive_text + f"\n\n[\uC0C1세도] {detail}" + (f"\n[\uAC15조 주제] {', '.join(emphasis)}" if emphasis else "") ), }, ], ) return (resp.choices[0].message.content or "").strip() or None except Exception as exc: # pragma: no cover - 네트워크/키 의존 logger.warning("Abstractive summary failed, using extractive: %s", exc) return None _default_summarizer: Summarizer | None = None def get_summarizer(settings=None) -> Summarizer: global _default_summarizer if _default_summarizer is None: _default_summarizer = Summarizer(settings) return _default_summarizer