[feat] solution/backend: 업소 조사 — 소개문이 쓸 재료를 출처와 함께 찾아온다

소개문이 "군산시에 있는 스테이,머뭄입니다. 주차 가능." 한 줄이었다. 생성기 잘못이 아니라
**쓸 재료가 그것뿐**이었다 — 네이버 플레이스가 준 fact 3건이 전부이고, TourAPI 는 미등록,
예약 페이지와 인스타그램은 robots 가 자동 수집을 금지한다. 그런데 이 업소의 내력
(1925년 적산가옥 · 히로쓰 가옥 후문 옆 · A동 B동 컨셉)은 블로그·기사에 공개돼 있다.
그걸 가져오는 단계가 없었을 뿐이다.

★ 문장을 검색모델에게 시키지 않는다. 재료만 모으고 소개문은 지금처럼 Gemini 가 쓴다 —
  소개문을 바로 시키면 그 문장의 근거를 우리가 못 갖고, ground_check 가 전부 반려한다.

- services/prompts/place_research.py: 사실 조각을 **출처와 함께** 요구한다. 요금·객실 수·
  체크인·취소 규정은 묻지 않는다 — 그건 fact 이고 블로그의 옛값이 섞이면 예약 클레임이다
- services/grounding/place_research.py: 출처 없는 항목은 버린다(story 와 같은 규율) +
  **상호 대조**를 더한다. 지역 이야기는 틀려도 지역 이야기지만, 업소 조사가 틀리면 남의
  가게 이야기가 이 사장님 소개문이 된다 — 이 레포에서 가장 비싼 실수다
- services/place_research.py: 조사 → place_channels.raw 에 근거 적재. **확정하지 않는다** —
  남이 쓴 글이라 공식 채널·sameAs 로 나가면 안 된다. 새 표를 만들지 않았다
- copy_service: 확정 링크만 읽던 근거를 raw.kind=research 까지 넓혔다. 확정 여부는
  "화면에 채널로 낼 것인가" 의 판단이지 "근거로 읽을 것인가" 의 판단이 아니다
- prompts/copy: 조사 기록을 fact 목록이 아니라 **별도 절**로 준다(fact 자리에 섞으니
  모델이 값 하나로 읽고 안 썼다). 소개문 분량 100~250자 → 200~600자·2~3문단 —
  옛 길이로는 확인된 사실을 나열하면 끝나 기록이 들어갈 자리가 없었다
- collect_service: 수집이 끝난 **뒤** 조사한다. 앞에 두면 네이버·TourAPI 가 이미 준 것을
  다시 묻는 꼴이라 검색 요금이 헛돈다

실측(스테이,머뭄): 조사 8건 채택·0건 버림 → 소개문이
"1925년에 지어진 100년 된 적산가옥을 리노베이션한 숙소 … 히로쓰 가옥 후문 바로 옆" 으로.
발행본 본문 10,798자 → 11,100자. 생성물은 여전히 PENDING_OWNER 로 들어가 사장님이 확인해야
노출된다(절대규칙 1).
This commit is contained in:
hbyang 2026-09-10 14:17:44 +09:00
parent ee51d89e83
commit 7b238efffb
7 changed files with 331 additions and 4 deletions

View File

@ -616,6 +616,18 @@ async def run_collect(job: dict) -> dict:
from services import story_service
result["local_job_id"] = await story_service.enqueue_region_job(place)
# ── 업소 조사 — 소개문을 쓸 재료 ──────────────────────────────────
# ★ 수집이 끝난 **뒤**에 한다. 앞에서 하면 네이버·TourAPI 가 이미 준 것을 다시 묻는
# 꼴이고, 검색 요금이 그만큼 헛돈다. 수집이 얇게 끝났을 때 그 구멍을 메우는 자리다.
# ★ fact 를 만들지 않는다(place_research 머리주석) — 소개문 생성의 근거만 쌓는다.
# ★ 실패해도 수집은 성공이다. 재료가 적을 뿐 발행은 된다.
try:
from services import place_research
result["research"] = await place_research.research_place(place, place_id)
except Exception as ex: # noqa: BLE001
LOG.w(f"[collect] 업소 조사 실패(계속): {type(ex).__name__}: {ex}")
result["research"] = {"error": f"{type(ex).__name__}: {ex}"}
await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}")
return result

