From 3342981bd21996cc63777149d60dae9c5dfbfa73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Mon, 14 Sep 2026 17:01:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20Gemini=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=86=8C=EA=B0=9C=EB=AC=B8=20=EC=9A=94=EC=95=BD=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캔버스 미리보기에서 intro/room_intro 원문이 길 때 CSS로 자르는 대신 실제 문장을 줄여 보여주기 위한 첫 단계. DB에는 저장하지 않고 프로세스 메모리 캐시(sha256 키)로 같은 원문의 재호출만 막는다. --- .../backend/services/external/gemini_text.py | 62 +++++++++++++++++++ solution/backend/tests/test_gemini_text.py | 61 ++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/solution/backend/services/external/gemini_text.py b/solution/backend/services/external/gemini_text.py index 05419a2..e8383bc 100644 --- a/solution/backend/services/external/gemini_text.py +++ b/solution/backend/services/external/gemini_text.py @@ -10,6 +10,7 @@ 한때 이 네 가지가 한 파일 500줄에 뭉쳐 있었다. "FAQ 답이 이상하다" 를 고치러 와도 어디를 봐야 할지가 파일 안에서 갈리지 않았다. """ +import hashlib import json from dataclasses import dataclass, field from typing import Optional @@ -196,6 +197,67 @@ async def generate_copy( return result +# ── 요약(summarize_text) ────────────────────────────────────────────────── +# ★ generate_copy 와 다르다: 여기서 압축하는 문장은 **이미 승인된 값**이다(fact 로 저장된 intro· +# room_intro). 새 사실을 만드는 게 아니라 같은 내용을 짧게 쓰는 것뿐이라 ground_check 를 다시 +# 걸지 않는다 — "사실을 더하지 마라"는 프롬프트 지시로 충분하다. +_SUMMARY_CACHE: dict[str, str] = {} +_SUMMARY_CACHE_MAX = 500 + + +async def summarize_text( + text: str, + *, + model: str = DEFAULT_TEXT_MODEL, + max_retries: int = 2, + client: Optional[httpx.AsyncClient] = None, +) -> Optional[str]: + """캔버스 미리보기용 축약문. 실패해도 예외를 올리지 않는다 — 호출측은 None 이면 원문을 쓴다. + + ★ DB 에 남기지 않는다. 같은 원문은 프로세스 메모리 캐시(sha256 키)로 재호출을 막는다 + (서버 재시작하면 비워진다 — 요구사항: "DB 저장은 생략하고 프론트 응답에만 실어준다"). + """ + stripped = text.strip() + if not stripped: + return None + if not is_configured(): + return None + + cache_key = hashlib.sha256(stripped.encode("utf-8")).hexdigest() + cached = _SUMMARY_CACHE.get(cache_key) + if cached is not None: + return cached + + body = { + "contents": [{"role": "user", "parts": [{ + "text": ( + "다음 문장을 사실을 더하거나 빼지 말고 한국어 2~3문장, 150자 내외로 " + "간결하게 줄여줘. 요약문만 출력하고 다른 말은 붙이지 마.\n\n" + stripped + ), + }]}], + "generationConfig": {"temperature": 0.2}, + } + + owns_client = client is None + client = client or httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0)) + try: + payload = await call(client, model, body, max_retries) + summary = extract_text(payload).strip() + except GeminiError as ex: + LOG.w(f"[gemini-text] 요약 실패: {ex}") + return None + finally: + if owns_client: + await client.aclose() + + if not summary: + return None + if len(_SUMMARY_CACHE) >= _SUMMARY_CACHE_MAX: + _SUMMARY_CACHE.clear() # 간단한 캐시 상한 — 관리 도구 트래픽 규모에는 LRU 가 과하다. + _SUMMARY_CACHE[cache_key] = summary + return summary + + @dataclass class GeneratedSong: """가사 생성 결과. 곡은 여기서 만들지 않는다 — 작곡은 services/external/suno 다.""" diff --git a/solution/backend/tests/test_gemini_text.py b/solution/backend/tests/test_gemini_text.py index 0a1087c..d8d6659 100644 --- a/solution/backend/tests/test_gemini_text.py +++ b/solution/backend/tests/test_gemini_text.py @@ -361,3 +361,64 @@ def test_ground_check_grounds_numbers_from_unit_summaries(): grounding = FACTS + gt._unit_facts([{"name": "A동", "facts": {"max_capacity": "4"}}]) ok, _reasons = grounding_copy.ground_check("A동은 최대 4명까지 이용하실 수 있습니다.", grounding) assert ok is True + + +# ── 요약(summarize_text) ────────────────────────────────────────────────── +@pytest.fixture(autouse=True) +def _clear_summary_cache(): + """모듈 전역 캐시가 테스트끼리 새어 들어가지 않게 매번 비운다.""" + gt._SUMMARY_CACHE.clear() + + +async def test_summarize_returns_shortened_text(): + """검증: 긴 문장을 요약 API 로 축약한다. + 기대결과: 응답 텍스트가 그대로 반환되고, 원문이 요청 프롬프트에 실린다.""" + payload = { + "candidates": [{"content": {"parts": [{"text": "짧게 줄인 문장입니다."}]}, "finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 300, "candidatesTokenCount": 20}, + } + calls = [] + async with _client(_ok(payload, calls)) as c: + result = await gt.summarize_text("첫 번째 테스트용 원문입니다. " * 20, client=c) + + assert result == "짧게 줄인 문장입니다." + prompt = json.loads(calls[0].content)["contents"][0]["parts"][0]["text"] + assert "첫 번째 테스트용 원문입니다." in prompt + + +async def test_summarize_skips_when_not_configured(): + """검증: GEMINI_API_KEY 미설정. + 기대결과: 호출 자체를 안 하고 None — 캔버스는 원문으로 폴백한다.""" + llm.external_api_config.gemini_api_key = "" + result = await gt.summarize_text("두 번째 테스트용 원문입니다. " * 20) + assert result is None + + +async def test_summarize_caches_repeated_calls(): + """검증: 같은 원문을 두 번 요약 요청한다. + 기대결과: 두 번째는 API 를 다시 부르지 않고 캐시된 값을 돌려준다.""" + payload = {"candidates": [{"content": {"parts": [{"text": "캐시 확인용 요약"}]}, "finishReason": "STOP"}]} + calls = [] + text = "세 번째 테스트용 원문입니다. " * 20 + async with _client(_ok(payload, calls)) as c: + first = await gt.summarize_text(text, client=c) + second = await gt.summarize_text(text, client=c) + + assert first == second == "캐시 확인용 요약" + assert len(calls) == 1, "같은 원문인데 API 를 두 번 불렀다" + + +async def test_summarize_returns_none_on_repeated_failure(): + """검증: 재시도까지 전부 5xx 로 실패한다. + 기대결과: 예외를 올리지 않고 None — 요약 실패가 캔버스를 깨면 안 된다.""" + async with _client(lambda r: httpx.Response(503, text="unavailable")) as c: + result = await gt.summarize_text("네 번째 테스트용 원문입니다. " * 20, client=c, max_retries=1) + + assert result is None + + +async def test_summarize_empty_text_returns_none(): + """검증: 빈 문자열. + 기대결과: 호출 없이 None.""" + result = await gt.summarize_text(" ") + assert result is None