[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 from services import story_service
result["local_job_id"] = await story_service.enqueue_region_job(place) 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) await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}장") LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}장")
return result return result

View File

@ -27,6 +27,7 @@ from crud.fact_crud import FactCRUD
from crud.faq_crud import FaqCRUD from crud.faq_crud import FaqCRUD
from crud.place_crud import PlaceCRUD from crud.place_crud import PlaceCRUD
from router.v1.fact.protocol import Req_UpsertFact from router.v1.fact.protocol import Req_UpsertFact
from services import place_research
from services.external import gemini_text from services.external import gemini_text
from services.fact_service import FactService from services.fact_service import FactService
@ -90,15 +91,31 @@ async def run_copy(job: dict) -> dict:
# 왜 fact 로 넣지 않는가: `intro` 는 allow_llm=True 라 LLM 의 출력 칸이다. # 왜 fact 로 넣지 않는가: `intro` 는 allow_llm=True 라 LLM 의 출력 칸이다.
# 원문을 그 칸에 넣었더니 457자 원문이 발행본의 '숙소 소개' 를 차지했다(2026-08-31). # 원문을 그 칸에 넣었더니 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( l_err, link_rows = await DB_SESSION_MNG.execute_lambda(
place_channels.DBType(), place_channels.DBType(),
DBWRType.DB_READ.value, 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: if l_err == ErrorType.SUCCESS:
for link in (link_rows or []): 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: if text:
# ★ fact 목록이 아니라 records 로 넘긴다. fact 자리에 넣으면 모델이 값 하나로
# 읽고 거의 쓰지 않는다(prompts/copy.build_prompt 머리주석의 실측).
records.append(text[:4000])
# ground_check 는 여전히 이 글을 근거로 인정해야 한다 — 근거 목록에도 남긴다.
grounded.append(gemini_text.FactInput( grounded.append(gemini_text.FactInput(
key=f"source:{link.link_id}", label="수집 원문", value=text[:4000], key=f"source:{link.link_id}", label="수집 원문", value=text[:4000],
)) ))
@ -148,6 +165,7 @@ async def run_copy(job: dict) -> dict:
PlaceCategory(place.category), PlaceCategory(place.category),
grounded, grounded,
unit_summaries=unit_summaries or None, unit_summaries=unit_summaries or None,
records=records or None,
model=external_api_config.gemini_text_model, model=external_api_config.gemini_text_model,
) )
except gemini_text.GeminiNotConfigured as ex: except gemini_text.GeminiNotConfigured as ex:

View File

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