From 8920e9b9d053ad29e948b53f2884c5baef6f2dfe 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:03:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20fact=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20intro/room=5Fintro=20AI=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=20=EB=B6=80=EC=B0=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /v1/place/{id}/fact/list 가 intro·room_intro fact 중 200자를 넘는 것만 Gemini 로 요약해 FactData.summary 에 실어 보낸다. 짧은 값은 API를 부르지 않고, DB에도 저장하지 않는다(응답 전용). --- solution/backend/router/v1/fact/protocol.py | 3 ++ solution/backend/services/fact_service.py | 19 +++++++ solution/backend/tests/test_fact_api.py | 58 +++++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/solution/backend/router/v1/fact/protocol.py b/solution/backend/router/v1/fact/protocol.py index 91db197..a4c0b4d 100644 --- a/solution/backend/router/v1/fact/protocol.py +++ b/solution/backend/router/v1/fact/protocol.py @@ -75,6 +75,9 @@ class FactData(WebPacketProtocol): unit_id: Optional[uuid.UUID] = None key: str value: Optional[str] = None + # ★ 캔버스 미리보기용 축약문. intro/room_intro 원문이 길 때만 채운다 — DB 에는 없다(응답 전용, + # FactService._attach_summaries 가 요청마다 계산해 붙인다). + summary: Optional[str] = None unit: Optional[str] = None source_type: SourceType source_url: Optional[str] = None diff --git a/solution/backend/services/fact_service.py b/solution/backend/services/fact_service.py index c18e6ed..af4abbf 100644 --- a/solution/backend/services/fact_service.py +++ b/solution/backend/services/fact_service.py @@ -32,6 +32,7 @@ from router.v1.fact.protocol import ( Res_Fact, Res_FactList, ) +from services.external import gemini_text from services.external.gemini_extract import extract_facts from services.llm.gemini import GeminiError, GeminiNotConfigured @@ -40,6 +41,10 @@ from services.llm.gemini import GeminiError, GeminiNotConfigured # '어디서 왔는가' 는 여전히 명확하다: 사장님이 화면에 직접 붙여넣었다. OWNER_PASTE_SOURCE = "owner:paste" +# 캔버스 미리보기 요약 대상. 원문이 이 길이를 넘을 때만 요약을 만든다(짧은 문장까지 돈 쓰지 않는다). +_SUMMARY_FACT_KEYS = {"intro", "room_intro"} +_SUMMARY_THRESHOLD_CHARS = 200 + # 자동 수집 출처 — 노출값을 직접 바꾸지 못하고 후보로만 들어간다. _AUTO_SOURCES = (SourceType.API, SourceType.CRAWL, SourceType.LLM) @@ -133,12 +138,26 @@ class FactService: res.result.SetResult(list_err) return res res.facts = [FactData.model_validate(r) for r in rows] + await self._attach_summaries(res.facts) # ★ 사이트에 나갈 수 있는 건수. 발행 게이트가 보는 숫자와 같은 기준이다. res.publishable = sum(1 for r in rows if FactStatus(r.status) in PUBLISHABLE_FACT_STATUSES) # 재수집이 올려놓은 확인 대기 건수 — 관리 화면의 '검토할 것' 배지. res.pending_review = sum(1 for r in rows if FactStatus(r.status) == FactStatus.PENDING_OWNER) return res + async def _attach_summaries(self, facts: list) -> None: + """intro/room_intro 원문이 길면 캔버스 미리보기용 요약을 얹는다. + + ★ place_facts 에는 저장하지 않는다 — 이 응답(FactData.summary)에만 실린다. + 원문이 짧으면 API 를 부르지 않는다(_SUMMARY_THRESHOLD_CHARS).""" + for f in facts: + if f.key not in _SUMMARY_FACT_KEYS: + continue + value = (f.value or "").strip() + if len(value) <= _SUMMARY_THRESHOLD_CHARS: + continue + f.summary = await gemini_text.summarize_text(value) + # ---- 기록 ---- async def extract_from_text(self, user_info: UserInfo, place_id: str, text: str) -> Res_ExtractFacts: """사장님이 붙여넣은 원문 → fact 후보. diff --git a/solution/backend/tests/test_fact_api.py b/solution/backend/tests/test_fact_api.py index 7e73cc8..efeb68b 100644 --- a/solution/backend/tests/test_fact_api.py +++ b/solution/backend/tests/test_fact_api.py @@ -7,6 +7,7 @@ 수정 : 사람이 직접 넣으면 즉시 노출값 교체 + 재빌드 대상 표시 """ from common.enums import ErrorType, FactStatus, FactWriteOutcome, PlaceCategory, SourceType +from services.external import gemini_text as gt OTA = "https://ota.test/room/1" @@ -320,3 +321,60 @@ async def test_facts_are_scoped_to_owner(auth_headers, client): h2 = await auth_headers("o2") r = await client.get(f"/v1/place/{pid}/fact/list", headers=h2) assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value + + +# ── 캔버스 미리보기 요약 ──────────────────────────────────────────────────── +async def test_long_intro_gets_ai_summary_in_list(auth_headers, client, monkeypatch): + """검증: intro 가 요약 임계치(200자)를 넘는다. + 기대결과: /fact/list 응답의 summary 에 축약문이 실리고, value(원문)는 그대로 남는다.""" + async def _fake_summarize(text, **_kwargs): + return "축약된 소개문입니다." + monkeypatch.setattr(gt, "summarize_text", _fake_summarize) + + h = await auth_headers("u1") + pid = await _verified_place(client, h, kakao="sum1") + long_intro = "조용한 숙소입니다. " * 30 # 200자 초과 + await _crawl(client, h, pid, "intro", long_intro, SourceType.LLM) + + facts = await _facts(client, h, pid) + fact = next(f for f in facts["facts"] if f["key"] == "intro") + assert fact["value"] == long_intro + assert fact["summary"] == "축약된 소개문입니다." + + +async def test_short_intro_has_no_summary(auth_headers, client, monkeypatch): + """검증: intro 가 임계치보다 짧다. + 기대결과: summary 가 비어 있고, 요약 API 는 아예 불리지 않는다.""" + calls = [] + async def _fake_summarize(text, **_kwargs): + calls.append(text) + return "호출되면 안 된다" + monkeypatch.setattr(gt, "summarize_text", _fake_summarize) + + h = await auth_headers("u1") + pid = await _verified_place(client, h, kakao="sum2") + await _crawl(client, h, pid, "intro", "조용한 숙소입니다.", SourceType.LLM) + + facts = await _facts(client, h, pid) + fact = next(f for f in facts["facts"] if f["key"] == "intro") + assert fact.get("summary") is None + assert calls == [], "짧은 문장인데 요약 API 를 불렀다" + + +async def test_non_intro_fact_never_gets_summary(auth_headers, client, monkeypatch): + """검증: 길어도 intro/room_intro 가 아닌 key(예: cancel_policy). + 기대결과: 대상 key 가 아니므로 summary 를 만들지 않는다.""" + calls = [] + async def _fake_summarize(text, **_kwargs): + calls.append(text) + return "호출되면 안 된다" + monkeypatch.setattr(gt, "summarize_text", _fake_summarize) + + h = await auth_headers("u1") + pid = await _verified_place(client, h, kakao="sum3") + await _own(client, h, pid, "cancel_policy", "환불 규정 안내입니다. " * 30) + + facts = await _facts(client, h, pid) + fact = next(f for f in facts["facts"] if f["key"] == "cancel_policy") + assert fact.get("summary") is None + assert calls == []