"""업소 조사 — 소개문을 쓸 **재료**를 공개 웹에서 찾아 근거 자리에 넣는다. 프롬프트 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) usage = perplexity.read_usage(payload) LOG.i( f"[research] '{name}' 조사 {len(items)}건 채택, {len(dropped)}건 버림 · " f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${usage.cost}" ) 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}