View File

@ -27,6 +27,7 @@ from crud.fact_crud import FactCRUD
from crud.faq_crud import FaqCRUD
from crud.place_crud import PlaceCRUD
from router.v1.fact.protocol import Req_UpsertFact
from services import place_research
from services.external import gemini_text
from services.fact_service import FactService
@ -90,15 +91,31 @@ async def run_copy(job: dict) -> dict:
# 왜 fact 로 넣지 않는가: `intro` 는 allow_llm=True 라 LLM 의 출력 칸이다.
# 원문을 그 칸에 넣었더니 457자 원문이 발행본의 '숙소 소개' 를 차지했다(2026-08-31).
# 근거로만 쓰고 저장은 하지 않는다 — 원문은 화면에 나가지 않는다.
# ★ 확정 링크만 읽던 것을 **조사 근거까지** 읽게 넓혔다(2026-09-10).
# 업소 조사(`place_research`)는 남이 쓴 글이라 확정하지 않는다 — 공식 채널이 아니므로
# 발행본의 sameAs·푸터에 나가면 안 된다. 그런데 그것 때문에 여기서도 안 읽혀서,
# 조사해 온 재료가 소개문에 한 글자도 닿지 않았다. 확정 여부는 "화면에 채널로
# 내보낼 것인가" 의 판단이지 "근거로 읽을 것인가" 의 판단이 아니다.
# ★ 다만 아무 미확정 링크나 읽지는 않는다 — raw.kind 가 research 인 것만이다.
# 미확정 채널 URL 은 동명 업소일 수 있고(그게 확정 절차의 이유다), 조사 근거는
# 상호 대조를 통과한 것만 적재된다(`grounding/place_research.parse_items`).
records: list[str] = []
l_err, link_rows = await DB_SESSION_MNG.execute_lambda(
place_channels.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.list_links(s, pid, True),
lambda s: _place_crud.list_links(s, pid, False),
)
if l_err == ErrorType.SUCCESS:
for link in (link_rows or []):
text = ((link.raw or {}).get("text") or "").strip() if isinstance(link.raw, dict) else ""
raw = link.raw if isinstance(link.raw, dict) else {}
if link.confirmed_at is None and raw.get("kind") != place_research.RAW_KIND:
continue
text = (raw.get("text") or "").strip()
if text:
# ★ fact 목록이 아니라 records 로 넘긴다. fact 자리에 넣으면 모델이 값 하나로
# 읽고 거의 쓰지 않는다(prompts/copy.build_prompt 머리주석의 실측).
records.append(text[:4000])
# ground_check 는 여전히 이 글을 근거로 인정해야 한다 — 근거 목록에도 남긴다.
grounded.append(gemini_text.FactInput(
key=f"source:{link.link_id}", label="수집 원문", value=text[:4000],
))
@ -148,6 +165,7 @@ async def run_copy(job: dict) -> dict:
PlaceCategory(place.category),
grounded,
unit_summaries=unit_summaries or None,
records=records or None,
model=external_api_config.gemini_text_model,
)
except gemini_text.GeminiNotConfigured as ex:

View File

