[feat] solution/backend: 일정 생성 — 없는 기간만 부르고 같은 업체는 그대로 쓴다
This commit is contained in:
parent
0f3861c1e4
commit
2824701030
192
solution/backend/services/itinerary_llm_service.py
Normal file
192
solution/backend/services/itinerary_llm_service.py
Normal 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)
|
||||
246
solution/backend/tests/test_itinerary_llm_service.py
Normal file
246
solution/backend/tests/test_itinerary_llm_service.py
Normal 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"]
|
||||
Loading…
Reference in New Issue
Block a user