여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
299 lines
14 KiB
Python
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
|