o2o-site-AEO/solution/backend/tests/test_seo_keywords.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

299 lines
14 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"]
# generate:false 가 빠지면 SiteOntology 가 LLM 키워드 생성을 큐에 넣는다.
assert sent[0][1] == {**merchant, "generate": False}
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