feat(backend): fact 목록 응답에 intro/room_intro AI 요약 부착
/v1/place/{id}/fact/list 가 intro·room_intro fact 중 200자를 넘는 것만
Gemini 로 요약해 FactData.summary 에 실어 보낸다. 짧은 값은 API를
부르지 않고, DB에도 저장하지 않는다(응답 전용).
This commit is contained in:
parent
3342981bd2
commit
8920e9b9d0
@ -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
|
||||
|
||||
@ -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 후보.
|
||||
|
||||
@ -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 == []
|
||||
|
||||
Loading…
Reference in New Issue
Block a user