o2o-site-AEO/solution/backend/tests/test_itinerary_llm_service.py
Mina Choi 079c93a62a [feat] solution,postgres-init,docs: 생성 진행 상태 · 새로 만들기 존중 · 발행본 색인·파비콘
작업트리에 커밋되지 않은 채 쌓여 있던 것과, 오늘 찾은 문제 셋을 함께 담는다.

## 1. 콘텐츠 생성 진행 상태 (작업트리에 있던 것)

COPY 잡의 실제 단계를 DB에 기록하고 응답으로 내보낸다. 폴링 횟수로 진행률을 흉내 내던
것을 걷어냈다. 새로고침·재접속해도 jobId 로 이어서 본다.

- services/copy_steps.py · services/job_progress.py · common/job_errors.py (신규)
- postgres-init/migrations/0013_job_progress.sql + init.sql
- 프론트: useGenerationJob · generationLabels (신규), Step5Generating·pollJob 배선,
  orval 모델 갱신(jobProgress · jobStep · jobStepStatus · jobStepReason)
- docs/GENERATION_FLOW.md (신규)

## 2. 발행된 사이트만 색인한다

실측(2026-09-15): 디스크의 발행본 33곳 중 **15곳이 draft 인데 `index, follow`** 였고
사이트맵에도 올라가 있었다. 사장님이 발행 버튼을 누른 적 없는 사이트가 짓다 만 상태로
구글에 실려 있었다는 뜻이다.

head.ts 가 robots 를 하드코딩하고 payload 의 `site.status` 를 보지 않았다.
"색인을 막을 이유가 없다"는 주석은 굽는 것이 곧 발행이던 시절의 말인데, 지금은 빌더
미리보기만 눌러도 draft 로 구워진다.

- seo/head.ts: PUBLISHED 일 때만 index, 아니면 `noindex, follow`
- 사이트맵·`/s` 목록·llms.txt 에서도 함께 빠진다 — 그쪽은 구운 HTML 의 robots 를 읽어
  거른다(seo/directory.ts readBakedNoindex). 규칙을 두 자리에 두지 않으려고 한 곳에 뒀다

## 3. [새로 크롤링하고 사이트 생성하기] 를 뒤집지 않는다

ba90a19 의 중복 합치기가 **일부러 다시 만들려는 경우까지** 기존 사업장으로 끌고 갔다 —
새로 만들기를 눌렀는데 기존 에디터가 열린다(사장님 보고 2026-09-15).

- Req_VerifyPlaceByUrl.reuse_existing (기본 True — 다른 호출자의 동작은 그대로)
- place_service.verify_place_by_url: 끄면 이어붙이지 않는다. 다만 **비어 있는 중복 행은
  계속 치운다** — 원래 막으려던 누적이 그것이고 빈 행은 잃을 것이 없다
- ensureServerPlace: 위저드는 새로 만들기 경로에서만 오므로 False 로 보낸다

## 4. 발행본 파비콘

발행본에 파비콘 링크가 아예 없어 브라우저 탭에 기본 아이콘이 떴다. 파일은 오리진 루트의
공용 자산이라 사이트마다 복사하지 않고 루트 절대경로로 가리킨다.

검증: site vitest 84건 통과 · tsc(site·frontend) · eslint 통과.
백엔드 pytest 는 로컬 DB 비밀번호가 맞지 않아 돌리지 못했다(a5b8701 과 같은 자리).
발행본 반영에는 전체 재굽기가 필요하다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:29:27 +09:00

272 lines
11 KiB
Python

