[feat] solution/backend: 일정 생성 — 없는 기간만 부르고 같은 업체는 그대로 쓴다

This commit is contained in:
김성경 2026-09-11 11:44:09 +09:00
parent 0f3861c1e4
commit 2824701030
2 changed files with 438 additions and 0 deletions

View File

@ -0,0 +1,192 @@
"""여행 일정 생성 — 업장 × 기간마다 한 번 부르고 그 결과를 그대로 쓴다.
저장하나
예전 일정(services/itinerary.py) 저장하지 않고 빌드마다 즉석 계산했다 거리 계산은
공짜니까 그게 맞았다. LLM 건당 20~50·유료다. 빌드 재생성은 성립하지 않는다.
에디터 요청 안에서 부르지 않나
기간 합쳐 50~100초다. 지역 이야기가 잡으로 도는 이유와 같다
"에디터가 화면을 그리려고 부른 요청 안에서 1분을 붙잡으면 화면이 멈춘 것으로 보인다"
(`local_content_service._ensure_region_stories`). 모듈은 **잡과 빌드에서만** 불린다.
기간 둘을 순차로 부른다
동시에 띄우면 같은 키로 나가는 호출이라 429 떨어진다 업장이 자기 자신을 막는다
(story_service 다섯 종을 순차로 부르는 것과 같은 실측 근거).
실패는 예외로 올리지 않는다
일정은 업장의 사실이 아니라 곁들이는 정보다. 빌드도 에디터도 이것 때문에 멈추지 않는다.
"""
import uuid
import httpx
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, SourceType
from common.logger import LOG
from common.utils.gtime import GTime
from crud.place_itinerary_crud import PlaceItineraryCRUD
from services.grounding import itinerary as grounding
from services.llm import perplexity
from services.prompts import itinerary as prompts
# ★ 이야기(240초)와 같은 값이다. 2박 3일이 48초까지 갔고 변동이 크다(실측 2026-09-11).
_TIMEOUT = httpx.Timeout(240.0, connect=10.0)
_CRUD = PlaceItineraryCRUD()
def region_label_of(place) -> str:
"""프롬프트에 넣을 지명("전북특별자치도 군산시").
주소 토막이 사람이 부르는 이름이다. 주소가 없으면 문자열이고,
그때는 **부르지 않는다** 지역을 모른 물으면 모델이 아무 도시나 고른다
(`story_service.region_label_of` 같은 판단).
"""
address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip()
if not address:
return ""
tokens = address.split()
return " ".join(tokens[:2]) if len(tokens) >= 2 else tokens[0]
async def _stored(place_id) -> dict[str, list]:
"""기간 → 코스 목록. 읽지 못하면 빈 dict(모르는 상태로 유료 호출을 걸지 않는다)."""
err, rows = await DB_SESSION_MNG.execute_lambda(
place_itineraries.DBType(), DBWRType.DB_READ.value,
lambda s: _CRUD.list_by_place(s, place_id),
)
if err != ErrorType.SUCCESS:
LOG.w(f"[itinerary] place={place_id} 조회 실패: {err.name}")
return {}
out: dict[str, list] = {}
for row in rows or []:
body = row.body if isinstance(row.body, list) else []
out[str(row.duration)] = [c for c in body if isinstance(c, dict)]
return out
async def missing_durations(place_id) -> list[str]:
"""아직 없는 기간. 잡 가드(`_ensure_region_stories`)와 생성이 같은 기준을 본다.
"한 행이라도 있으면 건너뛴다" 쓰지 않는다. 기간이 늘어난 기존 업장이 목록에
멈춘다 story_service `has_stories` 하나로 판단하다 `daily` 영영 받던 것과
같은 함정이다(`story_service.missing_kinds` 주석).
"""
have = await _stored(place_id)
return [d for d in prompts.DURATIONS if not have.get(d)]
async def get_itineraries(place_id) -> list[dict]:
"""저장된 코스 전부. **DURATIONS 순**으로 이어 붙인다 —
화면 순서(`ItinerarySection` 적힌 순서를 순서로 쓴다) 저장 순서에 흔들리면 된다."""
have = await _stored(place_id)
out: list[dict] = []
for duration in prompts.DURATIONS:
out += have.get(duration) or []
return out
async def _generate_one(client: httpx.AsyncClient, place_name: str, region: str, duration: str):
"""기간 하나. 실패는 예외로 올리지 않고 (빈 목록, 이유) 로 돌려준다."""
body = {
"model": perplexity.DEFAULT_MODEL,
"messages": [
{"role": "system", "content": prompts.SYSTEM_PROMPT},
{"role": "user", "content": prompts.build_prompt(place_name, region, duration)},
],
"max_tokens": prompts.MAX_TOKENS,
}
try:
payload = await perplexity.call(body, client=client)
except perplexity.PerplexityNotConfigured:
return [], ["PERPLEXITY_API_KEY 미설정"]
except perplexity.PerplexityError as ex:
LOG.w(f"[itinerary] {duration} 호출 실패 place={place_name}: {ex}")
return [], [f"호출 실패: {ex}"]
courses, dropped = grounding.parse_courses(payload, duration)
LOG.i(f"[itinerary] {place_name} {duration}: {len(courses)}개 채택, {len(dropped)}건 버림")
return courses, dropped
async def ensure_generated(place) -> dict:
"""없는 기간만 만들어 저장한다. 기간별 코스 수를 돌려준다.
이미 있는 기간은 부르지 않는다 같은 업체는 그대로 재사용한다(2026-09-11 결정).
기존 행을 먼저 지우지 않는다. 이번 호출이 부실하다고 지난번 결과를 날리지 않는다.
"""
place_id = getattr(place, "place_id", None)
result: dict = {"place_id": str(place_id) if place_id else None, "counts": {}, "notes": []}
if place_id is None:
result["notes"].append("place_id 가 없다")
return result
if not perplexity.is_configured():
result["notes"].append("PERPLEXITY_API_KEY 미설정")
return result
name = str(getattr(place, "name", None) or "").strip()
region = region_label_of(place)
if not name or not region:
result["notes"].append("상호나 지역을 특정할 수 없어 부르지 않는다")
return result
wanted = await missing_durations(place_id)
if not wanted:
result["notes"].append("이미 있다")
return result
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
for duration in wanted:
courses, dropped = await _generate_one(client, name, region, duration)
result["notes"] += [f"{duration}: {d}" for d in dropped]
if not courses:
continue
values = {
"place_itinerary_id": uuid.uuid4(),
"place_id": place_id,
"duration": duration,
"body": courses,
"generated_by": SourceType.LLM.value,
"model": f"perplexity:{perplexity.DEFAULT_MODEL}",
"generated_at": GTime.UTC(),
}
err = await DB_SESSION_MNG.execute_lambda_run(
[place_itineraries.DBType()], [lambda s, v=values: _CRUD.upsert(s, v)],
)
if err != ErrorType.SUCCESS:
LOG.w(f"[itinerary] 저장 실패 place={place_id} {duration}: {err.name}")
result["notes"].append(f"{duration}: 저장 실패 {err.name}")
continue
result["counts"][duration] = len(courses)
LOG.i(f"[itinerary] place={place_id} 완료: {result['counts']}")
return result
async def ensure_generated_by_id(place_id) -> dict:
"""잡이 쓰는 입구 — payload 에는 place_id 만 있다.
주인(owner_user_id)으로 스코프하지 않는다. 잡은 이미 업장의 빌드/수집을 하는 중이고,
여기서 주인을 요구하면 payload 주인을 실어 보내야 한다(`sync_place_by_id` 같은 판단).
"""
from sqlalchemy import select
# ★ `local_content_service._load_place` 와 같은 방식이다 — 단건 조회 헬퍼는 없고
# execute(...).limit(1) 로 받아 첫 행을 쓴다.
err, rows = await DB_SESSION_MNG.execute_lambda(
places.DBType(), DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(
s,
select(places).where(
places.place_id == place_id, places.deleted == False # noqa: E712
).limit(1),
),
)
place = (rows[0] if rows else None) if err == ErrorType.SUCCESS else None
if place is None:
LOG.w(f"[itinerary] place={place_id} 사업장을 찾을 수 없어 건너뛴다")
return {"place_id": str(place_id), "counts": {}, "notes": ["사업장을 찾을 수 없다"]}
return await ensure_generated(place)

View File

@ -0,0 +1,246 @@
"""일정 생성 오케스트레이션 — 언제 부르고 언제 안 부르나.
실제 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
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:
items = [
{"name": n, "duration": duration, "audience": "누구에게나", "why": "이유.",
"days": [{"label": "1일차", "startTime": "14:00",
"stops": [{"name": f"{n}-장소{i}", "minutes": 60, "moveMinutes": 10,
"searchQuery": f"{n}-장소{i}",
"latitude": 35.99, "longitude": 126.71} for i in range(3)]}]}
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):
"""검증: 아무것도 없는 업장.
기대결과: 기간 둘을 각각 번씩 부르고( 2) 각각 5 코스를 저장한다."""
calls, _ = spy_perplexity
pid = uuid.uuid4()
out = await service.ensure_generated(_FakePlace(pid))
assert len(calls) == 2
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) == 1
assert "2박 3일" in calls[0]["messages"][1]["content"]
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
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"]