여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
205 lines
7.5 KiB
Python
205 lines
7.5 KiB
Python
"""여행 일정 생성 — 업장 × 기간마다 한 번 부르고 그 결과를 그대로 쓴다."""
|
||
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)
|