"""스토리 요약/분석 모듈 (계획서 과제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__) # 문장 분할 — 종결부호 기준 (한국어 '다./요./까?/!' + 줄바꿈) _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 @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 def extractive_summary(text: str, ratio: float = 0.3, max_sentences: int | None = None) -> SummaryResult: """비지도 추출적 요약 — 정답셋/LLM/외부호출 불필요.""" sentences = split_sentences(text) n = len(sentences) if n == 0: return SummaryResult("", None, "", [], "extractive", 0, 0) if n <= 2: joined = " ".join(sentences) return SummaryResult(joined, None, joined, list(range(n)), "extractive", n, n) k = max(1, math.ceil(n * ratio)) if max_sentences is not None: k = min(k, max_sentences) vectors = [_lemma_vector(s) for s in sentences] scores = _textrank_scores(vectors) # 상위 k개 문장 선택 → 원문 등장 순서로 재정렬 (가독성) top = 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), ) _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 = 0.3, max_sentences: int | None = None, use_abstractive: bool = True, ) -> SummaryResult: base = extractive_summary(text, ratio=ratio, max_sentences=max_sentences) 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) 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, ) return base def _abstractive(self, extractive_text: 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}, ], ) 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