"""SiteOntology(o2o-site-ontology) 클라이언트 — 이 가게에 맞는 검색 키워드를 받아 온다. SiteOntology 는 펜션 SEO/AEO 키워드 사전(pgvector)을 들고, 업체 프로필에 맞는 단어를 골라 주는 사내 서비스다. 여기서는 창구 두 개만 쓴다(실측 2026-09-14, 로컬 :3100). POST /v1/merchants/publish {externalId, name, industryId, regionId, description, profile, generate:false} → 201 {merchant, generation: "skipped"} 업체를 저장만 한다. 키워드는 주지 않는다 POST /v1/match {query: , limit} → 201 {resolved, lanes, matches[], byLane[], excluded[]} 저장된 프로필로 추천한다 ★ 두 번 부르는 이유: "프로필을 주면 키워드를 돌려주는" 창구가 한 번에는 없다. match 는 query 문자열만 받고, externalId 를 넣으면 저장된 업체로 해석해 그 프로필로 추천한다. 그래서 먼저 publish 로 프로필을 이번 빌드의 스냅샷 값으로 맞춘다. ★ generate:false 를 반드시 보낸다. 빠뜨리면 SiteOntology 가 LLM 키워드 생성을 큐에 넣는다 — 그 결과는 우리가 쓰지 않는 창구(/seo)로만 나가고, 로컬은 mock LLM 이라 가짜 단어가 사전에 쌓인다. ★ regionId 가 SiteOntology 의 region 표에 없으면 **500** 이다(외래키 위반, 실측). 그 표는 적재한 데이터셋에 따라 달라서(군산만 / 전국 54개) 우리가 알 수 없다 — 500 이면 regionId 를 비워 한 번 더 보낸다. 지역이 비면 유형 추천에서 "군산" 이 빠져 품질이 떨어지지만(실측: 1위가 `독채펜션`), 없는 것보다 낫고 지역 없는 단어는 호출측 거르기(seo_keywords)가 제목에서 뺀다. ★ externalId 가 해석되지 않으면 match 는 에러가 아니라 **입력 문자열 자체로 검색한 결과**를 201 로 준다 (실측: "no-such-place-id" → `나운동 숙소`·`선유도 숙소 예약 언제 해야 하나요`). 그걸 쓰면 남의 동네 단어가 나간다 — resolved 가 우리 externalId 가 아니면 실패로 본다. ★ 실패는 발행을 막지 않는다. 여기서는 SiteOntologyError 로 올리고, 호출측이 잡아 키워드 없이 굽는다. """ import httpx from common.logger import LOG from config.server_configs import external_api_config # 로컬 임베딩 모델이라 호출당 1~2초다. 넉넉히 잡되 발행 잡 데드라인(900s)을 잡아먹지 않게 끊는다. TIMEOUT_SEC = 15.0 # 거르기(seo_keywords)에서 절반 넘게 떨어진다(실측: 10건 → 4건). 메타 태그 10개를 채우려면 넉넉히 받는다. DEFAULT_LIMIT = 40 PUBLISH_PATH = "/v1/merchants/publish" MATCH_PATH = "/v1/match" class SiteOntologyError(RuntimeError): """SiteOntology 호출 실패(네트워크·타임아웃·4xx/5xx·업체 미해석). 발행은 계속된다.""" def base_url() -> str: return (external_api_config.site_ontology_url or "").strip().rstrip("/") def is_configured() -> bool: return bool(base_url()) async def _post(client: httpx.AsyncClient, path: str, body: dict) -> dict: res = await client.post(f"{base_url()}{path}", json=body) if res.status_code >= 400: raise SiteOntologyError(f"{path} HTTP {res.status_code}: {res.text[:200]}") try: data = res.json() except ValueError as ex: raise SiteOntologyError(f"{path} 응답이 JSON 이 아니다: {res.text[:200]}") from ex if not isinstance(data, dict): raise SiteOntologyError(f"{path} 응답 모양이 다르다: {type(data).__name__}") return data async def match_for_merchant(merchant: dict, limit: int = DEFAULT_LIMIT) -> dict: """업체를 저장하고, 그 업체로 해석된 추천 결과(/v1/match 응답)를 돌려준다. merchant 는 publish 요청 본문이다(generate 는 여기서 붙인다). 실패하면 SiteOntologyError.""" body = {**merchant, "generate": False} try: async with httpx.AsyncClient(timeout=TIMEOUT_SEC) as client: try: await _post(client, PUBLISH_PATH, body) except SiteOntologyError as ex: if not body.get("regionId"): raise LOG.w(f"[site-ontology] regionId={body['regionId']} 로 저장하지 못했다 — 지역 없이 다시 보낸다: {ex}") body = {**body, "regionId": None} await _post(client, PUBLISH_PATH, body) result = await _post(client, MATCH_PATH, {"query": merchant["externalId"], "limit": limit}) except httpx.HTTPError as ex: raise SiteOntologyError(f"{type(ex).__name__}: {ex}") from ex resolved = result.get("resolved") or {} if resolved.get("externalId") != merchant["externalId"]: raise SiteOntologyError(f"업체가 해석되지 않았다 — externalId={merchant['externalId']}") return result