o2o-site-AEO/solution/backend/services/grounding/place_research.py
hbyang 7b238efffb [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).
2026-09-10 14:17:44 +09:00

91 lines
3.8 KiB
Python

"""업소 조사 응답 해석 — 쓸 수 있는 항목만 남긴다.
`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