"""일정 생성 오케스트레이션 — 언제 부르고 언제 안 부르나.
★ 실제 Perplexity 를 부르지 않는다. 유료·느리고, APP_ENV=test 는 .env 를 안 읽어 키도 없다.
`perplexity.call` 을 가로채 호출 횟수와 프롬프트를 본다.
이 서비스가 지켜야 하는 것:
- 이미 있는 기간은 다시 부르지 않는다(같은 업체는 그대로 재사용 — 2026-09-11 결정)
- 지역을 모르면 아예 부르지 않는다(모델이 아무 도시나 고른다)
- 한 기간이 실패해도 다른 기간은 저장한다
"""
import json
import uuid
import pytest
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_itineraries, places
from common.enums import DBWRType, ErrorType, PlaceCategory, SourceType
from crud.place_itinerary_crud import PlaceItineraryCRUD
from services import itinerary_llm_service as service
from services.llm import perplexity
from services.prompts import itinerary as prompts
from services.prompts.itinerary import DAY_SCHEDULE
class _FakePlace:
"""ORM 행 대신 쓰는 최소 객체 — 서비스는 getattr 로만 읽는다."""
def __init__(self, place_id, name="스테이,머뭄", road_address="전북특별자치도 군산시 절골길 18"):
self.place_id = place_id
self.name = name
self.road_address = road_address
self.address = road_address
def _response(names: list[str], duration: str) -> dict:
"""가짜 Perplexity 응답. `_apply_schedule`(grounding)이 하루 수·시각을 검사하므로
DAY_SCHEDULE 이 요구하는 일수만큼 하루를 채운다 — 안 채우면 전부 드롭된다(실측 회귀)."""
items = [
{"name": n, "duration": duration, "audience": "누구에게나", "why": "이유.",
"days": [
{"stops": [{"name": f"{n}-{day}일차-장소{i}", "minutes": 60, "moveMinutes": 10,
"searchQuery": f"{n}-{day}일차-장소{i}",
"latitude": 35.99, "longitude": 126.71} for i in range(2)]}
for day in range(len(DAY_SCHEDULE[duration]))
]}
for n in names
]
return {
"choices": [{"message": {"content": json.dumps(
{"kind": "itinerary", "version": 1, "items": items}, ensure_ascii=False)}}],
"search_results": [{"title": "군산문화관광", "url": "https://www.gunsan.go.kr/tour/"}],
}
@pytest.fixture
def spy_perplexity(monkeypatch):
"""perplexity.call 을 가로채고 (호출 기록, 응답 설정) 을 준다."""
calls: list[dict] = []
plan: dict = {}
async def fake_call(body, *, client=None):
calls.append(body)
duration = "2박 3일" if "2박 3일" in body["messages"][1]["content"] else "1박 2일"
outcome = plan.get(duration, "ok")
if outcome == "error":
raise perplexity.PerplexityError("일부러 실패")
if outcome == "garbage":
return {"choices": [{"message": {"content": "일정을 만들 수 없습니다."}}]}
return _response([f"{duration} 코스{i}" for i in range(5)], duration)
monkeypatch.setattr(perplexity, "call", fake_call)
monkeypatch.setattr(perplexity, "is_configured", lambda: True)
return calls, plan
async def _rows(place_id):
err, rows = await DB_SESSION_MNG.execute_lambda(
place_itineraries.DBType(), DBWRType.DB_READ.value,
lambda s: PlaceItineraryCRUD().list_by_place(s, place_id),
)
assert err == ErrorType.SUCCESS
return list(rows or [])
async def test_generates_both_durations(db_engine, spy_perplexity):
"""검증: 아무것도 없는 업장.
기대결과: 기간 둘을 각각 만들고 각각 5개 코스를 저장한다.
★ 호출 수는 기간당 MAX_ATTEMPTS 다. 목응답이 5개(< TARGET_COURSES=10)라 서비스가
같은 프롬프트로 한 번 더 부르고, 두 번째 응답은 already_seen 에 걸려 전부 버려진다 —
그래서 호출은 늘어도 저장된 코스는 5개 그대로다."""
calls, _ = spy_perplexity
pid = uuid.uuid4()
out = await service.ensure_generated(_FakePlace(pid))
assert len(calls) == 2 * service.MAX_ATTEMPTS
assert out["counts"] == {"1박 2일": 5, "2박 3일": 5}
rows = await _rows(pid)
assert {r.duration for r in rows} == {"1박 2일", "2박 3일"}
assert all(r.generated_by == SourceType.LLM.value for r in rows)
assert all(r.model == f"perplexity:{perplexity.DEFAULT_MODEL}" for r in rows)
async def test_does_not_call_again_for_durations_already_stored(db_engine, spy_perplexity):
"""검증: 한 번 만든 업장을 다시 부른다.
기대결과: 추가 호출 0회 — 유료 호출을 두 번 하지 않는다(요금 가드)."""
calls, _ = spy_perplexity
pid = uuid.uuid4()
await service.ensure_generated(_FakePlace(pid))
calls.clear()
out = await service.ensure_generated(_FakePlace(pid))
assert calls == []
assert out["counts"] == {}
assert "이미 있다" in " ".join(out["notes"])
async def test_fills_only_the_missing_duration(db_engine, spy_perplexity):
"""검증: 1박2일만 있는 업장.
기대결과: 2박3일만 부른다 — missing_durations 가 기준이다."""
calls, _ = spy_perplexity
pid = uuid.uuid4()
await service.ensure_generated(_FakePlace(pid))
# 2박 3일 행만 지운다(직접 SQL — 서비스에 삭제 창구가 없다)
from sqlalchemy import delete
await DB_SESSION_MNG.execute_lambda_run(
[place_itineraries.DBType()],
[lambda s: DB_SESSION_MNG.add(s, delete(place_itineraries).where(
place_itineraries.place_id == pid, place_itineraries.duration == "2박 3일"))],
)
calls.clear()
out = await service.ensure_generated(_FakePlace(pid))
assert len(calls) == service.MAX_ATTEMPTS
assert all("2박 3일" in c["messages"][1]["content"] for c in calls)
assert out["counts"] == {"2박 3일": 5}
async def test_skips_when_region_is_unknown(db_engine, spy_perplexity):
"""검증: 주소가 없는 업장.
기대결과: 호출하지 않는다 — 지역을 모른 채 물으면 모델이 아무 도시나 고른다."""
calls, _ = spy_perplexity
place = _FakePlace(uuid.uuid4(), road_address="")
place.address = ""
out = await service.ensure_generated(place)
assert calls == []
assert "지역" in " ".join(out["notes"])
async def test_skips_when_key_is_missing(db_engine, monkeypatch):
"""검증: PERPLEXITY_API_KEY 미설정.
기대결과: 조용히 건너뛴다 — 키가 없다고 빌드나 에디터를 막지 않는다."""
monkeypatch.setattr(perplexity, "is_configured", lambda: False)
out = await service.ensure_generated(_FakePlace(uuid.uuid4()))
assert out["counts"] == {}
assert "PERPLEXITY_API_KEY" in " ".join(out["notes"])
async def test_one_duration_failing_does_not_lose_the_other(db_engine, spy_perplexity):
"""검증: 1박2일 호출이 실패한다.
기대결과: 2박3일은 저장된다 — 한 기간의 실패가 다른 기간을 끌고 내려가지 않는다."""
calls, plan = spy_perplexity
plan["1박 2일"] = "error"
pid = uuid.uuid4()
out = await service.ensure_generated(_FakePlace(pid))
assert out["counts"] == {"2박 3일": 5}
assert [r.duration for r in await _rows(pid)] == ["2박 3일"]
async def test_unparseable_response_stores_nothing_for_that_duration(db_engine, spy_perplexity):
"""검증: JSON 이 아닌 응답.
기대결과: 그 기간은 저장하지 않는다(빈 body 로 행을 만들지 않는다)."""
_calls, plan = spy_perplexity
plan["2박 3일"] = "garbage"
pid = uuid.uuid4()
out = await service.ensure_generated(_FakePlace(pid))
assert out["counts"] == {"1박 2일": 5}
assert [r.duration for r in await _rows(pid)] == ["1박 2일"]
async def test_prompt_carries_place_and_region(db_engine, spy_perplexity):
"""검증: 실제로 보낸 프롬프트.
기대결과: 상호와 지역(주소 앞 두 토막)이 들어간다."""
calls, _ = spy_perplexity
await service.ensure_generated(_FakePlace(uuid.uuid4()))
user_prompt = calls[0]["messages"][1]["content"]
assert "스테이,머뭄" in user_prompt
assert "전북특별자치도 군산시" in user_prompt
assert calls[0]["messages"][0]["content"] == prompts.SYSTEM_PROMPT
assert calls[0]["max_tokens"] == prompts.MAX_TOKENS
async def test_get_itineraries_returns_courses_in_duration_order(db_engine, spy_perplexity):
"""검증: 읽기.
기대결과: DURATIONS 순(1박2일 → 2박3일)으로 이어 붙인 ItineraryItem[] 이다 —
화면 탭 순서가 저장 순서에 흔들리지 않아야 한다."""
pid = uuid.uuid4()
await service.ensure_generated(_FakePlace(pid))
items = await service.get_itineraries(pid)
assert len(items) == 10
assert [i["duration"] for i in items[:5]] == ["1박 2일"] * 5
assert [i["duration"] for i in items[5:]] == ["2박 3일"] * 5
async def test_missing_durations_reports_what_is_absent(db_engine, spy_perplexity):
"""검증: missing_durations.
기대결과: 비었을 때 둘 다, 채운 뒤엔 빈 목록 — 잡 가드가 이 값을 본다."""
pid = uuid.uuid4()
assert await service.missing_durations(pid) == list(prompts.DURATIONS)
await service.ensure_generated(_FakePlace(pid))
assert await service.missing_durations(pid) == []
async def test_ensure_generated_by_id_loads_the_place(db_engine, spy_perplexity, owner_id):
"""검증: 잡이 쓰는 입구(place_id 만 있다).
기대결과: places 행을 읽어 같은 일을 한다."""
calls, _ = spy_perplexity
pid = uuid.uuid4()
from sqlalchemy import text
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO places (place_id, owner_user_id, name, category, road_address, status) "
"VALUES (:p, :o, :n, :c, :r, 1)"),
{"p": pid, "o": uuid.UUID(owner_id), "n": "스테이,머뭄", "c": PlaceCategory.LODGING.value,
"r": "전북특별자치도 군산시 절골길 18"},
)
out = await service.ensure_generated_by_id(pid)
assert len(calls) == 2 * service.MAX_ATTEMPTS
assert out["counts"] == {"1박 2일": 5, "2박 3일": 5}
async def test_ensure_generated_by_id_on_unknown_place(db_engine, spy_perplexity):
"""검증: 없는 업장 id.
기대결과: 호출하지 않고 이유만 남긴다 — 잡이 죽지 않는다."""
calls, _ = spy_perplexity
out = await service.ensure_generated_by_id(uuid.uuid4())
assert calls == []
assert out["counts"] == {}
assert out["notes"]
async def test_lodging_coordinates_flow_from_place_to_generated_stops(db_engine, spy_perplexity):
"""검증: place 에 좌표가 있으면 저장된 일정의 업소 정거장에도 실린다(2026-09-11 결정:
"업소는 출발지에 포함"). 기대결과: ensure_generated 가 place.latitude/longitude 를
grounding 의 _apply_schedule 까지 넘긴다 — 모델에게 묻지 않는다."""
place = _FakePlace(uuid.uuid4())
place.latitude = 35.98642
place.longitude = 126.70612
await service.ensure_generated(place)
items = await service.get_itineraries(place.place_id)
lodging = items[0]["days"][0]["stops"][0]
assert lodging["name"] == "스테이,머뭄"
assert (lodging["latitude"], lodging["longitude"]) == (35.98642, 126.70612)