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