숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를 받고, 이 가게의 확인된 자료로 거른 것만 `<meta name="keywords">` 와 제목 업종어 자리에 싣는다. 실측(2026-09-14, 스테이머뭄 프로필): 추천 10건 중 `군산 독채 마당 펜션`·`군산 독채 복층 펜션`· `군산 커플 프라이빗 펜션` 이 status=ok 로 왔다 — SiteOntology 사실 필터는 수용 인원과 일부 시설만 본다. 사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다. 메타 태그와 제목은 AI 검색이 그대로 읽는 자리라, 키워드의 모든 낱말이 이 가게 자료에 있을 때만 싣는다. SiteOntology 쪽 함정도 실측으로 막았다 — 없는 regionId 는 500(외래키), 해석 안 된 query 도 201 로 입력 문자열 검색 결과를 준다. - services/external/site_ontology.py: publish(generate:false) → match 두 번 호출. 500 이면 지역 없이 재시도, resolved 가 우리 place_id 가 아니면 실패로 본다 - services/seo_keywords.py: 스냅샷 → 프로필(있음=features · 없음=뺌 · 모름=unverified), 낱말 대조 거르기, 업종어뿐인 단어 제외, 제목은 유형 레인 코어 중 시·군 이름을 품고 예약·추천이 없는 것. 숙박만 - services/build_service.py: 스냅샷 직후 호출해 snapshot["seo"] 에 싣는다(= 발행 기록). 실패해도 발행 계속 - services/site_payload.py · shared site-payload.ts: 선택 필드 `seo` — 옛 payload·목업은 그대로 - site/src/seo/meta.ts · head.ts: 제목 `<상호> · <대표 키워드>`(15자 미만이면 예전 제목), keywords 태그는 키워드가 있을 때만(빈 태그를 만들지 않는다) - config_models.py · .env.example: SITE_ONTOLOGY_URL — 비우면 호출하지 않는다 - docs/ARCHITECTURE.md 발행 파이프라인 · docs/DEVLOG.md pytest tests/test_seo_keywords.py 16 passed · 백엔드 전체 650 passed(실패 2건은 이전부터: test_rate_limit_closes_the_tap · test_사이트_디렉터리_밖의_thumbs_에_올린다) site tsc·eslint 통과 · vitest 63 passed · frontend·admin tsc 통과 실제 발행 한 바퀴(로컬 SiteOntology :3100): 스테이머뭄 → `<title>스테이머뭄 · 군산 독채펜션</title>` + keywords 9건, 렌더 게이트 불일치 0건. SiteOntology 없는 payload 는 제목·head 가 예전 그대로 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LBR4o9Nth3g4eoQEnL1iat
88 lines
4.8 KiB
Python
88 lines
4.8 KiB
Python
"""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: <externalId>, 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
|