최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.
backend/ frontend/{admin,site,shared} → solution/{backend,front,site,shared} + admin/
## 왜
내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.
그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
(앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).
## admin 에 백엔드를 두지 않았다
내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.
## admin 의 `@` 는 solution/front/src 를 가리킨다
내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.
admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.
## 그 밖
- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
(conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.
검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
461 lines
20 KiB
Python
461 lines
20 KiB
Python
"""Perplexity 채널 발견 클라이언트 — 계약 검증(실제 API 호출 없음).
|
|
|
|
★ 이 모듈의 계약은 하나다: **URL 만 돌려준다.**
|
|
Perplexity 답변에는 동명 업소와 환각이 섞이므로, 상호명·주소·전화 같은 "사실"이
|
|
반환 구조에 새어나오면 그게 그대로 사이트에 실린다. 그걸 막는 게 여기 테스트의 목적이다.
|
|
"""
|
|
import httpx
|
|
import pytest
|
|
|
|
from common.enums import LinkChannel
|
|
|
|
# ★ 겹마다 사는 곳이 다르다(services/llm/__init__.py 의 설명 참조).
|
|
# 채널 판정·필터 규칙 → grounding, HTTP 호출 → llm, 조립 → external.
|
|
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 로 네트워크를 끊은 클라이언트. 실제 API 를 절대 때리지 않는다."""
|
|
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():
|
|
"""검증: 정상 응답을 파싱한다.
|
|
기대결과: links·raw·search_count 가 모두 채워지고, raw 는 응답 원문 그대로다."""
|
|
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():
|
|
"""검증: 실제로 나가는 요청 본문.
|
|
기대결과: response_format(JSON Schema)로 출력이 고정되고, 도메인이 야놀자·여기어때·
|
|
**네이버 플레이스**로 한정된다. naver.com 으로 넓히면 blog.naver.com 후기가 딸려온다(실측)."""
|
|
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", "구조화 출력으로 파싱 실패를 줄여야 한다"
|
|
# ★ 네이버는 호스트가 셋으로 나뉜다. place.naver.com 하나만 두면 실측상 네이버 링크가
|
|
# **한 건도 안 들어온다**(2026-08-27 '도플로'·'버터브루'): 사람이 공유하는 주소는
|
|
# map.naver.com/p/entry/place/... 이거나 naver.me 단축주소이고, place.naver.com 은
|
|
# 그 뒤 리다이렉트로만 나타난다.
|
|
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 이 중복으로 들어온다.
|
|
기대결과: 1건으로 합쳐진다(place_links 유니크와 충돌하지 않게)."""
|
|
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 호스트로 채널을 판정한다.
|
|
기대결과: 야놀자·여기어때·네이버플레이스·인스타는 정확히, 네이버 블로그/카페는 BLOG,
|
|
모르는 도메인은 ETC 로 떨어진다(버리지 않는다)."""
|
|
assert classify_url(url) is expected
|
|
|
|
|
|
async def test_links_carry_channel_code():
|
|
"""검증: 발견된 링크에 채널 코드가 붙는지.
|
|
기대결과: place_links.channel 에 그대로 넣을 수 있는 LinkChannel 값이 실린다."""
|
|
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():
|
|
"""검증: 응답 본문에 주소·전화·체크인시간 같은 '사실'이 섞여 들어온다.
|
|
기대결과: 반환 구조(links)에는 url·channel·title 만 있고 사실은 하나도 새어나오지 않는다.
|
|
★ Perplexity 답변을 사실로 쓰면 동명 업소·환각이 그대로 사이트에 실린다."""
|
|
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():
|
|
"""검증: 프롬프트가 무엇을 요구하는지.
|
|
기대결과: URL 만 요구하고, 정보를 쓰지 말라고 명시한다 — 물어보면 모델이 지어낸다."""
|
|
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 가 비어 있다.
|
|
기대결과: PerplexityNotConfigured — 이 어댑터만 비활성되고 서버 부팅은 막지 않는다."""
|
|
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():
|
|
"""검증: 상호명 없이 호출한다.
|
|
기대결과: PerplexityError — 검색 요금이 나가기 전에 막는다."""
|
|
with pytest.raises(PerplexityError):
|
|
await discover_channels(" ")
|
|
|
|
|
|
async def test_timeout_raises_domain_error():
|
|
"""검증: 호출이 타임아웃된다.
|
|
기대결과: PerplexityError(→ COLLECT_FETCH_FAILED) — httpx 예외가 그대로 새어나오지 않는다."""
|
|
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 응답.
|
|
기대결과: PerplexityError(→ COLLECT_FETCH_FAILED). 잡은 재시도로 흘러간다."""
|
|
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 으로 온다.
|
|
기대결과: 예외 없이 search_results 의 URL 로 폴백한다 — 검색 요금을 이미 냈으니 버리지 않는다."""
|
|
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 에 검색 횟수가 없다.
|
|
기대결과: search_results 개수로 대체한다 — 검색 요금 추적이 0 으로 비면 안 된다."""
|
|
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
|
|
|
|
|
|
# ── ★ URL 품질 필터 ──────────────────────────────────────────────────────
|
|
# 아래 URL 은 2026-08-27 '핑크비치펜션' 실호출에서 실제로 돌아온 것들이다.
|
|
_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(루트·카테고리·블로그)을 넣는다.
|
|
기대결과: 후보에서 빠지고 filtered_out 에 사유가 남는다 — 조용히 버리지 않는다."""
|
|
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():
|
|
"""검증: 같은 채널 상세 URL 이 우수수 들어온다(실측: 야놀자 12건, 전부 다른 숙소).
|
|
기대결과: 채널당 상한까지만 남고 나머지는 overflow 로 기록된다 —
|
|
사람이 12건을 하나씩 열어보게 만들면 안 된다."""
|
|
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 이 많이 들어온다.
|
|
기대결과: 전체 상한(MAX_LINKS)을 넘지 않는다."""
|
|
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
|