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

221 lines
9.5 KiB
Python

"""이 숙소의 노래 — SONG 잡이 하는 일."""
import uuid
from pathlib import Path
import httpx
from common.category_schema import CategorySchemaError, get_schema
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_channels, place_facts, place_songs, places
from common.enums import DBWRType, ErrorType, PlaceCategory, SongStatus
from common.logger import LOG
from common.utils.gtime import GTime
from crud.fact_crud import FactCRUD
from crud.place_crud import PlaceCRUD
from crud.song_crud import SongCRUD
from services import place_research, site_payload
from services.external import gemini_text, suno
from services.llm import provider
from services.llm.gemini import GeminiError, GeminiInvalidOutput, GeminiNotConfigured
from common.job_errors import PermanentJobError
_fact_crud = FactCRUD()
_place_crud = PlaceCRUD()
_song_crud = SongCRUD()
# payload 와 나란히 두는 자리.
SONGS_DIRNAME = "songs"
class SongAborted(PermanentJobError):
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남아 운영자가 본다."""
def songs_dir() -> Path:
return site_payload.payload_dir().parent / SONGS_DIRNAME
async def _grounding(place, place_id: str) -> tuple[list[str], str]:
"""가사 재료 — 확인된 fact + 수집/조사 원문, 그리고 소개문."""
schema = get_schema(PlaceCategory(place.category))
pid = uuid.UUID(place_id)
f_err, fact_rows = await DB_SESSION_MNG.execute_lambda(
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: _fact_crud.list_facts(s, pid, None, None, True, True),
)
if f_err != ErrorType.SUCCESS:
raise SongAborted(f"fact 조회 실패: {f_err.name}")
lines: list[str] = []
intro = ""
for row in (fact_rows or []):
value = (row.value or "").strip()
if not value:
continue
if row.key == "intro":
intro = value
continue
if row.key == "meta_description":
continue
spec = schema.get(row.key)
label = spec.label if spec else row.key
lines.append(f"{label}: {value}{(' ' + row.unit) if row.unit else ''}")
# 조사 근거(남이 쓴 글)도 재료다 — 이 숙소의 내력이 거기에만 있는 경우가 많다.
l_err, link_rows = await DB_SESSION_MNG.execute_lambda(
place_channels.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.list_links(s, pid, False),
)
if l_err == ErrorType.SUCCESS:
for link in (link_rows or []):
raw = link.raw if isinstance(link.raw, dict) else {}
if link.confirmed_at is None and raw.get("kind") != place_research.RAW_KIND:
continue
text = (raw.get("text") or "").strip()
if text:
lines.append(text[:1500])
return lines, intro
async def run_song(job: dict) -> dict:
"""SONG 잡 핸들러 — 발행과 무관하게 곡만 다시 만들 때 쓴다."""
payload = job["payload"]
# 이 잡은 "다시 만들어 달라" 는 요청이다 — 기존 곡이 있어도 만든다(ensure_song 주석).
return await ensure_song(payload["place_id"], payload["owner_user_id"], force=True)
async def ensure_song(place_id: str, owner_user_id: str, *, force: bool = False) -> dict:
"""이 업장의 노래를 한 곡 만든다."""
if not suno.is_configured():
# 키가 없는 것은 고장이 아니라 설정이다 — 예외로 올려 재시도·DEAD 로 만들지 않는다.
LOG.i(f"[song] place={place_id} 건너뜀 — SUNO_API_KEY 미설정")
return {"place_id": place_id, "skipped": "SUNO_API_KEY 미설정"}
if not force:
err, ready = await DB_SESSION_MNG.execute_lambda(
place_songs.DBType(),
DBWRType.DB_READ.value,
lambda s: _song_crud.latest_ready(s, uuid.UUID(place_id)),
)
# `execute()` 는 리스트를 준다(limit 1 이라 0개 아니면 1개다) — None 비교로 보지 않는다.
if err == ErrorType.SUCCESS and ready:
LOG.i(f"[song] place={place_id} 건너뜀 — 이미 곡이 있다(재발행은 다시 만들지 않는다)")
return {"place_id": place_id, "skipped": "이미 곡이 있다"}
err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
)
if err != ErrorType.SUCCESS or place is None:
raise SongAborted(f"사업장을 찾을 수 없다: {place_id}")
try:
get_schema(PlaceCategory(place.category))
except (CategorySchemaError, ValueError) as ex:
raise SongAborted(f"지원하지 않는 업종: {place.category}") from ex
lines, intro = await _grounding(place, place_id)
region = site_payload.region_label(place.road_address, place.address) or ""
# ── 1. 가사 ───────────────────────────────────────────────
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) as client:
try:
song = await gemini_text.generate_song(
place.name, PlaceCategory(place.category),
region=region, grounding=lines, intro=intro, client=client,
)
except GeminiNotConfigured:
LOG.i(f"[song] place={place_id} 건너뜀 — {provider.missing_key()} 미설정")
return {"place_id": place_id, "skipped": f"{provider.missing_key()} 미설정"}
except GeminiInvalidOutput as ex:
LOG.i(f"[song] place={place_id} 건너뜀 — {ex}")
return {"place_id": place_id, "skipped": str(ex)}
except GeminiError as ex:
raise SongAborted(f"가사 생성 실패: {ex}") from ex
# 가사를 먼저 적재한다 — 작곡이 실패해도 "무엇을 만들려 했는가" 가 남아야 한다.
row = place_songs(
song_id=uuid.uuid4(),
place_id=uuid.UUID(place_id),
title=song.title[:200],
lyrics=song.lyrics,
style=song.style[:200],
status=SongStatus.GENERATING.value,
)
add_err = await DB_SESSION_MNG.execute_lambda_run(
[place_songs.DBType()], [lambda s: _song_crud.insert(s, row)],
)
if add_err != ErrorType.SUCCESS:
raise SongAborted(f"노래 행 생성 실패: {add_err.name}")
async def _fail(reason: str) -> dict:
await DB_SESSION_MNG.execute_lambda_claim(
place_songs.DBType(),
lambda s: _song_crud.update(s, row.song_id, {
"status": SongStatus.FAILED.value, "last_error": reason[:2000],
}),
)
LOG.w(f"[song] place={place_id} 실패: {reason}")
return {"place_id": place_id, "song_id": str(row.song_id), "error": reason}
# ── 2. 작곡 ───────────────────────────────────────────
try:
task_id = await suno.generate(song.lyrics, title=song.title, style=song.style, client=client)
except suno.SunoNotConfigured as ex:
return await _fail(str(ex))
except suno.SunoError as ex:
return await _fail(f"작곡 요청 실패: {ex}")
await DB_SESSION_MNG.execute_lambda_claim(
place_songs.DBType(),
lambda s: _song_crud.update(s, row.song_id, {"provider_task_id": task_id}),
)
try:
clip = await suno.wait_for_clip(task_id, client=client)
except suno.SunoError as ex:
return await _fail(str(ex))
audio_url = clip.get("audioUrl") or clip.get("sourceAudioUrl") or ""
try:
content = await suno.download(audio_url, client=client)
except httpx.HTTPError as ex:
return await _fail(f"오디오 내려받기 실패: {type(ex).__name__}: {ex}")
# ── 3. 보관 ─────────────────────────────────────────────── 파일명은 song_id 다 — 발행마다 새 곡이 생기므로 이름이 겹치면 옛 곡이 새 곡으로 바뀐다.
file_name = f"{row.song_id}.mp3"
directory = songs_dir()
try:
directory.mkdir(parents=True, exist_ok=True)
tmp = directory / f".{file_name}.tmp"
tmp.write_bytes(content)
# payload 와 같은 이유로 rename 이다 — 프리렌더가 반쯤 쓰인 파일을 집으면 안 된다.
tmp.replace(directory / file_name)
except OSError as ex:
return await _fail(f"파일 저장 실패: {ex}")
duration = clip.get("duration")
await DB_SESSION_MNG.execute_lambda_claim(
place_songs.DBType(),
lambda s: _song_crud.update(s, row.song_id, {
"status": SongStatus.READY.value,
"file_name": file_name,
"origin_url": audio_url[:1000] or None,
"duration_sec": round(float(duration), 2) if isinstance(duration, (int, float)) else None,
"title": (clip.get("title") or song.title)[:200],
}),
)
LOG.i(f"[song] place={place_id} '{song.title}' ({song.style}) 완성 — {len(content)} bytes · {file_name}")
# 여기서 재빌드를 걸지 않는다.
return {
"place_id": place_id, "song_id": str(row.song_id), "title": song.title,
"style": song.style, "file": file_name,
}