@ -96,6 +96,7 @@ async def generate_copy(
facts: list[FactInput],
*,
unit_summaries: Optional[list[dict]] = None,
records: Optional[list[str]] = None,
max_faqs: int = 8,
model: str = DEFAULT_TEXT_MODEL,
max_retries: int = 2,
@ -124,7 +125,7 @@ async def generate_copy(
body = {
"contents": [{"role": "user", "parts": [{
"text": build_prompt(place_name, category, facts, max_faqs, unit_grounding)
"text": build_prompt(place_name, category, facts, max_faqs, unit_grounding, records)
}]}],
"generationConfig": {
"responseMimeType": "application/json",

View File

@ -0,0 +1,90 @@
"""업소 조사 응답 해석 — 쓸 수 있는 항목만 남긴다.
`grounding/story.py` 같은 규율이다. 다른 점은 하나: 여기서 나온 문장은 **화면에 그대로
나가지 않고** 소개문 생성의 근거로만 쓰인다(`copy_service` '수집 원문' 자리). 그래도
출처를 똑같이 요구한다 근거가 거짓이면 근거로 문장도 거짓이고, ground_check
"근거에 있는가" 보지 "근거가 참인가" 본다.
상호 대조를 한다
story 결정적으로 다른 지점이다. 지역 이야기는 틀려도 "군산 이야기가 조금 부정확한"
것이지만, 업소 조사가 틀리면 **남의 가게 이야기가 사장님 사이트의 소개문** 된다
레포에서 가장 비싼 실수다(`collect_service.discover_naver_place` 머리주석).
그래서 출처 URL 이나 문장에 상호가 나타나지 않는 항목은 버린다.
"""
import json
import re
from common.logger import LOG
_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)
def _payload_text(payload: dict) -> str:
choices = payload.get("choices") or []
if not choices or not isinstance(choices[0], dict):
return ""
return ((choices[0].get("message") or {}).get("content")) or ""
def _clean_source(value) -> dict | None:
if not isinstance(value, dict):
return None
url = (value.get("url") or "").strip()
if not url.startswith("http"):
return None
return {"name": (value.get("name") or url).strip(), "url": url}
def _name_tokens(name: str) -> list[str]:
"""상호를 대조에 쓸 조각으로. 쉼표·공백·가운뎃점으로 끊는다.
통짜로 비교하면 된다 '스테이,머뭄' 블로그에서 '스테이 머뭄' · '스테이머뭄' 으로
적힌다. 글자 이상인 조각이 하나라도 걸리면 같은 업소로 본다.
"""
parts = [p for p in re.split(r"[\s,·・/|]+", name or "") if len(p) >= 2]
return parts or ([name] if name else [])
def parse_items(payload: dict, place_name: str, limit: int) -> tuple[list[dict], list[str]]:
"""(쓸 수 있는 항목, 버린 이유). 버린 이유는 로그와 잡 결과에 남긴다 — 조용히 버리면
"조사가 부실한 것" "필터가 과한 것" 구분할 없다."""
text = _FENCE_RE.sub("", _payload_text(payload)).strip()
if not text:
return [], ["응답이 비었다"]
try:
parsed = json.loads(text)
except json.JSONDecodeError as ex:
LOG.w(f"[research] JSON 이 아니다: {ex}")
return [], [f"JSON 파싱 실패: {ex}"]
rows = parsed.get("items") if isinstance(parsed, dict) else parsed
if not isinstance(rows, list):
return [], ["items 배열이 없다"]
tokens = _name_tokens(place_name)
items: list[dict] = []
dropped: list[str] = []
for row in rows[: limit * 2]: # 버려질 것을 감안해 넉넉히 보되, 채택은 limit 까지다
if len(items) >= limit:
break
if not isinstance(row, dict):
dropped.append("항목이 객체가 아니다")
continue
sentence = str(row.get("text") or "").strip()
if not sentence:
dropped.append("빈 문장")
continue
source = _clean_source(row.get("source"))
if not source:
dropped.append(f"출처 없음: {sentence[:30]}")
continue
# ★ 상호 대조(머리주석). 문장과 출처 주소·이름 어디에도 상호가 없으면 남의 가게다.
haystack = f"{sentence} {source['url']} {source['name']}".lower()
if not any(tok.lower() in haystack for tok in tokens):
dropped.append(f"상호가 없다: {sentence[:30]}")
continue
items.append({"text": sentence, "source": source})
return items, dropped

View File

@ -0,0 +1,127 @@
"""업소 조사 — 소개문을 쓸 **재료**를 공개 웹에서 찾아 근거 자리에 넣는다.
프롬프트 services/prompts/place_research.py 무엇을 묻나
호출 services/llm/perplexity.py HTTP·타임아웃·인증
믿을 것인가 services/grounding/place_research.py 출처 필수 · 상호 대조
여기 조사 근거 적재 (문장은 쓰지 않는다)
모듈은 **문장을 쓰지 않는다.** 소개문은 지금처럼 `copy_service`(Gemini) 쓴다.
여기가 하는 일은 생성기에게 재료를 늘리는 것뿐이다. 나누나
소개문을 검색모델에게 바로 시키면 문장의 근거를 우리가 갖지 못하고,
`ground_check` 근거 없는 문장을 전부 반려해 결국 앙상해진다.
필요했나 (실측 2026-09-10, 스테이,머뭄)
근거 fact 9건으로 생성한 소개문은 "군산시에 있는 스테이,머뭄입니다. 주차 가능." 이었다.
네이버 플레이스 3 · TourAPI 미등록 · 예약 페이지와 인스타는 robots 금지 남은 공개
출처가 없어서지 생성기 잘못이 아니었다. 그런데 업소의 내력(1920년대 고택, 히로쓰 가옥
, 2024 리모델링, A동·B동) 블로그·기사에 있다. 그걸 출처와 함께 가져온다.
요금은 조사하지 않는다. 블로그의 요금은 대개 옛값이고, 틀리면 예약 클레임이다.
fact fact 경로(수집·사장님 확인)로만 들어온다 모듈은 `place_facts` 쓰지 않는다.
적재 자리: `place_channels.raw` `copy_service` 이미 '수집 원문' 읽는 자리다.
표를 만들지 않는다. 대신 **확정하지 않는다**(`confirmed_at` NULL) 조사 출처는
업소의 공식 채널이 아니라 남이 글이다. 발행본의 공식 채널·sameAs 나가면 된다.
"""
import uuid
import httpx
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_channels
from common.enums import ErrorType, LinkChannel, SourceType
from common.logger import LOG
from common.utils.gtime import GTime
from crud.place_crud import PlaceCRUD
from services.grounding import place_research as grounding
from services.llm import perplexity
from services.prompts import place_research as prompts
_place_crud = PlaceCRUD()
# 검색을 동반해 느리다. 지역 이야기와 같은 값을 쓴다(실측 건당 9~15초).
_TIMEOUT = 120.0
# ★ raw 봉투의 표식. `copy_service` 가 "확정되지 않았지만 근거로는 읽어도 되는 글" 을
# 이 값으로 가른다. 크롤 원문(확정 채널)과 섞이지 않게 이름을 붙여 둔다.
RAW_KIND = "research"
def _envelope(items: list[dict]) -> dict:
"""근거 봉투. text 는 `copy_service` 가 그대로 읽고, sources 는 추적용으로 남긴다.
문장 뒤에 출처를 붙여 덩어리로 만든다 생성기가 문장만 보고 쓰더라도,
나중에 "이 소개문의 이 대목은 어디서 왔나" raw 열어 보면 있어야 한다.
"""
return {
"kind": RAW_KIND,
"text": "\n".join(f"- {row['text']} (출처: {row['source']['name']})" for row in items),
"sources": [row["source"] for row in items],
"collected_at": GTime.UTC().isoformat(),
}
async def research_place(place, place_id: str) -> dict:
"""업소 하나를 조사해 근거를 적재한다. 채택 건수와 버린 이유를 돌려준다.
실패는 예외로 올리지 않는다 조사가 없어도 발행은 되어야 한다(재료가 적을 뿐이다).
"""
name = (getattr(place, "name", None) or "").strip()
address = (getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip()
if not name or not address:
return {"skipped": "상호·주소를 모른다"}
if not perplexity.is_configured():
return {"skipped": "PERPLEXITY_API_KEY 미설정"}
# 업종 이름은 업종 스키마가 단일 출처다(`common/category_schema`) — 여기에 표를 또 적으면
# 업종이 늘 때 한쪽만 늘어난다.
from common.category_schema import get_schema
category_label = get_schema(place.category).label
body = {
"model": perplexity.DEFAULT_MODEL,
"messages": [
{"role": "system", "content": prompts.SYSTEM_PROMPT},
{"role": "user", "content": prompts.build_prompt(name, address, category_label)},
],
"max_tokens": perplexity.DEFAULT_MAX_TOKENS,
}
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
payload = await perplexity.call(body, client=client)
except perplexity.PerplexityNotConfigured:
return {"skipped": "PERPLEXITY_API_KEY 미설정"}
except perplexity.PerplexityError as ex:
LOG.w(f"[research] 호출 실패 place={place_id}: {ex}")
return {"error": str(ex)}
items, dropped = grounding.parse_items(payload, name, prompts.MAX_ITEMS)
LOG.i(f"[research] '{name}' 조사 {len(items)}건 채택, {len(dropped)}건 버림")
if not items:
return {"items": 0, "dropped": dropped}
# 출처 주소 하나를 대표로 링크에 단다 — 없는 URL 을 만들지 않기 위해 첫 출처를 쓴다.
url = items[0]["source"]["url"]
row = place_channels(
link_id=uuid.uuid4(),
place_id=uuid.UUID(place_id),
channel=LinkChannel.ETC.value,
url=url,
title=f"{name} 조사 근거",
discovered_by=SourceType.API.value,
discovered_at=GTime.UTC(),
raw=_envelope(items),
)
err = await DB_SESSION_MNG.execute_lambda_run(
[place_channels.DBType()], [lambda s: _place_crud.add_link(s, row)],
)
if err != ErrorType.SUCCESS:
# 이미 같은 URL 이 있으면 raw 만 갱신한다 — 재조사가 행을 늘리면 안 된다.
await DB_SESSION_MNG.execute_lambda_claim(
place_channels.DBType(),
lambda s: _place_crud.set_link_raw(s, uuid.UUID(place_id), url, _envelope(items)),
)
return {"items": len(items), "dropped": dropped, "source": url}

View File

@ -55,7 +55,16 @@ def build_prompt(
facts: Sequence[FactLike],
max_faqs: int,
unit_facts: Optional[Sequence[FactLike]] = None,
records: Optional[Sequence[str]] = None,
) -> str:
"""소개문·메타·FAQ 생성 프롬프트.
`records` fact 아니라 ****이다(수집 원문 · 업소 조사 결과).
예전에는 이것도 fact 목록에 `- source:<uuid> (수집 원문) = ` 섞여 들어갔다.
모델은 그걸 하나로 읽고 거의 쓰지 않았다 실측(2026-09-10, 스테이,머뭄):
조사 근거 448자를 넣어도 소개문은 "주방 시설을 갖춘 독채형 객실을 운영하는
숙박업소입니다" 에서 한 발도 못 나갔다. 재료를 재료 자리에 놓아야 쓴다.
"""
sections = [
f"'{place_name}'({_CATEGORY_LABEL.get(category, '사업장')})의 공식 홈페이지 문구를 작성한다.",
"",
@ -64,16 +73,26 @@ def build_prompt(
]
if unit_facts:
sections.extend(["", "확인된 객실·메뉴 사실:", _fact_lines(unit_facts)])
if records:
sections.extend([
"",
"업소에 대해 확인된 기록(출처가 있는 글):",
*(f"- {line}" for line in records),
])
sections.extend([
"",
"출력:",
"- intro: 소개문 100~250자",
# ★ 100~250자였다. 그 길이로는 확인된 사실을 나열하면 끝나서, 기록이 있어도
# 들어갈 자리가 없었다. 시안(/s/stay)의 소개는 3문단 450자 안팎이다.
"- intro: 소개문 200~600자. 사실이 충분하면 2~3문단으로 나눈다(문단 사이 빈 줄)",
"- intro_fact_keys: 소개문의 근거 key",
"- meta_description: 검색 요약 50~120자",
f"- faqs: 최대 {max_faqs}개, 각 항목에 근거 fact_keys 포함",
"",
"규칙:",
"- 위 사실에 없는 숫자·시설·지역 정보를 지어내지 마라.",
"- **기록 절에 있는 내용은 적극적으로 쓴다.** 그것도 출처가 확인된 사실이다 —"
" 건물의 내력·공간 구성·주변과의 관계처럼 이 업소만의 이야기가 거기 있다.",
"- false·불가·없음 값을 가능하다고 표현하지 않는다.",
"- 홍보성·평가성 표현을 쓰지 않는다.",
"- 근거 없는 FAQ는 만들지 않는다.",

View File

@ -0,0 +1,60 @@
"""업소 조사 프롬프트 — 소개문을 쓸 **근거**를 공개 웹에서 찾아온다.
무엇을 요구하나
"소개문을 써 달라" 아니다. **사실 조각을 출처와 함께** 달라고 한다.
소개문을 모델에게 바로 시키면 문장이 어디서 왔는지 없고, 우리 규칙은
근거 없는 문장을 발행하지 않는다(`copy_service.ground_check`). 그래서 단계는
재료만 모으고, 문장은 기존 생성기(Gemini) 재료로 쓴다.
이게 필요한가 (실측 2026-09-10, 스테이,머뭄)
네이버 플레이스가 주는 fact 3(주차·와이파이·휠체어)뿐이고 TourAPI 미등록,
예약 페이지와 인스타그램은 robots 자동 수집을 금지한다. 상태로 소개문을 생성하면
"군산시에 있는 스테이,머뭄입니다. 주차 가능." 줄이 나온다 재료가 그것뿐이라
생성기 잘못이 아니다. 그런데 업소에 대한 사실(1920년대 고택 · 히로쓰 가옥 ·
2024 리모델링 · A동/B동) 블로그·기사에 공개돼 있다. 그걸 **출처와 함께** 가져오는
자리가 없었을 뿐이다.
지어내게 두지 않는다
- 항목마다 출처 URL 요구한다. 없으면 버린다(`grounding/place_research.py`).
- 확인할 없는 것은 비우라고 명시한다. 모델은 빈칸을 싫어해서, 그러면 채운다.
- **가격·객실 ·운영 규정은 묻지 않는다.** 그건 fact 이고, 틀리면 예약 클레임이 난다
출처가 블로그면 요금이 그대로 올라온다. 단계가 모으는 것은 **소개문의 재료**.
"""
SYSTEM_PROMPT = (
"당신은 지역 업소를 조사하는 사람이다. 웹에서 확인되는 사실만 적는다. "
"확인되지 않으면 그 항목을 아예 빼라 — 추측하거나 일반론으로 채우지 마라. "
"출력은 JSON 하나뿐이고 코드펜스를 두르지 않는다."
)
# ★ 최대 개수를 둔다. 많이 받아 봐야 소개문 한 문단이고, 길수록 옛 정보가 섞인다.
MAX_ITEMS = 8
def build_prompt(name: str, address: str, category_label: str) -> str:
"""조사 프롬프트. 상호와 주소를 **둘 다** 준다 — 동명 업소를 가르는 유일한 단서다."""
return f"""다음 업소에 대해 웹에서 확인되는 사실을 모아라.
상호: {name}
주소: {address}
업종: {category_label}
[무엇을 찾나]
- 업소만의 특징: 건물의 내력·연식, 공간 구성, 주변 랜드마크와의 관계, 운영 방식
- 손님이 실제로 겪는 : 어떤 사람이 어떤 목적으로 오는가, 무엇이 인상적이라고 말하는가
- 시기: 문을 , 고쳐 지은
[적지 않을 ]
- 요금·객실 ·체크인 시각·취소 규정 이건 다른 경로로 확인한다. 값이 섞이면 위험하다
- "아름다운", "최고의" 같은 형용사만 있는 문장
- 다른 업소 이야기. 상호와 주소가 위와 일치하는 곳만이다
[문장 쓰는 ]
- ** 문장에 업소 이름을 넣어라.** "이 숙소는…" 처럼 쓰지 마라 문장만 떼어 놔도 어느
업소 이야기인지 있어야 한다. 뒤에서 문장들만 모아 근거로 쓰기 때문이다.
- 문장에 사실 하나. 가지를 이어 붙이면 한쪽이 틀렸을 통째로 버리게 된다.
[형식] 아래 JSON 출력한다. 항목에 ** 사실이 적힌 페이지 주소** 단다.
{{"items": [{{"text": "한 문장으로 적은 사실", "source": {{"name": "출처 이름", "url": "https://..."}}}}]}}
항목은 최대 {MAX_ITEMS}. 출처를 없는 항목은 넣지 마라 적게 주는 편이 낫다."""