feat(backend): Gemini 기반 소개문 요약 함수 추가
캔버스 미리보기에서 intro/room_intro 원문이 길 때 CSS로 자르는 대신 실제 문장을 줄여 보여주기 위한 첫 단계. DB에는 저장하지 않고 프로세스 메모리 캐시(sha256 키)로 같은 원문의 재호출만 막는다.
This commit is contained in:
parent
c07e2bddf4
commit
3342981bd2
@ -10,6 +10,7 @@
|
|||||||
한때 이 네 가지가 한 파일 500줄에 뭉쳐 있었다. "FAQ 답이 이상하다" 를 고치러 와도
|
한때 이 네 가지가 한 파일 500줄에 뭉쳐 있었다. "FAQ 답이 이상하다" 를 고치러 와도
|
||||||
어디를 봐야 할지가 파일 안에서 갈리지 않았다.
|
어디를 봐야 할지가 파일 안에서 갈리지 않았다.
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@ -196,6 +197,67 @@ async def generate_copy(
|
|||||||
return result
|
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
|
@dataclass
|
||||||
class GeneratedSong:
|
class GeneratedSong:
|
||||||
"""가사 생성 결과. 곡은 여기서 만들지 않는다 — 작곡은 services/external/suno 다."""
|
"""가사 생성 결과. 곡은 여기서 만들지 않는다 — 작곡은 services/external/suno 다."""
|
||||||
|
|||||||
@ -361,3 +361,64 @@ def test_ground_check_grounds_numbers_from_unit_summaries():
|
|||||||
grounding = FACTS + gt._unit_facts([{"name": "A동", "facts": {"max_capacity": "4"}}])
|
grounding = FACTS + gt._unit_facts([{"name": "A동", "facts": {"max_capacity": "4"}}])
|
||||||
ok, _reasons = grounding_copy.ground_check("A동은 최대 4명까지 이용하실 수 있습니다.", grounding)
|
ok, _reasons = grounding_copy.ground_check("A동은 최대 4명까지 이용하실 수 있습니다.", grounding)
|
||||||
assert ok is True
|
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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user