o2o-site-AEO/solution/backend/tests/test_seo_keywords.py
hbyang 94c4116689 [feat] solution/backend: 발행 때 온톨로지 키워드 생성을 켠다 — 호텔 판정 · 업종별 유형어 게이트
generate:false 로 불러 새 숙소가 사전 밖 단어를 받지 못했다. 생성을 켜면 호텔이 펜션 사전의
`군산 독채펜션` 을 받는데, 메타 게이트가 '펜션' 을 업종어로 보고 자료 없이 통과시켰다.

- site_ontology: generate+sync — 처음 보는 숙소만 저쪽이 기다리게 하고 publish 상한 60초
- seo_keywords: 외부 분류(없으면 상호)에 '호텔' 이면 stay.hotel, 자기 유형어만 자료 없이 통과
- snapshot: place.external_category 를 실음 — 호텔 판정 근거
- .env.example · ARCHITECTURE · DEVLOG: ONTOLOGY_LLM_PROVIDER, 발행 흐름, 실측

test_seo_keywords 20 passed(호텔 4건 추가). 전체 1041 passed / 47 failed —
47건은 이 변경 전 main 과 같은 목록

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 13:49:37 +09:00

328 lines
16 KiB
Python

"""발행 사이트 메타 태그 키워드(SiteOntology)."""
import json
import uuid
import httpx
import pytest
from sqlalchemy import text
from common.enums import JobStatus, LocalContentType, MediaStatus, PlaceCategory, SiteStatus, SourceType
from crud.job_crud import JobQueue
from services import seo_keywords
from services.external import site_ontology
from worker.handlers import build_handler
from worker.runner import Worker
def _kw(canonical, category, intent="local", status="ok"):
return {"canonical": canonical, "category": category, "intent": intent, "kind": "keyword", "status": status}
MATCH = {
"mode": "fusion",
"matches": [
_kw("군산 펜션 독채", "시설"),
_kw("군산 독채펜션", "코어"),
_kw("군산 독채 마당 펜션", "롱테일"),
_kw("군산 독채 복층 펜션", "롱테일"),
_kw("군산 커플 프라이빗 펜션", "롱테일"),
_kw("군산 독채펜션 예약", "코어", intent="transactional"),
_kw("군산에 독채 펜션 있나요", "질문형", intent="informational"),
_kw("군산 가족 독채펜션", "동반자"),
_kw("말랭이마을 근처 숙소", "관광지"),
_kw("군산 프라이빗한 독채", "분위기"),
_kw("선유도 독채펜션", "권역"),
_kw("군산 펜션 최저가", "의도", intent="transactional"),
_kw("군산 주차 펜션", "시설", status="hold"),
],
"byLane": [
{"key": "type", "label": "유형", "items": [
_kw("군산 펜션 독채", "시설"),
_kw("군산 독채펜션", "코어"),
_kw("군산 독채 마당 펜션", "롱테일"),
]},
{"key": "place", "label": "위치", "items": [_kw("말랭이마을 근처 숙소", "관광지")]},
],
}
def _fact(key, value, unit_id=None):
return {"key": key, "value": value, "scope": "unit" if unit_id else "place", "unit_id": unit_id, "status": 3}
def _snapshot(**place):
return {
"place": {
"name": "스테이,머뭄", "category": PlaceCategory.LODGING.value,
"road_address": "전북특별자치도 군산시 절골길 18", "address": None, **place,
},
"facts": [
_fact("intro", "군산 원도심 말랭이마을 옆 독채 스테이.\n두 번째 문단은 싣지 않는다."),
_fact("bbq_available", "false"),
_fact("parking", "true"),
_fact("room_type", "독채", unit_id="u1"),
_fact("max_capacity", "4", unit_id="u1"),
_fact("max_capacity", "2", unit_id="u2"),
],
"local": {"contents": [
{"content_type": LocalContentType.ATTRACTION.value, "title": "말랭이마을", "body": {"name": "말랭이마을"}},
{"content_type": LocalContentType.RESTAURANT.value, "title": "이성당", "body": {"name": "이성당"}},
{"content_type": LocalContentType.ATTRACTION.value, "title": "동국사", "body": {}},
]},
}
# ── 요청 만들기 ─────────────────────────────────────────────────────────────
def test_스냅샷으로_업체_프로필을_만든다():
"""검증: 확인된 fact·지역 정보가 든 숙박 스냅샷."""
merchant = seo_keywords.build_merchant("place-1", _snapshot())
assert merchant == {
"externalId": "place-1",
"name": "스테이,머뭄",
"industryId": "stay.pension",
"regionId": "kr.jeonbuk.gunsan",
"description": "군산 원도심 말랭이마을 옆 독채 스테이.",
"profile": {
"address": "전북특별자치도 군산시 절골길 18",
"capacity": {"max": 4},
"features": ["주차", "독채"],
# 음식점(이성당)은 주변 관광지가 아니다.
"nearby": ["말랭이마을", "동국사"],
# 바베큐는 false(없음)라 여기 없다 — 넣으면 SiteOntology 가 배제 대신 보류한다.
"unverified": ["애견동반", "조식"],
},
}
def test_숙박이_아니면_부르지_않는다():
assert seo_keywords.build_merchant("place-1", _snapshot(category=PlaceCategory.CAFE.value)) is None
def test_표에_없는_지역은_지역을_비운다():
"""검증: SiteOntology 지역 표(54개)에 없는 시·군."""
merchant = seo_keywords.build_merchant("place-1", _snapshot(road_address="서울특별시 강남구 테헤란로 1"))
assert merchant["regionId"] is None
def test_같은_이름의_시군은_시도로_가른다():
assert seo_keywords.region_key("강원특별자치도 고성군 토성면 1") == "kr.gangwon.goseong"
assert seo_keywords.region_key("경상남도 고성군 고성읍 1") is None
# ── 거르기 ─────────────────────────────────────────────────────────────────
def test_자료가_뒷받침하는_키워드만_남긴다():
"""검증: 실제 추천 결과를 이 가게 자료(주차·독채·원도심·말랭이마을·동국사)로 거른다."""
merchant = seo_keywords.build_merchant("place-1", _snapshot())
seo = seo_keywords.select(MATCH, merchant)
assert seo["keywords"] == ["군산 펜션 독채", "군산 독채펜션", "군산 독채펜션 예약", "말랭이마을 근처 숙소"]
def test_제목_키워드는_유형_레인의_코어를_고른다():
merchant = seo_keywords.build_merchant("place-1", _snapshot())
assert seo_keywords.select(MATCH, merchant)["titleKeyword"] == "군산 독채펜션"
def test_제목에는_예약_추천과_지역_없는_단어를_쓰지_않는다():
merchant = seo_keywords.build_merchant("place-1", _snapshot())
result = {"matches": [], "byLane": [{"key": "type", "items": [
_kw("군산 독채펜션 예약", "코어", intent="transactional"),
_kw("독채펜션", "태그"),
_kw("군산 펜션 독채", "시설"),
]}]}
assert seo_keywords.select(result, merchant) == {"keywords": [], "titleKeyword": "군산 펜션 독채"}
def test_업종어뿐인_단어는_싣지_않는다():
merchant = seo_keywords.build_merchant("place-1", _snapshot())
result = {"matches": [_kw("숙소", "태그"), _kw("펜션 예약", "의도"), _kw("원도심", "태그")], "byLane": []}
assert seo_keywords.select(result, merchant)["keywords"] == ["원도심"]
def test_표기만_다른_중복은_하나로():
merchant = seo_keywords.build_merchant("place-1", _snapshot())
result = {"matches": [_kw("군산 독채펜션", "코어"), _kw("군산 독채 펜션", "태그")], "byLane": []}
assert seo_keywords.select(result, merchant)["keywords"] == ["군산 독채펜션"]
# ── 클라이언트 ────────────────────────────────────────────────────────────
def _response(status, body, url):
return httpx.Response(status, json=body, request=httpx.Request("POST", url))
@pytest.fixture
def ontology_url(monkeypatch):
monkeypatch.setattr(site_ontology.external_api_config, "site_ontology_url", "http://onto.test/")
def _fake_ontology(sent, *, reject_region=False, resolve=True):
async def fake_post(self, url, json=None, **kwargs):
sent.append((url, json))
if url.endswith(site_ontology.PUBLISH_PATH):
if reject_region and json.get("regionId"):
return _response(500, {"statusCode": 500, "message": "Internal server error"}, url)
return _response(201, {"merchant": {"external_id": json["externalId"]}, "generation": "skipped"}, url)
resolved = {"externalId": json["query"]} if resolve else None
return _response(201, {**MATCH, "resolved": resolved}, url)
return fake_post
async def test_업체를_저장한_뒤_그_업체로_추천을_받는다(ontology_url, monkeypatch):
sent = []
monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology(sent))
merchant = seo_keywords.build_merchant("place-1", _snapshot())
await site_ontology.match_for_merchant(merchant)
assert [url for url, _ in sent] == ["http://onto.test/v1/merchants/publish", "http://onto.test/v1/match"]
# 처음 보는 업체는 생성을 기다려 첫 발행부터 새 키워드가 실린다(sync 는 저쪽이 처음 보는 업체에만 먹인다).
assert sent[0][1] == {**merchant, "generate": True, "sync": True}
assert sent[1][1] == {"query": "place-1", "limit": site_ontology.DEFAULT_LIMIT}
async def test_지역_키가_거절되면_지역_없이_다시_보낸다(ontology_url, monkeypatch):
sent = []
monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology(sent, reject_region=True))
merchant = seo_keywords.build_merchant("place-1", _snapshot())
await site_ontology.match_for_merchant(merchant)
publishes = [body for url, body in sent if url.endswith(site_ontology.PUBLISH_PATH)]
assert [b["regionId"] for b in publishes] == ["kr.jeonbuk.gunsan", None]
assert sent[-1][0].endswith(site_ontology.MATCH_PATH)
async def test_업체가_해석되지_않은_추천은_쓰지_않는다(ontology_url, monkeypatch):
"""검증: match 가 resolved=null 로 201 을 준다(입력 문자열로 검색한 결과)."""
monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology([], resolve=False))
merchant = seo_keywords.build_merchant("place-1", _snapshot())
with pytest.raises(site_ontology.SiteOntologyError):
await site_ontology.match_for_merchant(merchant)
assert await seo_keywords.fetch("place-1", _snapshot()) is None
async def test_주소가_비면_부르지_않는다(monkeypatch):
monkeypatch.setattr(site_ontology.external_api_config, "site_ontology_url", "")
async def boom(self, url, json=None, **kwargs):
raise AssertionError("설정이 비었는데 SiteOntology 를 불렀다")
monkeypatch.setattr(httpx.AsyncClient, "post", boom)
assert await seo_keywords.fetch("place-1", _snapshot()) is None
async def test_연결이_안_되면_예외를_올리지_않는다(ontology_url, monkeypatch):
async def refused(self, url, json=None, **kwargs):
raise httpx.ConnectError("connection refused", request=httpx.Request("POST", url))
monkeypatch.setattr(httpx.AsyncClient, "post", refused)
assert await seo_keywords.fetch("place-1", _snapshot()) is None
# ── 발행 한 바퀴 ───────────────────────────────────────────────────────────
REQUIRED = {
"check_in_time": "15:00", "check_out_time": "11:00",
"cancel_policy": "이용 7일 전 100% 환불, 3일 전 50% 환불",
"cooking_allowed": "true", "pet_allowed": "false",
}
async def _published_place(client, h, db_engine):
pid = (await client.post("/v1/place", headers=h, json={"name": "하조대독채", "category": 1})).json()["place"]["place_id"]
await client.post(f"/v1/place/{pid}/verify", headers=h, json={
"source": 2, "road_address": f"강원특별자치도 양양군 현북면 하조대3길 {uuid.uuid4().hex[:3]}",
"phone": "033-672-0000", "latitude": "38.0219217", "longitude": "128.7221449",
})
for k, v in {**REQUIRED, "intro": "하조대 해변 도보 3분 거리의 독채 펜션입니다.", "facilities": "독채"}.items():
await client.post(f"/v1/place/{pid}/fact", headers=h, json={"key": k, "value": v})
async with db_engine.begin() as c:
await c.execute(
text("INSERT INTO place_photos (media_id, place_id, url, origin_url, source_type, status, alt_text, label, sort_order) "
"VALUES (:m,:p,:u,:u,:s,:st,:a,:l,0)"),
{"m": uuid.uuid4(), "p": uuid.UUID(pid), "u": f"https://cdn.test/{uuid.uuid4().hex[:6]}.jpg",
"s": SourceType.CRAWL.value, "st": MediaStatus.APPROVED.value, "a": "2층 목조 건물 외관", "l": "외관"},
)
return pid
async def _build(client, h, pid) -> dict:
job_id = (await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True})).json()["job_id"]
assert await Worker("w", JobQueue(), build_handler(), job_deadline_sec=60).process_one() is True
job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]
assert job["status"] == JobStatus.DONE.value, job.get("last_error")
return job["result"]
async def test_발행_payload_에_키워드가_실린다(auth_headers, client, db_engine, ontology_url, monkeypatch):
"""검증: SiteOntology 가 켜진 채로 숙박 사업장을 발행한다."""
h = await auth_headers("u1")
pid = await _published_place(client, h, db_engine)
sent = []
async def fake_match(merchant, limit=site_ontology.DEFAULT_LIMIT):
sent.append(merchant)
return {"resolved": {"externalId": merchant["externalId"]},
"matches": [_kw("양양 독채펜션", "코어"), _kw("양양 독채 마당 펜션", "롱테일")],
"byLane": [{"key": "type", "items": [_kw("양양 독채펜션", "코어")]}]}
monkeypatch.setattr(site_ontology, "match_for_merchant", fake_match)
r = await _build(client, h, pid)
assert r["published"] is True
assert sent[0]["externalId"] == pid
assert sent[0]["regionId"] == "kr.gangwon.yangyang"
assert "독채" in sent[0]["profile"]["features"]
payload = json.loads(open(r["payload_path"], encoding="utf-8").read())
assert payload["seo"] == {"keywords": ["양양 독채펜션"], "titleKeyword": "양양 독채펜션"}
assert r["seo"] == payload["seo"]
async def test_SiteOntology_가_죽어도_발행된다(auth_headers, client, db_engine, ontology_url, monkeypatch):
h = await auth_headers("u1")
pid = await _published_place(client, h, db_engine)
async def down(merchant, limit=site_ontology.DEFAULT_LIMIT):
raise site_ontology.SiteOntologyError("ConnectError: connection refused")
monkeypatch.setattr(site_ontology, "match_for_merchant", down)
r = await _build(client, h, pid)
assert r["published"] is True
assert "seo" not in r
site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()
assert site["site"]["status"] == SiteStatus.PUBLISHED.value
payload = json.loads(open(r["payload_path"], encoding="utf-8").read())
assert "seo" not in payload
# ── 호텔 ───────────────────────────────────────────────────────────────────
def test_외부_분류에_호텔이_있으면_호텔_업종으로_보낸다():
merchant = seo_keywords.build_merchant("place-1", _snapshot(external_category="여행 > 숙박 > 호텔"))
assert merchant["industryId"] == "stay.hotel"
def test_외부_분류가_없으면_상호로_가른다():
assert seo_keywords.build_merchant("place-1", _snapshot(name="군산 베이 호텔"))["industryId"] == "stay.hotel"
assert seo_keywords.build_merchant("place-1", _snapshot())["industryId"] == "stay.pension"
def _item(canonical):
return {"canonical": canonical, "status": "ok", "category": "코어"}
def test_호텔에는_펜션_유형어를_싣지_않는다():
merchant = seo_keywords.build_merchant("place-1", _snapshot(name="군산 베이 호텔"))
result = {"matches": [_item("군산 독채펜션"), _item("군산 독채 호텔"), _item("말랭이마을 숙소")]}
assert seo_keywords.select(result, merchant)["keywords"] == ["군산 독채 호텔", "말랭이마을 숙소"]
def test_펜션에는_호텔_유형어를_싣지_않는다():
merchant = seo_keywords.build_merchant("place-1", _snapshot())
result = {"matches": [_item("군산 독채 호텔"), _item("군산 독채펜션")]}
assert seo_keywords.select(result, merchant)["keywords"] == ["군산 독채펜션"]