좌표 + 온보딩(여행 상태·무드) → 추천 곡 10 + 배경 사진 1을 돌려주는 FastAPI 서비스. 2026 관광데이터 활용 공모전 출품작의 ML 컴포넌트다.
POST /recommend {x, y, state, mood}
│
▼
poi_select.pick_poi ──── 자체 카탈로그 512곳에서 대표 POI 1개 (API 0회)
│ 거리 0.45 · 타입 0.20 · 대표성 0.15 재랭킹
│ + 별칭/부모 collapse (근정전 → 경복궁)
├─ 없음(5km 밖) → tour_api.nearby_poi (관광공사가 메움)
│ └ 그래도 없음 → 곡: 무드 100% · 사진: null
│ 둘 중 어느 쪽이든 contentid 있으면 detailCommon2로 개요 보강
▼
┌─ 곡 ──────────────────────────┐ ┌─ 사진 ────────────────────────┐
│ soundlog_place.decide │ │ photo_recommend.pick_photo │
│ └ 지원 축일 때만 POI α=0.3 │ │ ① POI 대표 사진(firstimage) │
│ KURE 임베딩 → 출처 매칭 │ │ ② 갤러리 5,043장 임베딩 매칭 │
│ → 독점 상한 그리디 → 곡 10 │ │ (장소>시군구>시도 게이트) │
└────────────────────────────────┘ │ ③ null → 프론트 그라데이션 │
└───────────────────────────────┘
▼
{tracks, backgroundImageUrl, poi, meta} ← 응답 계약은 API.md
POST /photo {x, y} ← 리캡 배경 전용
│
└─ 위 흐름의 왼쪽(곡)을 건너뛰고 POI→사진만 탄다.
같은 좌표면 /recommend가 주는 배경과 같은 사진이 나온다 —
poi_payload()를 공유해서 둘이 갈라지지 않는다.
내보내는 URL은 항상 https (iOS ATS 차단 회피).
곡과 사진이 같은 POI를 본다 — 배경은 해수욕장인데 곡은 옆 음식점
기준이 되는 어긋남을 없애기 위해서다. 관광공사 TourAPI는 더 이상 부르지
않는다(대표 장소를 구조적으로 누락해서 자체 카탈로그로 피벗 —
recommend.py 상단 주석에 근거).
| 경로 | 역할 |
|---|---|
main.py |
FastAPI 서버. 응답 조립(곡+사진), 헬스 체크 |
recommend.py |
곡 추천 파이프라인. POI→축 판정→α블렌드→매칭→상한 그리디 |
tour_api.py |
요청 시점 관광공사 — 카탈로그 밖 좌표 폴백 + POI 개요 보강 |
embedder.py |
KURE 단일 로드 지점. 곡·사진이 같은 모델 인스턴스를 공유 |
poi_select.py |
좌표→대표 POI. 카탈로그·별칭/부모 맵. 요청당 ~1ms |
photo_recommend.py |
POI→배경 사진. firstimage→갤러리 3단계 게이트→null |
soundlog_place.py |
장소 축 8종 어휘·지원 판정(decide). 부족 축은 무드만 |
soundlog_catalog.py |
서빙 DB 로더. excluded/canon/🎤접두사 교정 + 임베딩 캐시 |
soundlog.db |
서빙 DB (v3: 출처 695 · 곡 5,095행 · 고유 곡 3,149 · 채널 25) |
data/poi_catalog_v3.csv |
POI 카탈로그 805행(좌표 확보 512곳 로드) |
data/photos_ready.csv |
관광공사 갤러리 사진 5,043장 메타 (죽은 URL 667장 제거 후) |
data/photo_embs.npy |
위 사진의 KURE 임베딩 (5043×1024, ~20MB) |
tests/ |
test_swap.py(회귀 86검사) · try_poi.py · smoke_api.py |
tools/ |
build_serve_tables.py · make_deploy_zip.py · make_fixture_db.py |
rollback/ |
이전 서빙 DB. rollback_originals/엔 통합 전 py 3종 |
:: 로컬 (Windows)
.venv\Scripts\activate
uvicorn main:app --port 8000# 서버 (Ubuntu / Naver Cloud)
nohup .venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 >> server.log 2>&1 &첫 기동은 KURE 로드 + 임베딩 캐시 확인으로 수십 초 걸린다.
로그에 준비 완료 — 요청 받을 수 있음이 찍히면 준비된 것이다.
기동이 곧 자기 진단이다 — 로그에 카탈로그 교정 내역(0건이어도), 장소 축 지원표(4/8), POI 카탈로그·별칭·부모 수가 전부 찍힌다. 침묵하는 단계가 없으니, 로그에 없는 건 안 돈 것이다.
curl http://127.0.0.1:8000/
| 필드 | 기대값 | 다르면 |
|---|---|---|
catalog.uniqueTracks |
3,149 | 다른 DB를 물고 있음 (.env의 ML_CATALOG_DB) |
catalog.channels |
25 | 〃 |
catalog.corrections.* |
0 아님 | build_serve_tables.py 안 돌린 DB |
poi.count |
512 | data/poi_catalog_v3.csv 누락/미배치 |
photos.count |
5,043 | data/photos_ready.csv 누락 |
tourApi.enabled |
true | false면 .env에 TOUR_API_KEY가 없음 |
photos.embMatched |
true | false면 기동 시 임베딩 재계산됨(수 분) — npy 행 불일치 |
poiCache 섹션이 보이면 구버전 main.py다.
| 변수 | 기본 | 뜻 |
|---|---|---|
ML_CATALOG_DB |
soundlog_serve.db(파일 옆) |
서빙 DB 경로. 이 폴더는 soundlog.db 사용 |
ML_ALPHA |
0.3 | 곡 쿼리에서 POI 비중 (지원 축일 때만) |
ML_AXIS_MIN_TRACKS / ML_AXIS_MIN_SOURCES |
40 / 10 | 축 지원 문턱 |
ML_PER_VIDEO_CAP / ML_PER_ARTIST_CAP |
3 / 2 | 곡 독점 상한 |
ML_POSITION_DECAY |
0.01 | 같은 출처 내 수록순 미세 감쇠 |
ML_MAX_SEQ_LEN |
256 | KURE 입력 길이 |
ML_EMBED_MODEL |
nlpai-lab/KURE-v1 | 임베딩 모델 |
ML_EXCLUDE_CHANNELS |
(빈값) | 채널 제외(부분일치, 쉼표) |
TOUR_API_KEY |
(없음) | 관광공사 키. 없으면 폴백·보강이 조용히 꺼진다 |
ML_TOUR_API |
1 | TourAPI 전체 스위치 |
ML_TOUR_FALLBACK / ML_TOUR_DETAIL |
1 / 1 | 폴백·보강 개별 스위치 |
ML_TOUR_BUDGET |
2.0 | 호출당 예산(초). urllib timeout은 소켓 단위라 상한이 아니다 |
ML_TOUR_DETAIL_BLOCKING |
0 | 1이면 개요를 응답에서 기다린다(실측 최대 5.9초 — 권장 안 함) |
ML_TOUR_OVERVIEW_QUERY |
0 | 1이면 개요를 쿼리 벡터에까지 투입. 실측상 광안리 곡이 나빠지고 응답이 160→1,200ms — 권장 안 함. 축 탐지는 이 값과 무관하게 항상 개요를 쓴다 |
사라진 변수 — ML_POI_BUDGET/CACHE_TTL/CACHE_MAX/GRID/TYPE_WEIGHT/ DIST_HALF. POI 선택이 카탈로그로 옮겨가 요청당 호출 예산이 필요 없어졌다
(TourAPI 예산은 ML_TOUR_BUDGET이 따로 관리한다). 사진 쪽 상수(PHOTO_ALPHA=0.7, 게이트
문턱)는 env가 아니라 photo_recommend.py 상단에 근거와 함께 있다.
- TESTING.md — 기능별 테스트 명령·기대 출력·트러블슈팅
- API.md —
/recommend응답 계약. 백엔드에 이 파일만 전달하면 된다 - 설계 근거는 각 파일 상단 주석에 그 자리에서 남겼다 (왜 TourAPI를 버렸나 → recommend.py, 왜 부족 축은 안 섞나 → soundlog_place.py, 왜 POI 선택에 임베딩을 안 쓰나 → poi_select.py, 사진 게이트가 왜 3단계인가 → photo_recommend.py)