o2o-site-AEO/solution/backend/tests/test_perplexity.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

420 lines
16 KiB
Python

"""Perplexity 채널 발견 클라이언트 — 계약 검증(실제 API 호출 없음)."""
import httpx
import pytest
from common.enums import LinkChannel
# 겹마다 사는 곳이 다르다(services/llm/__init__.py 의 설명 참조).
from services.grounding.channels import (
MAX_LINKS,
MAX_LINKS_PER_CHANNEL,
REASON_BLOG,
REASON_CATEGORY,
REASON_OVERFLOW,
REASON_ROOT,
DiscoveredLink,
classify_url,
)
from services.llm import perplexity as llm_perplexity
from services.llm.perplexity import PerplexityError, PerplexityNotConfigured
from services.external import perplexity
from services.external.perplexity import ChannelDiscovery, discover_channels
@pytest.fixture(autouse=True)
def _api_key(monkeypatch):
"""모든 테스트가 '키 설정됨' 상태에서 시작한다(미설정 케이스는 개별 테스트가 덮어쓴다)."""
monkeypatch.setattr(llm_perplexity.external_api_config, "perplexity_api_key", "pplx-test-key")
def _client(handler) -> httpx.AsyncClient:
"""MockTransport 로 네트워크를 끊은 클라이언트."""
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
def _ok_payload(content: str, search_results=None, usage=None) -> dict:
return {
"choices": [{"message": {"role": "assistant", "content": content}}],
"search_results": search_results if search_results is not None else [],
"usage": usage if usage is not None else {"total_tokens": 500, "num_search_queries": 3},
}
# ── 정상 파싱 ────────────────────────────────────────────────────────────
async def test_discover_returns_links_raw_and_search_count():
"""검증: 정상 응답을 파싱한다."""
payload = _ok_payload(
'{"links": [{"url": "https://www.yanolja.com/pension/1", "title": "하조대펜션"}]}',
search_results=[{"url": "https://place.naver.com/restaurant/123", "title": "하조대펜션"}],
usage={"total_tokens": 812, "num_search_queries": 4},
)
async def handler(request):
return httpx.Response(200, json=payload)
async with _client(handler) as c:
result = await discover_channels("하조대펜션", client=c)
assert isinstance(result, ChannelDiscovery)
assert result.search_count == 4
assert result.raw == payload, "raw 는 응답 원문을 통째로 보존해야 한다(환각 추적용)"
urls = [x.url for x in result.links]
assert "https://www.yanolja.com/pension/1" in urls
assert "https://place.naver.com/restaurant/123" in urls
async def test_request_pins_schema_and_domain_filter():
"""검증: 실제로 나가는 요청 본문."""
seen = {}
async def handler(request):
seen["body"] = httpx.Request("POST", request.url, content=request.content).content
seen["auth"] = request.headers.get("Authorization")
import json as _json
seen["json"] = _json.loads(request.content)
return httpx.Response(200, json=_ok_payload('{"links": []}'))
async with _client(handler) as c:
await discover_channels("테스트업소", client=c)
body = seen["json"]
assert seen["auth"] == "Bearer pplx-test-key"
assert body["model"] == "sonar"
assert body["response_format"]["type"] == "json_schema", "구조화 출력으로 파싱 실패를 줄여야 한다"
# 네이버는 호스트가 셋으로 나뉜다.
assert set(body["search_domain_filter"]) == {
"yanolja.com", "goodchoice.kr", "place.naver.com", "map.naver.com", "naver.me",
}
assert "naver.com" not in body["search_domain_filter"], "넓은 naver.com 은 블로그를 끌어온다"
assert body["max_tokens"] >= 1024, "구조화 출력이 잘리면 파싱이 깨진다 — 넉넉해야 한다"
async def test_duplicate_urls_are_collapsed():
"""검증: 모델 답변과 search_results 에 같은 URL 이 중복으로 들어온다."""
url = "https://www.yanolja.com/pension/7"
async def handler(request):
return httpx.Response(200, json=_ok_payload(
f'{{"links": [{{"url": "{url}"}}]}}',
search_results=[{"url": url, "title": "중복"}],
))
async with _client(handler) as c:
result = await discover_channels("중복테스트", client=c)
assert [x.url for x in result.links] == [url]
async def test_non_http_urls_are_dropped():
"""검증: 모델이 http(s) 가 아닌 문자열을 URL 자리에 넣는다."""
async def handler(request):
return httpx.Response(200, json=_ok_payload(
'{"links": [{"url": "야놀자에서 검색하세요"}, {"url": "javascript:alert(1)"},'
' {"url": "https://www.yanolja.com/pension/9"}]}'
))
async with _client(handler) as c:
result = await discover_channels("쓰레기필터", client=c)
assert [x.url for x in result.links] == ["https://www.yanolja.com/pension/9"]
# ── URL → 채널 매핑 ──────────────────────────────────────────────────────
@pytest.mark.parametrize(
"url,expected",
[
("https://www.yanolja.com/pension/123", LinkChannel.YANOLJA),
("https://place.goodchoice.kr/product/detail/1", LinkChannel.GOODCHOICE),
("https://www.yeogi.com/domestic-accommodations/1", LinkChannel.GOODCHOICE),
("https://place.naver.com/restaurant/1234567", LinkChannel.NAVER_PLACE),
("https://m.place.naver.com/accommodation/1", LinkChannel.NAVER_PLACE),
("https://naver.me/xAbCdEf", LinkChannel.NAVER_PLACE),
("https://www.instagram.com/some_pension/", LinkChannel.INSTAGRAM),
("https://blog.naver.com/someone/12345", LinkChannel.BLOG),
("https://cafe.naver.com/board/1", LinkChannel.BLOG),
("https://example.co.kr/", LinkChannel.ETC),
("not-a-url", LinkChannel.ETC),
("", LinkChannel.ETC),
],
)
def test_classify_url(url, expected):
"""검증: URL 호스트로 채널을 판정한다."""
assert classify_url(url) is expected
async def test_links_carry_channel_code():
"""검증: 발견된 링크에 채널 코드가 붙는지."""
async def handler(request):
return httpx.Response(200, json=_ok_payload(
'{"links": [{"url": "https://www.yanolja.com/pension/1"},'
' {"url": "https://place.goodchoice.kr/product/1"},'
' {"url": "https://somewhere.example/x"}]}'
))
async with _client(handler) as c:
result = await discover_channels("채널매핑", client=c)
assert [x.channel for x in result.links] == [
LinkChannel.YANOLJA,
LinkChannel.GOODCHOICE,
LinkChannel.ETC,
]
# ── ★ URL 발견 전용 계약 ─────────────────────────────────────────────────
async def test_facts_in_answer_do_not_leak_into_result():
"""검증: 응답 본문에 주소·전화·체크인시간 같은 '사실'이 섞여 들어온다."""
async def handler(request):
return httpx.Response(200, json=_ok_payload(
'{"links": [{"url": "https://www.yanolja.com/pension/1", "title": "하조대펜션"}],'
' "address": "강원 양양군 현북면 하조대해안길 3", "phone": "033-672-0000",'
' "check_in": "15:00", "price": 180000}'
))
async with _client(handler) as c:
result = await discover_channels("하조대펜션", client=c)
assert len(result.links) == 1
link = result.links[0]
assert isinstance(link, DiscoveredLink)
# DiscoveredLink 는 url/channel/title 세 필드뿐이다 — 사실을 담을 자리가 없다.
assert set(vars(link)) == {"url", "channel", "title"}
flat = " ".join(f"{x.url} {x.title}" for x in result.links)
for leaked in ("하조대해안길", "033-672-0000", "15:00", "180000"):
assert leaked not in flat, f"사실이 반환 구조에 새어나왔다: {leaked}"
# 단, raw 에는 원문이 그대로 남아야 한다(추적용) — 이건 사실 근거가 아니라 증거다.
assert "033-672-0000" in str(result.raw)
async def test_prompt_does_not_ask_for_facts():
"""검증: 프롬프트가 무엇을 요구하는지."""
seen = {}
async def handler(request):
import json as _json
seen["json"] = _json.loads(request.content)
return httpx.Response(200, json=_ok_payload('{"links": []}'))
async with _client(handler) as c:
await discover_channels("어떤펜션", address="강원 양양군", client=c)
user_msg = seen["json"]["messages"][-1]["content"]
assert "URL" in user_msg
assert "쓰지 마라" in user_msg or "지어내" in user_msg
# 출력 스키마에도 사실 필드가 없어야 한다.
props = seen["json"]["response_format"]["json_schema"]["schema"]["properties"]
assert set(props) == {"links"}
# ── 실패 처리 ────────────────────────────────────────────────────────────
async def test_missing_api_key_raises_not_configured(monkeypatch):
"""검증: PERPLEXITY_API_KEY 가 비어 있다."""
monkeypatch.setattr(llm_perplexity.external_api_config, "perplexity_api_key", "")
with pytest.raises(PerplexityNotConfigured):
await discover_channels("아무업소")
assert llm_perplexity.is_configured() is False
async def test_blank_name_is_rejected():
"""검증: 상호명 없이 호출한다."""
with pytest.raises(PerplexityError):
await discover_channels(" ")
async def test_timeout_raises_domain_error():
"""검증: 호출이 타임아웃된다."""
async def handler(request):
raise httpx.ReadTimeout("too slow", request=request)
async with _client(handler) as c:
with pytest.raises(PerplexityError, match="타임아웃"):
await discover_channels("느린업소", client=c)
async def test_server_error_raises_domain_error():
"""검증: 5xx 응답."""
async def handler(request):
return httpx.Response(503, text="service unavailable")
async with _client(handler) as c:
with pytest.raises(PerplexityError, match="503"):
await discover_channels("서버오류", client=c)
async def test_broken_structured_output_falls_back_to_search_results():
"""검증: 구조화 출력이 깨진 JSON 으로 온다."""
async def handler(request):
return httpx.Response(200, json=_ok_payload(
"이건 JSON 이 아닙니다",
search_results=[{"url": "https://www.yanolja.com/pension/5", "title": "폴백"}],
))
async with _client(handler) as c:
result = await discover_channels("깨진출력", client=c)
assert [x.url for x in result.links] == ["https://www.yanolja.com/pension/5"]
async def test_empty_result_is_not_an_error():
async def handler(request):
return httpx.Response(200, json=_ok_payload('{"links": []}', usage={"num_search_queries": 2}))
async with _client(handler) as c:
result = await discover_channels("존재하지않는업소", client=c)
assert result.links == []
assert result.search_count == 2
async def test_search_count_falls_back_to_search_results_length():
"""검증: usage 에 검색 횟수가 없다."""
async def handler(request):
return httpx.Response(200, json=_ok_payload(
'{"links": []}',
search_results=[{"url": "https://a.test/1"}, {"url": "https://b.test/2"}],
usage={"total_tokens": 100},
))
async with _client(handler) as c:
result = await discover_channels("카운트없음", client=c)
assert result.search_count == 2
_REAL_JUNK = {
"https://nol.yanolja.com/": REASON_ROOT,
"https://www.goodchoice.kr/": REASON_ROOT,
"https://nol.yanolja.com/sub-home/pension": REASON_CATEGORY,
"https://nol.yanolja.com/programmatic/domestic-accommodation": REASON_CATEGORY,
"https://www.yanolja.com/recommend/ranking?target=W40": REASON_CATEGORY,
"https://blog.naver.com/sodam0526/223364495517": REASON_BLOG,
}
_REAL_DETAIL = [
"https://nol.yanolja.com/stay/domestic/3017828",
"https://place-site.yanolja.com/places/1000099552",
"https://place-site.yanolja.com/places/10054750/607239/665015",
]
async def _discover_with(urls, **kwargs):
"""주어진 URL 들이 모델 응답으로 오는 상황을 만든다."""
import json as _json
links = _json.dumps({"links": [{"url": u} for u in urls]}, ensure_ascii=False)
async def handler(request):
return httpx.Response(200, json=_ok_payload(links))
async with _client(handler) as c:
return await discover_channels("필터테스트", client=c, **kwargs)
@pytest.mark.parametrize("url,reason", sorted(_REAL_JUNK.items()))
async def test_junk_urls_are_filtered_with_reason(url, reason):
"""검증: 실호출에서 나온 쓰레기 URL(루트·카테고리·블로그)을 넣는다."""
result = await _discover_with([url])
assert result.links == [], f"{url} 이 후보로 남았다"
assert (url, reason) in result.filtered_out
@pytest.mark.parametrize("url", _REAL_DETAIL)
async def test_real_detail_urls_pass(url):
"""검증: 실호출에서 나온 진짜 상세 페이지 URL."""
result = await _discover_with([url])
assert [x.url for x in result.links] == [url]
assert result.filtered_out == []
async def test_blogs_pass_when_explicitly_included():
"""검증: include_blogs=True 로 호출한다."""
url = "https://blog.naver.com/sodam0526/223364495517"
default = await _discover_with([url])
opened = await _discover_with([url], include_blogs=True)
assert default.links == []
assert [x.url for x in opened.links] == [url]
async def test_all_filtered_returns_empty_not_forced_pick():
"""검증: 발견된 URL 이 전부 쓰레기다."""
result = await _discover_with(list(_REAL_JUNK))
assert result.links == []
assert len(result.filtered_out) == len(_REAL_JUNK)
async def test_filtered_out_reason_counts():
"""검증: 탈락 사유 집계."""
result = await _discover_with(list(_REAL_JUNK))
counts = result.reason_counts()
assert counts[REASON_ROOT] == 2
assert counts[REASON_CATEGORY] == 3
assert counts[REASON_BLOG] == 1
async def test_too_many_same_channel_urls_are_capped():
urls = [f"https://place-site.yanolja.com/places/100{i:04d}" for i in range(10)]
result = await _discover_with(urls)
assert len(result.links) == MAX_LINKS_PER_CHANNEL
assert [x.url for x in result.links] == urls[:MAX_LINKS_PER_CHANNEL], "앞선 것(모델 선택)이 남아야 한다"
overflow = [u for u, r in result.filtered_out if r == REASON_OVERFLOW]
assert len(overflow) == len(urls) - MAX_LINKS_PER_CHANNEL
async def test_total_cap_across_channels():
"""검증: 여러 채널에서 상세 URL 이 많이 들어온다."""
urls = (
[f"https://place-site.yanolja.com/places/1{i}" for i in range(4)]
+ [f"https://place.goodchoice.kr/product/detail/{i}" for i in range(4)]
+ [f"https://place.naver.com/accommodation/{i}" for i in range(4)]
)
result = await _discover_with(urls)
assert len(result.links) <= MAX_LINKS
async def test_prompt_forbids_root_and_listing_pages():
"""검증: 프롬프트가 무엇을 금지하는지."""
seen = {}
async def handler(request):
import json as _json
seen["json"] = _json.loads(request.content)
return httpx.Response(200, json=_ok_payload('{"links": []}'))
async with _client(handler) as c:
await discover_channels("프롬프트검증", client=c)
msg = seen["json"]["messages"][-1]["content"]
assert "첫 화면" in msg and "목록" in msg
assert "블로그" in msg
async def test_high_search_count_is_warned(caplog):
"""검증: 검색 횟수가 기준을 넘는다."""
from services.external.perplexity import SEARCH_COUNT_WARN_THRESHOLD
async def handler(request):
return httpx.Response(200, json=_ok_payload(
'{"links": []}', usage={"num_search_queries": SEARCH_COUNT_WARN_THRESHOLD + 5}))
async with _client(handler) as c:
result = await discover_channels("비싼검색", client=c)
assert result.search_count == SEARCH_COUNT_WARN_THRESHOLD + 5