o2o-site-AEO/solution/backend/services/itinerary_llm_service.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

205 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""여행 일정 생성 — 업장 × 기간마다 한 번 부르고 그 결과를 그대로 쓴다."""
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초)와 같은 값이다.
_TIMEOUT = httpx.Timeout(240.0, connect=10.0)
# 10개(컨셉당 2개) — 프롬프트가 요구하는 개수와 같다(`prompts.itinerary._TASK`).
TARGET_COURSES = 10
# 사장님 지시: 2회로 제한한다.
MAX_ATTEMPTS = 2
_CRUD = PlaceItineraryCRUD()
def region_label_of(place) -> str:
"""프롬프트에 넣을 지명("전북특별자치도 군산시")."""
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]:
"""기간 → 코스 목록."""
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]:
"""아직 없는 기간."""
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]:
"""저장된 코스 전부."""
have = await _stored(place_id)
out: list[dict] = []
for duration in prompts.DURATIONS:
out += have.get(duration) or []
return out
def _as_float(value) -> float | None:
try:
return float(value) if value is not None else None
except (TypeError, ValueError):
return None
async def _generate_one(
client: httpx.AsyncClient, place_name: str, region: str, duration: str,
place_lat: float | None = None, place_lng: float | None = None,
):
"""기간 하나."""
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,
}
courses: list[dict] = []
seen: set[frozenset[str]] = set()
notes: list[str] = []
total_in = total_out = 0
total_cost = 0.0
for attempt in range(1, MAX_ATTEMPTS + 1):
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} {attempt}차 호출 실패 place={place_name}: {ex}")
notes.append(f"{attempt}차 호출 실패: {ex}")
break # 같은 오류가 반복될 걸 재시도로 밀어붙이지 않는다 — 지금까지 모은 것만 쓴다
usage = perplexity.read_usage(payload)
total_in += usage.input_tokens
total_out += usage.output_tokens
total_cost += usage.cost
new_courses, dropped = grounding.parse_courses(
payload, duration, place_name, place_lat, place_lng, already_seen=seen)
notes += dropped
for course in new_courses:
if len(courses) >= TARGET_COURSES:
break
courses.append(course)
seen.add(grounding.stop_signature(course))
if len(courses) >= TARGET_COURSES:
break
if len(courses) < TARGET_COURSES:
notes.append(f"{MAX_ATTEMPTS}차 시도 후에도 {len(courses)}/{TARGET_COURSES}개만 채웠다")
LOG.i(
f"[itinerary] {place_name} {duration}: {len(courses)}개 채택, {len(notes)}건 버림/안내 · "
f"tokens in={total_in} out={total_out} · 약 ${round(total_cost, 6)}"
)
return courses, notes
async def ensure_generated(place) -> dict:
"""없는 기간만 만들어 저장한다."""
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
lat = _as_float(getattr(place, "latitude", None))
lng = _as_float(getattr(place, "longitude", None))
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, lat, lng)
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 만 있다."""
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)